Day 49 — httptest handler tests
Day 49 — httptest handler tests
Stage V · ~3h
Goal: Fully test HTTP handlers with net/http/httptest—table-driven routes, status/body/header assertions, middleware checks—without flaky port binds.
Why this day exists
Manual curl does not scale. Regression suites need:
- Fast in-process requests
- Deterministic status and JSON bodies
- Coverage of 400/404/405/500 paths
- No race on
:8080already in use
httptest is the stdlib way. Combined with Day 47 slog and Day 45 routing, this is the testing backbone for Stage V gate and beyond.
Theory 1 — ResponseRecorder
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if res.StatusCode != http.StatusOK {
t.Fatalf("status %d body=%s", res.StatusCode, body)
}Recorder implements http.ResponseWriter and records code, headers, body. Prefer rec.Result() when you need a real *http.Response (trailers, etc.); rec.Code / rec.Body are fine for simple asserts.
Theory 2 — NewRequest vs context
req := httptest.NewRequest(http.MethodPost, "/items", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
req = req.WithContext(ctx)Path and query: "/items?limit=10"—available as r.URL.Query(). For Go 1.22+ method muxes, the path must match the registered pattern (GET /items/{id}).
httptest.NewRequest panics on bad method/URL in older mental models—still validate inputs in helpers if you build URLs dynamically.
Theory 3 — Table-driven handler matrix
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantSubstr string
}{
{"health", http.MethodGet, "/healthz", "", 200, `"status"`},
{"missing", http.MethodGet, "/items/nope", "", 404, `not found`},
{"bad json", http.MethodPost, "/items", `{`, 400, ``},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var r io.Reader
if tc.body != "" {
r = strings.NewReader(tc.body)
}
req := httptest.NewRequest(tc.method, tc.path, r)
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if tc.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) {
t.Fatalf("body=%s", rec.Body.String())
}
})
}Rule: each t.Run gets its own handler/server state when handlers are mutable.
Theory 4 — Testing Server vs Recorder
| Tool | When |
|---|---|
httptest.NewRecorder + ServeHTTP |
Unit/handler tests (default) |
httptest.NewServer |
Need real URL, clients, redirects, TLS optional |
httptest.NewTLSServer |
TLS client tests |
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
resp, err := ts.Client().Get(ts.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()ts.Client() is preconfigured for that server (including TLS when used). Prefer Recorder unless you need a real http.Client path (Day 46 skill).
Theory 5 — Middleware and path values
Exercise middleware by asserting headers:
if rec.Header().Get("X-Request-ID") == "" {
t.Fatal("missing request id")
}For Go 1.22+ mux, path patterns work through the same mux you productionize—test the assembled handler, not only leaf functions, so routing stays covered.
Leaf unit tests still useful for pure decoding logic. Setting PathValue manually is awkward; prefer full mux: GET /items/abc.
// Good: production wiring
h := api.New(log).Handler() // includes mux + middleware
// Avoid: only testing leaf with hand-built PathValue unless necessaryTheory 6 — Assertions that age well
| Prefer | Avoid |
|---|---|
| Status code + JSON fields | Exact full body strings only |
json.Unmarshal into structs |
Brittle substring soup for every field |
| Header canonical names | Typos in mixed-case keys |
Stable error code |
Free-form message text alone |
var env struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
if env.Error.Code != "not_found" {
t.Fatalf("code=%q", env.Error.Code)
}Worked example — test helper package
package api_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"example.com/day49/api"
)
func newTestHandler(t *testing.T) http.Handler {
t.Helper()
return api.New().Handler()
}
func doJSON(t *testing.T, h http.Handler, method, path, body string) (int, map[string]any) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var m map[string]any
if rec.Body.Len() > 0 {
if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
t.Fatalf("json: %v body=%s", err, rec.Body.String())
}
}
return rec.Code, m
}
func TestCreateAndGet(t *testing.T) {
h := newTestHandler(t)
code, m := doJSON(t, h, http.MethodPost, "/items", `{"name":"widget"}`)
if code != http.StatusCreated {
t.Fatalf("create code=%d m=%v", code, m)
}
id, _ := m["id"].(string)
if id == "" {
t.Fatal("missing id")
}
code, m = doJSON(t, h, http.MethodGet, "/items/"+id, "")
if code != http.StatusOK || m["name"] != "widget" {
t.Fatalf("get code=%d m=%v", code, m)
}
}Lab 1 — Project
mkdir -p ~/lab/90daysofx/01-go/day49
cd ~/lab/90daysofx/01-go/day49
go mod init example.com/day49Bring forward (or reimplement) Day 45/47 API surface: health, list, get, create + middleware (request ID + access log optional).
Lab 2 — Coverage matrix
Minimum cases:
- Health 200
- Create 201/200 + returned id
- Get existing 200
- Get missing 404
- Create invalid body 400
- Create empty name 400
- Method not allowed on patterned route (e.g. DELETE /healthz)
- Request ID header present after middleware
go test ./... -count=1
go test ./... -coverAim for meaningful cover on api package (not vanity 100%).
Lab 3 — Parallel tests
t.Parallel()Ensure handler state is either per-test server instance (preferred) or mutex-safe. Catch races:
go test ./... -raceIf a test shares a global map store, either protect it or construct a fresh Server per test.
Lab 4 — httptest.NewServer client path
One test uses NewServer + real http.Client Get to prove client/server wiring (ties Day 46). Assert status and a JSON field. Cleanup with t.Cleanup(ts.Close).
Lab 5 — Golden JSON (optional)
For stable list responses, compare pretty-printed JSON with testdata/list.golden (Day 35 skill). Update with -update flag pattern if you like. Prefer golden for stable sorted lists; avoid for timestamps unless you stub clocks.
Common gotchas
| Gotcha | Fix |
|---|---|
| Forgetting Content-Type on POST | Set header in helper |
| Reusing one Server mutable state across parallel tests | New server per test |
| Testing only happy path | Matrix includes 4xx |
| Asserting exact log format only | Assert status/body first |
| Using live port in unit tests | Recorder/Server |
| Ignoring middleware in tests | Hit full Handler() chain |
Not closing res.Body from Result() |
defer res.Body.Close() |
| Path mismatch with Go 1.22 patterns | Register and request the same shape |
Checkpoint
- Table-driven tests for all routes
- 4xx paths covered
- Middleware header asserted
-raceclean
- Helper reduces boilerplate
- Optional NewServer client test
Commit
git add .
git commit -m "day49: httptest table-driven API tests"Write three personal gotchas before continuing.
Tomorrow
Day 50 — Stage V gate: ship a coherent HTTP service with slog, tests, and graceful shutdown—the bar for leaving stdlib I/O stage.