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.Handlerinstance. 
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.
testing/synctest helper aka tdsynctest
The package
tdsynctest
allows to use testing/synctest
on steroids thanks to go-testdeep.
Helpers utils aka tdutil
To write helpers, some commonly used functions are shared in
tdutil
package.