Table Driven Tests for Go API Handlers That Read Well
Write table driven tests for Go HTTP handlers with httptest: a small request helper, JSON compared as data, header checks and parallel subtests.

Handler tests tend to go wrong in one of two directions. Either there are three of them, each a page long, with setup copied between them, or there are hundreds of tiny ones that each repeat six lines of request plumbing and nobody can see which behaviours are covered. A table of cases fixes both: the behaviours are rows you can read in a column, and the plumbing is written once.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article builds a table-driven test for a small notes API in the style that has held up best in my experience. It covers what a row should contain, how to compare JSON so tests do not break on key order, how to isolate state so subtests can run in parallel, and what to leave out. It assumes you know table-driven tests in general and the basics of testing handlers with httptest. It ran on Go 1.25.6.
The handler under test
The API has three routes: create a note, get one, list all. It validates content type and body, rejects unknown fields, caps the body at 1 KiB, and talks to a Store interface so tests can supply their own.
package main
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"sync"
)
type Note struct {
ID int `json:"id"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
}
type Store interface {
Create(Note) Note
Get(id int) (Note, bool)
List() []Note
}
type memStore struct {
mu sync.Mutex
notes []Note
}
func (m *memStore) Create(n Note) Note {
m.mu.Lock()
defer m.mu.Unlock()
n.ID = len(m.notes) + 1
m.notes = append(m.notes, n)
return n
}
func (m *memStore) Get(id int) (Note, bool) {
m.mu.Lock()
defer m.mu.Unlock()
if id < 1 || id > len(m.notes) {
return Note{}, false
}
return m.notes[id-1], true
}
func (m *memStore) List() []Note {
m.mu.Lock()
defer m.mu.Unlock()
return append([]Note(nil), m.notes...)
}
var errTitle = errors.New("title is required and at most 80 characters")
func NewHandler(s Store) http.Handler {
mux := http.NewServeMux()
write := func(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
mux.HandleFunc("POST /notes", func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
write(w, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"})
return
}
var in Note
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
write(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
if t := strings.TrimSpace(in.Title); t == "" || len(t) > 80 {
write(w, http.StatusUnprocessableEntity, map[string]string{"error": errTitle.Error()})
return
}
in.ID = 0
n := s.Create(in)
w.Header().Set("Location", "/notes/"+strconv.Itoa(n.ID))
write(w, http.StatusCreated, n)
})
mux.HandleFunc("GET /notes/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
write(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
return
}
n, ok := s.Get(id)
if !ok {
write(w, http.StatusNotFound, map[string]string{"error": "note not found"})
return
}
write(w, http.StatusOK, n)
})
mux.HandleFunc("GET /notes", func(w http.ResponseWriter, r *http.Request) {
write(w, http.StatusOK, s.List())
})
return mux
}
func main() {}The Store interface is the seam. For a handler, a small in-memory fake is usually better than a mock framework: it behaves like a store, so the test describes outcomes ("after creating, the note can be fetched") instead of call sequences. When the real store matters, use a real database as in integration tests for APIs.
The shape of a row
Each case needs to answer five questions: what state does the world start in, what request is sent, what status is expected, what body is expected, and which headers matter. The test struct has a field for each:
tests := []struct {
name string
seed func(*memStore)
method string
path string
body string
headers map[string]string
wantStatus int
wantJSON string
wantHeader map[string]string
}{ ... }Resist adding fields until a case needs them. A seed function is better than a pre-built store in the row, because it keeps the starting state next to the request it affects and is nil for most rows.
The whole test
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
// do sends one request through the handler and returns the recorded response.
func do(t *testing.T, h http.Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestNotesAPI(t *testing.T) {
jsonCT := map[string]string{"Content-Type": "application/json"}
seed := func(s *memStore) { s.Create(Note{Title: "first", Body: "hello"}) }
tests := []struct {
name string
seed func(*memStore)
method string
path string
body string
headers map[string]string
wantStatus int
wantJSON string // compared as data, so key order and whitespace do not matter
wantHeader map[string]string
}{
{name: "create", method: "POST", path: "/notes", body: `{"title":"buy milk"}`, headers: jsonCT,
wantStatus: 201, wantJSON: `{"id":1,"title":"buy milk"}`, wantHeader: map[string]string{"Location": "/notes/1"}},
{name: "create ignores a client chosen id", method: "POST", path: "/notes", body: `{"id":99,"title":"x"}`, headers: jsonCT,
wantStatus: 201, wantJSON: `{"id":1,"title":"x"}`},
{name: "wrong content type", method: "POST", path: "/notes", body: `{"title":"x"}`, headers: map[string]string{"Content-Type": "text/plain"},
wantStatus: 415, wantJSON: `{"error":"content type must be application/json"}`},
{name: "malformed JSON", method: "POST", path: "/notes", body: `{"title":`, headers: jsonCT,
wantStatus: 400, wantJSON: `{"error":"invalid JSON body"}`},
{name: "unknown field", method: "POST", path: "/notes", body: `{"title":"x","admin":true}`, headers: jsonCT,
wantStatus: 400, wantJSON: `{"error":"invalid JSON body"}`},
{name: "blank title", method: "POST", path: "/notes", body: `{"title":" "}`, headers: jsonCT,
wantStatus: 422, wantJSON: `{"error":"title is required and at most 80 characters"}`},
{name: "title too long", method: "POST", path: "/notes", body: `{"title":"` + strings.Repeat("a", 81) + `"}`, headers: jsonCT,
wantStatus: 422, wantJSON: `{"error":"title is required and at most 80 characters"}`},
{name: "body too large", method: "POST", path: "/notes", body: `{"title":"` + strings.Repeat("a", 2000) + `"}`, headers: jsonCT,
wantStatus: 400, wantJSON: `{"error":"invalid JSON body"}`},
{name: "get existing", seed: seed, method: "GET", path: "/notes/1",
wantStatus: 200, wantJSON: `{"id":1,"title":"first","body":"hello"}`},
{name: "get missing", method: "GET", path: "/notes/7",
wantStatus: 404, wantJSON: `{"error":"note not found"}`},
{name: "get with a non numeric id", method: "GET", path: "/notes/abc",
wantStatus: 400, wantJSON: `{"error":"id must be a number"}`},
{name: "list when empty", method: "GET", path: "/notes", wantStatus: 200, wantJSON: `null`},
{name: "list after seeding", seed: seed, method: "GET", path: "/notes",
wantStatus: 200, wantJSON: `[{"id":1,"title":"first","body":"hello"}]`},
{name: "method not allowed", method: "DELETE", path: "/notes/1", wantStatus: 405},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
store := &memStore{} // a fresh store per case, so cases cannot depend on each other
if tc.seed != nil {
tc.seed(store)
}
rec := do(t, NewHandler(store), tc.method, tc.path, tc.body, tc.headers)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, tc.wantStatus, rec.Body)
}
for k, v := range tc.wantHeader {
if got := rec.Header().Get(k); got != v {
t.Errorf("header %s = %q, want %q", k, got, v)
}
}
if tc.wantJSON == "" {
return
}
var got, want any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("response is not JSON: %v\n%s", err, rec.Body)
}
json.Unmarshal([]byte(tc.wantJSON), &want)
if !reflect.DeepEqual(got, want) {
t.Errorf("body mismatch\n got: %s\nwant: %s", strings.TrimSpace(rec.Body.String()), tc.wantJSON)
}
})
}
}Read the table before reading the loop. Each row is one line of intent: "create ignores a client chosen id", "title too long", "get with a non numeric id". If you can name a row clearly, you probably understand the behaviour; if you cannot, the behaviour needs thinking about before it needs a test.
Running it
go test -race -count=1 -v . | grep -E '^(--- | --- |ok|PASS|FAIL)' | sed -E 's/ \([0-9.]+s\)//'--- PASS: TestNotesAPI
--- PASS: TestNotesAPI/get_existing
--- PASS: TestNotesAPI/method_not_allowed
--- PASS: TestNotesAPI/malformed_JSON
--- PASS: TestNotesAPI/title_too_long
--- PASS: TestNotesAPI/wrong_content_type
--- PASS: TestNotesAPI/get_with_a_non_numeric_id
--- PASS: TestNotesAPI/get_missing
--- PASS: TestNotesAPI/unknown_field
--- PASS: TestNotesAPI/body_too_large
--- PASS: TestNotesAPI/create_ignores_a_client_chosen_id
--- PASS: TestNotesAPI/blank_title
--- PASS: TestNotesAPI/list_when_empty
--- PASS: TestNotesAPI/create
--- PASS: TestNotesAPI/list_after_seeding
PASS
ok example.test/lab 1.348sAll fourteen cases pass under the race detector, with each subtest listed by name. The names appear as written with spaces turned into underscores, which is why descriptive row names pay off when you want to run one with -run 'TestNotesAPI/blank_title'.
Decisions in the loop
A do helper with t.Helper(). It builds the request, runs the handler and returns the recorder. The call to t.Helper() makes a failure inside it report the line in the test that called it. The helper does not take a *memStore; it takes a http.Handler. Anything that implements the interface can be tested the same way, including a handler wrapped in middleware.
A fresh store for every row. The line store := &memStore{} is inside the subtest, not above the loop. That is what makes t.Parallel() safe: no row can see another row's notes. It also means the id in a created note is always 1, so the expected body can say so. Shared state across rows is the usual reason a table test passes alone and fails in a suite. Since Go 1.22 the loop variable tc is per-iteration, so the closure is safe without copying; see the loop variable change.
Status first, with t.Fatalf. If the status is wrong, the body comparison is noise. Fail immediately and print the body, so the message reads "status = 422, want 201 (body {...})". That one line is usually enough to diagnose the case.
Compare JSON as data. The expected body is a string, but both sides are decoded into any and compared with reflect.DeepEqual. Key order, whitespace and a trailing newline from json.Encoder do not matter. The failure message prints both strings so a human can compare. For large responses, the go-cmp package prints a field-level diff, covered in comparing values with cmp.Diff.
An empty list is a good example of why you compare decoded values. The handler returns null for an empty store because a nil slice marshals as null. The test pins that down with wantJSON: "null". Whether that is the behaviour you want is a design question (many APIs return []), and the test makes the current answer visible so changing it is a deliberate act.
Headers get their own field. Only the headers that matter, like Location on a 201, are checked, and only when the row lists them. Checking every header makes tests fail for irrelevant reasons.
Things the table catches that a direct call would not
Because rows go through ServeHTTP, they exercise routing and the standard library's behaviour. The DELETE /notes/1 row expects 405 and nothing in the handler code writes that status: the mux generated it from the registered patterns. The PathValue("id") lookup is covered by the get cases. The body-size row passes a 2000 byte title and expects a 400, which only happens if MaxBytesReader is wired in. If someone later replaces the mux or drops the limit, a row fails.
This is also why a test that calls the handler method directly with a hand-built request, skipping routing, covers less than it appears to.
What to add next
Authentication and authorisation rows. Once the handler has a user, add rows for no token, a token for the wrong tenant and a token with too few rights. The same table, with a headers entry, covers them. See testing middleware for testing the middleware itself in isolation.
A failing store. Give the fake a switch that makes Create return an error and add a row expecting 500 with a generic message. The error path is the one most often untested.
Golden files for big responses. When a response has dozens of fields, store the expected JSON in testdata/ and compare against it with an -update flag, as covered in golden files.
A property or fuzz test on the decoder for the odd inputs a table will not think of. Go's fuzzing can feed random bodies to the handler and assert it never returns a 5xx for client mistakes.
What to leave out
Do not assert on log output unless the log is the feature. Do not use real time or real sleeps; inject a clock. Do not share a global handler between rows. And do not put logic in the table: no if inside a row, no computed expectations that repeat the code under test. If a row needs a function to compute what it expects, it is probably testing the implementation instead of the behaviour.
For the test style as a whole, the Go team's table-driven tests wiki page is the reference, and the net/http/httptest documentation at pkg.go.dev lists the recorder and server helpers used above.


