tdhttp
The
tdhttp
package helper allows to easily test HTTP APIs.
It handles any kind of API, with some specific features for the routes accepting and/or returning JSON or XML.
All known web frameworks are handled:
net/http.Handler
instance.See examples of use in
tdhttp
package example section,
in
FAQ
or expand the one below:
package myapi
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/maxatome/go-testdeep/helpers/tdhttp"
"github.com/maxatome/go-testdeep/td"
)
func TestMyAPI(t *testing.T) {
// Our API handle Persons with 3 routes:
// - POST /person
// - GET /person/{personID}
// - DELETE /person/{personID}
// Person describes a person.
type Person struct {
ID int64 `json:"id,omitempty" xml:"ID,omitempty"`
Name string `json:"name" xml:"Name"`
Age int `json:"age" xml:"Age"`
CreatedAt *time.Time `json:"created_at,omitempty" xml:"CreatedAt,omitempty"`
}
// Error is returned to the client in case of error.
type Error struct {
Mesg string `json:"message" xml:"Message"`
Code int `json:"code" xml:"Code"`
}
// Our µDB :)
var mu sync.Mutex
personByID := map[int64]*Person{}
personByName := map[string]*Person{}
var lastID int64
// reply is a helper to send responses.
reply := func(w http.ResponseWriter, status int, contentType string, body any) {
if body == nil {
w.WriteHeader(status)
return
}
w.Header().Set("Content-Type", contentType)
w.WriteHeader(status)
switch contentType {
case "application/json":
json.NewEncoder(w).Encode(body) //nolint: errcheck
case "application/xml":
xml.NewEncoder(w).Encode(body) //nolint: errcheck
default: // text/plain
fmt.Fprintf(w, "%+v", body)
}
}
// Our API
mux := http.NewServeMux()
// POST /person
mux.HandleFunc("/person", func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if req.Body == nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
defer req.Body.Close()
var in Person
var contentType string
switch req.Header.Get("Content-Type") {
case "application/json":
err := json.NewDecoder(req.Body).Decode(&in)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
case "application/xml":
err := xml.NewDecoder(req.Body).Decode(&in)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
case "application/x-www-form-urlencoded":
b, err := io.ReadAll(req.Body)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
v, err := url.ParseQuery(string(b))
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
in.Name = v.Get("name")
in.Age, err = strconv.Atoi(v.Get("age"))
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
default:
http.Error(w, "Unsupported media type", http.StatusUnsupportedMediaType)
return
}
contentType = req.Header.Get("Accept")
if in.Name == "" || in.Age <= 0 {
reply(w, http.StatusBadRequest, contentType, Error{
Mesg: "Empty name or bad age",
Code: http.StatusBadRequest,
})
return
}
mu.Lock()
defer mu.Unlock()
if personByName[in.Name] != nil {
reply(w, http.StatusConflict, contentType, Error{
Mesg: "Person already exists",
Code: http.StatusConflict,
})
return
}
lastID++
in.ID = lastID
now := time.Now()
in.CreatedAt = &now
personByID[in.ID] = &in
personByName[in.Name] = &in
reply(w, http.StatusCreated, contentType, in)
})
// GET /person/{id}
// DELETE /person/{id}
mux.HandleFunc("/person/", func(w http.ResponseWriter, req *http.Request) {
id, err := strconv.ParseInt(strings.TrimPrefix(req.URL.Path, "/person/"), 10, 64)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
accept := req.Header.Get("Accept")
mu.Lock()
defer mu.Unlock()
if personByID[id] == nil {
reply(w, http.StatusNotFound, accept, Error{
Mesg: "Person does not exist",
Code: http.StatusNotFound,
})
return
}
switch req.Method {
case http.MethodGet:
reply(w, http.StatusOK, accept, personByID[id])
case http.MethodDelete:
delete(personByID, id)
reply(w, http.StatusNoContent, "", nil)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
//
// Let's test our API
//
ta := tdhttp.NewTestAPI(t, mux)
// Re-usable custom operator to check Content-Type header
contentTypeIs := func(ct string) td.TestDeep {
return td.SuperMapOf(http.Header{"Content-Type": []string{ct}}, nil)
}
//
// Person not found
//
ta.Get("/person/42", "Accept", "application/json").
Name("GET /person/42 - JSON").
CmpStatus(404).
CmpHeader(contentTypeIs("application/json")).
CmpJSONBody(Error{
Mesg: "Person does not exist",
Code: 404,
})
t.Log("GET /person/42 - JSON:", !ta.Failed())
ta.Get("/person/42", "Accept", "application/xml").
Name("GET /person/42 - XML").
CmpStatus(404).
CmpHeader(contentTypeIs("application/xml")).
CmpXMLBody(Error{
Mesg: "Person does not exist",
Code: 404,
})
t.Log("GET /person/42 - XML:", !ta.Failed())
ta.Get("/person/42", "Accept", "text/plain").
Name("GET /person/42 - raw").
CmpStatus(404).
CmpHeader(contentTypeIs("text/plain")).
CmpBody("{Mesg:Person does not exist Code:404}")
t.Log("GET /person/42 - raw:", !ta.Failed())
//
// Create a Person
//
var bobID int64
ta.PostXML("/person", Person{Name: "Bob", Age: 32},
"Accept", "application/xml").
Name("POST /person - XML").
CmpStatus(201).
CmpHeader(contentTypeIs("application/xml")).
CmpXMLBody(Person{ // using operator anchoring directly in literal
ID: ta.A(td.Catch(&bobID, td.NotZero()), int64(0)).(int64),
Name: "Bob",
Age: 32,
CreatedAt: ta.A(td.Ptr(td.Between(ta.SentAt(), time.Now()))).(*time.Time),
})
t.Logf("POST /person - XML: %t → Bob ID=%d\n", !ta.Failed(), bobID)
var aliceID int64
ta.PostJSON("/person", Person{Name: "Alice", Age: 35},
"Accept", "application/json").
Name("POST /person - JSON").
CmpStatus(201).
CmpHeader(contentTypeIs("application/json")).
CmpJSONBody(td.JSON(` // using JSON operator (yes comment allowed in JSON!)
{
"id": $1,
"name": "Alice",
"age": 35,
"created_at": $2
}`,
td.Catch(&aliceID, td.NotZero()),
td.Smuggle(func(date string) (time.Time, error) {
return time.Parse(time.RFC3339Nano, date)
}, td.Between(ta.SentAt(), time.Now()))))
t.Logf("POST /person - JSON: %t → Alice ID=%d\n", !ta.Failed(), aliceID)
var brittID int64
ta.PostForm("/person",
url.Values{
"name": []string{"Britt"},
"age": []string{"29"},
},
"Accept", "text/plain").
Name("POST /person - raw").
CmpStatus(201).
CmpHeader(contentTypeIs("text/plain")).
// using Re (= Regexp) operator
CmpBody(td.Re(`\{ID:(\d+) Name:Britt Age:29 CreatedAt:.*\}\z`,
td.Smuggle(func(groups []string) (int64, error) {
return strconv.ParseInt(groups[0], 10, 64)
}, td.Catch(&brittID, td.NotZero()))))
t.Logf("POST /person - raw: %t → Britt ID=%d\n", !ta.Failed(), brittID)
//
// Get a Person
//
ta.Get(fmt.Sprintf("/person/%d", aliceID), "Accept", "application/xml").
Name("GET Alice - XML (ID #%d)", aliceID).
CmpStatus(200).
CmpHeader(contentTypeIs("application/xml")).
CmpXMLBody(td.SStruct( // using SStruct operator
Person{
ID: aliceID,
Name: "Alice",
Age: 35,
},
td.StructFields{
"CreatedAt": td.Ptr(td.NotZero()),
},
))
t.Log("GET XML Alice:", !ta.Failed())
ta.Get(fmt.Sprintf("/person/%d", aliceID), "Accept", "application/json").
Name("GET Alice - JSON (ID #%d)", aliceID).
CmpStatus(200).
CmpHeader(contentTypeIs("application/json")).
CmpJSONBody(td.JSON(` // using JSON operator (yes comment allowed in JSON!)
{
"id": $1,
"name": "Alice",
"age": 35,
"created_at": $2
}`,
aliceID,
td.Not(td.Re(`^0001-01-01`)), // time is not 0001-01-01… aka zero time.Time
))
t.Log("GET JSON Alice:", !ta.Failed())
//
// Delete a Person
//
ta.Delete(fmt.Sprintf("/person/%d", aliceID), nil).
Name("DELETE Alice (ID #%d)", aliceID).
CmpStatus(204).
CmpHeader(td.Not(td.ContainsKey("Content-Type"))).
NoBody()
t.Log("DELETE Alice:", !ta.Failed())
// Check Alice is deleted
ta.Get(fmt.Sprintf("/person/%d", aliceID), "Accept", "application/json").
Name("GET (deleted) Alice - JSON (ID #%d)", aliceID).
CmpStatus(404).
CmpHeader(contentTypeIs("application/json")).
CmpJSONBody(td.JSON(`
{
"message": "Person does not exist",
"code": 404
}`))
t.Log("Alice is not found anymore:", !ta.Failed())
}
tdsuite
The package
tdsuite
adds tests suite feature to go-testdeep in a non-intrusive way, but
easily and powerfully.
tdutil
To write helpers, some commonly used functions are shared in
tdutil
package.