apitest
A simple and extensible behavioural testing library for Go HTTP services. Tests are written the way a client uses the API: build a request, send it to the handler, and assert on the response. External HTTP calls can be mocked, and every test can render a sequence diagram of what happened.
In behavioural tests the internal structure of the app is not known by the tests. Data is input to the system and the outputs are expected to meet certain conditions.
- Fluent builders for requests and expectations
- Works with anything that implements
http.Handler, or against a running server - Declarative mocks for outbound HTTP calls, with matchers for every part of the request
- Sequence diagrams of each test, including mocked calls and database queries
- No third party dependencies; integrates with
testing, Ginkgo and custom assertion libraries
The API is stable. The library is maintained and issues are addressed; feature requests are considered.
Join the conversation at #apitest on https://gophers.slack.com.
Logo by @egonelbre
Documentation
This README covers the whole library. The same material with more narrative is at https://apitest.dev, and the API reference is on pkg.go.dev.
Installation
go get github.com/steinfletcher/apitest
Quick start
func TestGetUser(t *testing.T) {
apitest.New().
Handler(handler).
Get("/user/1234").
Expect(t).
Status(http.StatusOK).
Body(`{"id": "1234", "name": "Tate"}`).
End()
}
Handler takes any http.Handler, so a http.ServeMux, a gin engine, an echo instance and so on all work. Everything
before Expect(t) describes the request; everything after it describes the expected response. End() runs the test.
When the expected body is valid JSON it is compared structurally, so key order and whitespace do not matter.
Demo

Building the request
Method and URL
Get, Post, Put, Patch and Delete set the method and URL. Each has an f variant that formats the URL, and
Method covers anything else.
apitest.Handler(handler).Getf("/user/%s", id)
apitest.Handler(handler).Method(http.MethodOptions).URL("/user")
A ready-made *http.Request can be used instead of the builder.
req := httptest.NewRequest(http.MethodGet, "/user/1234", nil)
apitest.Handler(handler).
HttpRequest(req).
Expect(t).
Status(http.StatusOK).
End()
Headers
apitest.Handler(handler).
Get("/hello").
Header("Authorization", "Bearer token").
Headers(map[string]string{"X-Request-Id": "12345"}).
ContentType("application/json").
Expect(t).
Status(http.StatusOK).
End()
Query parameters
Query, QueryParams and QueryCollection can be combined. QueryCollection sets repeated parameters, so
map[string][]string{"a": {"b", "c", "d"}} is encoded as a=b&a=c&a=d.
apitest.Handler(handler).
Get("/hello").
QueryParams(map[string]string{"a": "1", "b": "2"}).
Query("c", "d").
QueryCollection(map[string][]string{"e": {"f", "g"}}).
Expect(t).
Status(http.StatusOK).
End()
Cookies
apitest.Handler(handler).
Get("/hello").
Cookie("session", "12345").
Cookies(apitest.NewCookie("theme").Value("dark").Path("/")).
Expect(t).
Status(http.StatusOK).
End()
Body
Body sets a raw body. JSON sets the body and the Content-Type header; it accepts a string, a []byte, or any
value that can be marshalled. BodyFromFile and JSONFromFile read the body from disk.
apitest.Handler(handler).
Post("/user").
JSON(map[string]any{"name": "jan", "age": 32}).
Expect(t).
Status(http.StatusCreated).
End()
GraphQL requests are built with GraphQLQuery, or GraphQLRequest when an operation name is needed.
apitest.Handler(handler).
Post("/graphql").
GraphQLQuery(`query User($id: ID!) { user(id: $id) { name } }`, map[string]any{"id": "1234"}).
Expect(t).
Status(http.StatusOK).
End()
Form data
FormData sends an application/x-www-form-urlencoded body.
apitest.Handler(handler).
Post("/hello").
FormData("a", "1").
FormData("b", "2", "3").
Expect(t).
Status(http.StatusOK).
End()
MultipartFormData and MultipartFile send multipart/form-data. The two form styles cannot be combined in one
request.
apitest.Handler(handler).
Post("/upload").
MultipartFormData("description", "holiday photos").
MultipartFile("file", "testdata/beach.jpg", "testdata/sunset.jpg").
Expect(t).
Status(http.StatusOK).
End()
Files are read from the OS by default. UseFS swaps in any fs.FS, such as an in-memory fstest.MapFS.
inMemFS := fstest.MapFS{
"audio.wav": &fstest.MapFile{Data: []byte{19, 2, 123, 12, 35, 1}},
}
apitest.Handler(handler).
UseFS(inMemFS).
Post("/upload").
MultipartFile("file", "audio.wav").
Expect(t).
Status(http.StatusOK).
End()
Basic auth
apitest.Handler(handler).
Get("/hello").
BasicAuth("username", "password").
Expect(t).
Status(http.StatusOK).
End()
Context
WithContext sets the request context, which is how deadlines, cancellation and context values reach the handler.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
apitest.Handler(handler).
Get("/hello").
WithContext(ctx).
Expect(t).
Status(http.StatusOK).
End()
Intercept
Intercept receives the built *http.Request just before it is sent, for changes the builder does not cover.
apitest.Handler(handler).
Intercept(func(req *http.Request) {
req.URL.RawQuery = "a[]=xxx&a[]=yyy"
}).
Get("/hello").
Expect(t).
Status(http.StatusOK).
End()
Asserting on the response
Status and body
apitest.Handler(handler).
Get("/user/1234").
Expect(t).
Status(http.StatusOK).
Body(`{"id": "1234", "name": "Tate"}`).
End()
A JSON body is compared structurally. Any other body is compared as an exact string. Bodyf formats the expected
body and BodyFromFile reads it from disk.
Headers
apitest.Handler(handler).
Get("/hello").
Expect(t).
Status(http.StatusOK).
Header("Content-Type", "application/json").
Headers(map[string]string{"X-Request-Id": "12345"}).
HeaderPresent("Etag").
HeaderNotPresent("X-Powered-By").
End()
Cookies
Only the fields set on the expected cookie are compared, so NewCookie("session").Value("12345") ignores the path,
expiry and other attributes of the actual cookie.
apitest.Handler(handler).
Patch("/hello").
Expect(t).
Status(http.StatusOK).
Cookie("session", "12345").
Cookies(apitest.NewCookie("theme").Value("dark").HttpOnly(true)).
CookiePresent("csrf").
CookieNotPresent("legacy").
End()
Custom assertions
Assert takes a function that receives copies of the response and request and returns an error on failure. It can
be called several times.
apitest.Handler(handler).
Get("/hello").
Expect(t).
Assert(func(res *http.Response, req *http.Request) error {
if res.Header.Get("X-Rate-Limit") == "" {
return errors.New("expected a rate limit header")
}
return nil
}).
End()
apitest.IsSuccess, apitest.IsClientError and apitest.IsServerError are ready-made assertions on the status code
range.
JSONPath
For asserting on parts of the response body, the separate apitest-jsonpath module provides JSONPath assertions. It is packaged separately to keep this library dependency free.
Given the response {"a": 12345, "b": [{"key": "c", "value": "result"}]}:
apitest.Handler(handler).
Get("/hello").
Expect(t).
Assert(jsonpath.Contains(`$.b[? @.key=="c"].value`, "result")).
Assert(jsonpath.Equal(`$.a`, float64(12345))).
End()
The result
End() returns a Result holding the response, so further checks can be made after the test has run.
result := apitest.Handler(handler).
Get("/user/1234").
Expect(t).
Status(http.StatusOK).
End()
var user struct {
Name string `j