Documentation
¶
Overview ¶
Package apitest tests an http.Handler through a real HTTP client and an in-memory server.
A test states a request and what a client must see:
func TestGetArticle(t *testing.T) {
api := apitest.Make(t, handler)
api.Get("/articles/42").Do().
Status(http.StatusOK).
Header("Content-Type", "application/json").
JSONEqual(map[string]any{
"data": map[string]any{"id": 42, "title": "HTTP boundaries"},
})
}
The request crosses a real HTTP connection, so routing, the headers net/http adds, the status and the bytes of the body are what a client gets in production. There is no network: Make starts a server from httptest.NewTestServer and stops it when the test ends. A panic in the handler fails the test.
Requests ¶
Test starts a request with Get, Post, Put, Patch, Delete, Head or Options, or with any method through Test.Request. A Request sets headers, query parameters, cookies, the Host, a context and at most one body: Request.JSON encodes a value, and Request.Body sends raw bytes with any media type, for example malformed JSON. Nothing is sent before Request.Do, which reads and closes the whole response.
The client follows no redirects and keeps no cookies, so every response is the handler's own answer. A request fails after five seconds, or the duration set with WithTimeout, and always before the deadline of the test, so a handler that hangs fails with the request in the message.
Assertions ¶
A Response checks the status, the headers and the body:
- Response.Status stops the test on a mismatch and prints the body, because a response with another status has another shape;
- Response.Header and Response.HeaderAbsent check header values in order;
- Response.BodyEqual compares the exact body;
- Response.JSONEqual compares the body with a value encoded by encoding/json.
JSONEqual ignores object key order and whitespace, and nothing else. Extra and missing fields, array order and number literals count, so 1 and 1.0 differ as they do for a typed client. A body with a duplicate object key fails, because parsers disagree on which value wins. A mismatch lists up to ten differences by path, such as $.data.items[0].title.
Response.JSON decodes the body into a type for checks that need code, such as a generated identifier, and Response.Raw returns the buffered *http.Response.
Parallel tests ¶
A Test reports through the testing.TB it was made with. Make one for each parallel subtest, so that a failure points at the subtest and its line.
Example ¶
package main
import (
"net/http"
"testing"
"github.com/uchaloop/httpx/apitest"
"github.com/uchaloop/httpx/problem"
"github.com/uchaloop/httpx/request"
"github.com/uchaloop/httpx/response"
)
type exampleArticle struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
mapper, err := problem.MakeMapper(problem.InputProblemRule())
if err != nil {
panic(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "42" {
_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))
return
}
_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
})
mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
input, err := request.DecodeJSON[exampleArticle](r)
if err != nil {
_ = problem.Write(w, mapper.Map(r, err))
return
}
input.ID = 42
_ = response.Write(w, response.Created("/articles/42", input))
})
return mux
}
func main() {
var t *testing.T // the t of the test function
api := apitest.Make(t, exampleHandler())
api.Get("/articles/42").Do().
Status(http.StatusOK).
Header("Content-Type", "application/json").
JSONEqual(map[string]any{
"data": map[string]any{"id": 42, "title": "HTTP boundaries"},
})
api.Get("/articles/7").Do().
Status(http.StatusNotFound).
Header("Content-Type", problem.ContentType).
JSONEqual(map[string]any{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"instance": "/articles/7",
})
}
Output:
Index ¶
- type Option
- type Request
- func (r *Request) Body(contentType string, body io.Reader) *Request
- func (r *Request) Context(ctx context.Context) *Request
- func (r *Request) Cookie(cookie *http.Cookie) *Request
- func (r *Request) Do() *Response
- func (r *Request) Header(name string, values ...string) *Request
- func (r *Request) Host(host string) *Request
- func (r *Request) JSON(value any) *Request
- func (r *Request) Query(name string, values ...string) *Request
- type Response
- func (r *Response) BodyEqual(expected string) *Response
- func (r *Response) Header(name string, values ...string) *Response
- func (r *Response) HeaderAbsent(name string) *Response
- func (r *Response) JSON[T any]() T
- func (r *Response) JSONEqual(expected any) *Response
- func (r *Response) Raw() *http.Response
- func (r *Response) Status(code int) *Response
- type Test
- func (api *Test) Delete(path string) *Request
- func (api *Test) Get(path string) *Request
- func (api *Test) Head(path string) *Request
- func (api *Test) Options(path string) *Request
- func (api *Test) Patch(path string) *Request
- func (api *Test) Post(path string) *Request
- func (api *Test) Put(path string) *Request
- func (api *Test) Request(method, path string) *Request
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Option ¶
type Option func(*Test)
Option configures a Test.
func WithTimeout ¶
WithTimeout sets the maximum duration of each request, including reading the response body. The default is five seconds.
Example ¶
package main
import (
"net/http"
"testing"
"time"
"github.com/uchaloop/httpx/apitest"
"github.com/uchaloop/httpx/problem"
"github.com/uchaloop/httpx/request"
"github.com/uchaloop/httpx/response"
)
type exampleArticle struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
mapper, err := problem.MakeMapper(problem.InputProblemRule())
if err != nil {
panic(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "42" {
_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))
return
}
_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
})
mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
input, err := request.DecodeJSON[exampleArticle](r)
if err != nil {
_ = problem.Write(w, mapper.Map(r, err))
return
}
input.ID = 42
_ = response.Write(w, response.Created("/articles/42", input))
})
return mux
}
func main() {
var t *testing.T // the t of the test function
// A slow endpoint gets more than the default five seconds.
api := apitest.Make(t, exampleHandler(), apitest.WithTimeout(30*time.Second))
api.Get("/articles/42").Do().Status(http.StatusOK)
}
Output:
type Request ¶
type Request struct {
// contains filtered or unexported fields
}
Request is a single-use request builder. Nothing is sent before Do. It is not safe for concurrent use.
func (*Request) Body ¶
Body sets a raw body, for example malformed JSON, with media type contentType. An empty contentType sends no Content-Type. A request has at most one body.
Example ¶
package main
import (
"net/http"
"strings"
"testing"
"github.com/uchaloop/httpx/apitest"
"github.com/uchaloop/httpx/problem"
"github.com/uchaloop/httpx/request"
"github.com/uchaloop/httpx/response"
)
type exampleArticle struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
mapper, err := problem.MakeMapper(problem.InputProblemRule())
if err != nil {
panic(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "42" {
_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))
return
}
_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
})
mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
input, err := request.DecodeJSON[exampleArticle](r)
if err != nil {
_ = problem.Write(w, mapper.Map(r, err))
return
}
input.ID = 42
_ = response.Write(w, response.Created("/articles/42", input))
})
return mux
}
func main() {
var t *testing.T // the t of the test function
api := apitest.Make(t, exampleHandler())
// Malformed JSON is sent as is.
api.Post("/articles").Body("application/json", strings.NewReader(`{"title":`)).Do().
Status(http.StatusBadRequest).
Header("Content-Type", problem.ContentType)
// So is a body in a media type the endpoint does not read.
api.Post("/articles").Body("text/plain", strings.NewReader("title=HTTP")).Do().
Status(http.StatusUnsupportedMediaType).
Header("Accept", "application/json")
}
Output:
func (*Request) Context ¶
Context sets the parent context of the request, for example one that a mock cancels on an unexpected call. Cancellation reaches the handler; context values do not cross the HTTP connection.
func (*Request) Do ¶
Do sends the request once, then reads and closes the complete response body. Transport, timeout and read failures stop the test.
func (*Request) Header ¶
Header sets all values of a request header, replacing earlier ones. An explicit Content-Type overrides the media type set by JSON or Body. Use Host for the Host header: net/http ignores it in Header.
func (*Request) Host ¶
Host sets the Host header, for example for host-based routing. The default is example.com.
func (*Request) JSON ¶
JSON sets the body to value encoded by encoding/json, with media type application/json. A request has at most one body.
Example ¶
package main
import (
"net/http"
"testing"
"github.com/uchaloop/httpx/apitest"
"github.com/uchaloop/httpx/problem"
"github.com/uchaloop/httpx/request"
"github.com/uchaloop/httpx/response"
)
type exampleArticle struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
mapper, err := problem.MakeMapper(problem.InputProblemRule())
if err != nil {
panic(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "42" {
_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))
return
}
_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
})
mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
input, err := request.DecodeJSON[exampleArticle](r)
if err != nil {
_ = problem.Write(w, mapper.Map(r, err))
return
}
input.ID = 42
_ = response.Write(w, response.Created("/articles/42", input))
})
return mux
}
func main() {
var t *testing.T // the t of the test function
apitest.Make(t, exampleHandler()).Post("/articles").
JSON(map[string]any{"title": "HTTP boundaries"}).
Do().
Status(http.StatusCreated).
Header("Location", "/articles/42").
JSONEqual(map[string]any{
"data": map[string]any{"id": 42, "title": "HTTP boundaries"},
})
}
Output:
type Response ¶
type Response struct {
// contains filtered or unexported fields
}
Response holds a buffered response. Assertions and Raw never consume it.
func (*Response) HeaderAbsent ¶
HeaderAbsent asserts that the response has no such header.
func (*Response) JSON ¶
JSON decodes the body into T as a client using encoding/json would: unknown fields are allowed and the body must hold exactly one JSON value. A decode failure stops the test.
Example ¶
package main
import (
"net/http"
"testing"
"github.com/uchaloop/httpx/apitest"
"github.com/uchaloop/httpx/problem"
"github.com/uchaloop/httpx/request"
"github.com/uchaloop/httpx/response"
)
type exampleArticle struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
// exampleHandler is the handler under test.
func exampleHandler() http.Handler {
mapper, err := problem.MakeMapper(problem.InputProblemRule())
if err != nil {
panic(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /articles/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "42" {
_ = problem.Write(w, problem.MakeProblem(r, problem.Template{Status: http.StatusNotFound}))
return
}
_ = response.Write(w, response.OK(exampleArticle{ID: 42, Title: "HTTP boundaries"}))
})
mux.HandleFunc("POST /articles", func(w http.ResponseWriter, r *http.Request) {
input, err := request.DecodeJSON[exampleArticle](r)
if err != nil {
_ = problem.Write(w, mapper.Map(r, err))
return
}
input.ID = 42
_ = response.Write(w, response.Created("/articles/42", input))
})
return mux
}
func main() {
var t *testing.T // the t of the test function
type envelope struct {
Data exampleArticle `json:"data"`
}
created := apitest.Make(t, exampleHandler()).Post("/articles").
JSON(map[string]any{"title": "HTTP boundaries"}).
Do().
Status(http.StatusCreated).
JSON[envelope]()
if created.Data.ID <= 0 {
t.Errorf("id = %d, want a positive id", created.Data.ID)
}
}
Output:
func (*Response) JSONEqual ¶
JSONEqual asserts that the body is the same JSON document as expected encoded by encoding/json. Object key order and whitespace are ignored. Array order, extra and missing fields, null and number literals are not: 1 and 1.0 differ, as they do for a typed client. A body that is not exactly one JSON document, or has duplicate object keys, stops the test.
type Test ¶
type Test struct {
// contains filtered or unexported fields
}
Test executes requests through an isolated in-memory HTTP server. Make a separate Test for each parallel subtest.
func Make ¶
Make starts an in-memory server for h and registers its shutdown with t. The client follows no redirects and stores no cookies.