Your Handler Is a Function — Test It Like One
The GET /users/{id} handler has four branches: found, missing, bad id, store down. Testing it "for real" in your head means starting the server, remembering the port, curling four times, and reading bodies by eye. httptest removes the process: a handler is func(ResponseWriter, *Request), and a recorder is a ResponseWriter that remembers what was written.
func TestGetUser(t *testing.T) {
store := &fakeStore{users: map[int64]User{42: {ID: 42, Name: "Ada"}}}
srv := NewServer(store, slog.New(slog.NewTextHandler(io.Discard, nil)))
tests := []struct {
name string
target string
wantStatus int
wantName string
}{
{"found", "/users/42", http.StatusOK, "Ada"},
{"missing", "/users/404", http.StatusNotFound, ""},
{"bad id", "/users/abc", http.StatusBadRequest, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.target, nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body = %q", rec.Code, tt.wantStatus, rec.Body.String())
}
if tt.wantStatus == http.StatusOK {
var got User
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got.Name != tt.wantName {
t.Errorf("name = %q, want %q", got.Name, tt.wantName)
}
}
})
}
}
Four details decide whether this test means anything:
Serve the mux, not the method. srv.routes().ServeHTTP runs the request through the real routing layer, so the pattern GET /users/{id} matches and r.PathValue("id") returns 42. Call srv.handleGetUser directly and the path value is the empty string — the handler reads strconv.ParseInt("", ...) and every test fails at the same place, misleadingly. (If a unit test must call the handler bare, req.SetPathValue("id", "42") sets it explicitly.)
The recorder is in-memory. No port, no goroutine serving connections, no TLS — the test runs in microseconds and hundreds can run in parallel. rec.Code starts at 200 only because NewRecorder initializes it there; write your assertions against what the handler actually called, and remember a handler that never calls WriteHeader really did default to 200.
Assert the body, not just the status. A 200 with null in it is a failing API. Decoding into the response type checks content type, field names, and encoding in one shot (the JSON contract you already froze with tags).
Discard the logs. Swapping in a slog handler writing to io.Discard keeps go test output readable; reach for a buffer-backed handler only when debugging a specific test.
The recorder answers "what does my handler send?" The other half of HTTP testing asks the opposite question: what does my code send when it is the client? For that you need an actual server on the other end.
When Your Code Dials Out, Give It a Real Listener
The order client builds requests, sends them, parses responses, and maps status codes. It cannot be tested with a recorder — it is the thing that would connect to the recorder's server, which does not exist. httptest.NewServer starts a real HTTP server on a random port for the lifetime of your test:
func TestOrderClient_Fetch(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(Order{ID: r.PathValue("id"), Total: 1999})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
c := NewOrderClient(srv.Client(), srv.URL)
got, err := c.Fetch(context.Background(), "7")
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if got.Total != 1999 {
t.Errorf("total = %d, want 1999", got.Total)
}
}
Read the wiring: srv.URL is the base address (http://127.0.0.1:41234), so the client exercises its real URL-joining, header, and transport code against a real socket. srv.Close blocks until outstanding requests finish — t.Cleanup guarantees it runs even when an assertion fails first, which is why it beats a bare defer in subtests (the deferred call in a loop test would fire at the wrong time).
The stub handler above is where you script the upstream: return 500 on the first attempt and 200 on the second to prove the retry loop backs off; sleep past the client's timeout to prove the deadline fires; send a malformed body to prove decode errors surface. Those tests are impossible against production, and they are one table away from the handler tests you already write (table-driven tests).
Two variants cover the rest of the space. httptest.NewUnstartedServer plus StartTLS (or NewTLSServer) gives a server with a self-signed certificate — necessary when the client enforces TLS. Here is the trap: the client from http.DefaultClient rejects that certificate and every call fails verification. The fix is srv.Client(), a client preconfigured to trust this server's cert. It also honors any client configuration you pass, so timeouts and transport settings stay under test. NewTLSServer is also how you prove your client rejects bad certificates — flip srv.Client()'s transport to normal verification and assert the handshake fails.
Keep the boundary honest: the test server scripts upstream behavior; your client code is the subject. If the stub handler grows business logic of its own, the test starts verifying the fake — a sign the fake belongs one layer up, behind an interface, where most of your unit tests should already live.
The Fake Store: Testing Handlers Without a Database
Recorder tests still need something behind the handler — the store. Pointing tests at a real database makes them slow, flaky, and impossible to run in parallel; the handler only needs four behaviors from it (find by id, not found, error, list), which is exactly what a consumer-declared interface and a fake deliver.
// production
var _ Queries = (*UserRepo)(nil) // compile-time proof the repo satisfies the interface
// test fake
type fakeUsers struct {
users map[int64]User
err error
gotID int64 // record what the handler asked for
}
func (f *fakeUsers) UserByID(ctx context.Context, id int64) (User, error) {
f.gotID = id
if f.err != nil {
return User{}, f.err
}
u, ok := f.users[id]
if !ok {
return User{}, ErrNotFound
}
return u, nil
}
func TestGetUser_NotFoundMapsTo404(t *testing.T) {
store := &fakeUsers{users: map[int64]User{}}
srv := NewServer(store, discardLogger())
req := httptest.NewRequest(http.MethodGet, "/users/9", nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if store.gotID != 9 {
t.Errorf("store asked for id %d, want 9 — the handler parsed the wrong path value", store.gotID)
}
}
That last assertion is the part people skip. Checking only the response verifies half the contract; recording what the fake received catches handlers that pass the right output for the wrong reason — an empty id silently mapping to a default, a query built from the wrong variable. Fakes make the input side of the handler assertable.
Wire the whole stack when the behavior is cross-cutting. Authentication, request ids, and panic recovery live in middleware, so the test wraps chain(routes(), Recovery(log), auth.Middleware(fakeVerify)) and asserts that a request without a token never reaches the store — store.gotID still zero after a 401. One test per layer, one test for the assembly, and a panic in any handler surfaces as a failed assertion on a 500 rather than a crashed test binary.
What to leave alone: net/http's own routing logic and the standard library's body-draining behavior are not your code to re-test. The suite earns its keep on the branches you wrote — parse, map, delegate — and each one follows the same rhythm: build request, serve through the mux, assert status, decode body, check what the fake saw. With handler tests this cheap, the data layer is the next thing handlers depend on, and it brings a testing story of its own (database/sql).
Quiz
1. A test calls srv.handleGetUser(rec, req) directly, and every request returns 400 for a bad id even with URL /users/42. What happened?
2. You are testing a client that sends GET requests and parses JSON responses. Which setup exercises its real networking code?
3. A test starts httptest.NewTLSServer but the client under test fails every call with certificate errors. What is the fix?
4. A handler test asserts rec.Code == 404 but never inspects the body or the fake store. What class of bug slips through?
Flashcards
Question
When do you reach for httptest.NewRecorder versus httptest.NewServer?
Click to reveal answer
Answer
NewRecorder tests code that answers requests — handlers and middleware — in memory with no port. NewServer tests code that originates requests — HTTP clients, webhooks — by giving it a real listener with scripted responses. Recorder = your handler is the subject; server = your client is the subject.
Question
Why should a recorder test hit srv.routes() instead of calling the handler method directly?
Click to reveal answer
Answer
The mux populates r.PathValue during matching, so routing and parsing are covered together. Direct calls leave path captures empty and produce misleading failures; when a bare handler test is necessary, req.SetPathValue fills the gap explicitly.
Question
What does httptest.Server.Client() give you that http.DefaultClient does not?
Click to reveal answer
Answer
A client whose transport trusts the test server's self-signed certificate (for TLS servers) — so calls succeed while verification stays honest elsewhere. It is also the natural place to attach the timeouts and transport settings under test.
Question
How does a fake store make handler tests stronger than response assertions alone?
Click to reveal answer
Answer
It records what the handler asked for — which id, which filters — so tests catch wrong-input-right-output bugs like a handler passing a default id. The fake lives behind a consumer-declared interface, keeps tests off the database, and lets branches (not found, store error) be scripted in one line.
Question
Why t.Cleanup(srv.Close) instead of defer srv.Close() in a table-driven test?
Click to reveal answer
Answer
defer in a loop body runs at function end (or must be wrapped in t.Run to time correctly), while t.Cleanup ties teardown to the test or subtest that owns the server — it runs even when t.Fatal aborts mid-test, which is exactly when leaked servers hurt most.
Revision Notes
Key Takeaways
- 1. Handlers are plain functions — a recorder turns them unit-testable with no port, no process, and no waiting
- 2. Path values exist only after ServeMux routes the request; tests that bypass the mux test a different program
- 3. Client code needs httptest.NewServer with scripted behavior and t.Cleanup, not a recorder
- 4. Fakes behind consumer-declared interfaces let you assert the handler's inputs, not just its outputs
- 5. The three-layer split — recorder for handlers, server for clients, fakes for data — keeps each test fast enough to run everywhere
Interview Tips
- • "How do you test an HTTP handler in Go?" — NewRequest + NewRecorder through the real mux; then volunteer asserting the decoded body and fake interactions
- • "How would you test retry behavior?" — NewServer whose stub returns 500 then 200, assert attempt count and timing bounds
- • "What do you mock and what do you not?" — your own interfaces (store, clock); never stdlib routing or body draining; explain the fake-recording-inputs trick
- • "How do you test auth middleware?" — chain it with routes, assert 401 short-circuits before the fake store is touched
Cheat Sheet
Testing Handlers with httptest
Pick the tool:
| Code under test | Tool | Key calls |
|---|---|---|
| handler / middleware | NewRecorder |
req := NewRequest(...); routes().ServeHTTP(rec, req) |
| HTTP client | NewServer / NewTLSServer |
srv.URL as base, srv.Client(), t.Cleanup(srv.Close) |
| data layer behind handler | consumer interface + fake | assert response and recorded fake inputs |
Assertion rhythm: build request -> serve through the mux -> check rec.Code -> decode rec.Body into the response type -> check what the fake received.
Gotchas:
- direct handler call leaves
r.PathValueempty (mux fills it) — route throughroutes()orreq.SetPathValue NewRecorderinitializes Code to 200 — assert what the handler wrote, not the default- TLS servers need
srv.Client(); DefaultClient fails cert verification - fake errors: script not-found, 500, malformed upstream to prove each branch's status mapping
Code template:
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String()) }
Continue Learning
Before You Start
- No prerequisites listed.