Helpers
HTTP API test helper aka 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 standard, see Main example below
- Beego
- echo
- Gin
- gorilla/mux
- go-swagger
- HttpRouter
- pat
- and any other ones as long as they provide a
net/http.Handler
instance.
See examples of use in
tdhttp
package example section,
in
FAQ
or expand the one below:
Main example
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) //nolint: errcheck
}
}
// 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() //nolint: errcheck
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())
}
Tests suite helper aka tdsuite
The package
tdsuite
adds tests suite feature to go-testdeep in a non-intrusive way, but
easily and powerfully.
Helpers utils aka tdutil
To write helpers, some commonly used functions are shared in
tdutil
package.