Day 49 — httptest handler tests

Updated

July 30, 2026

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 :8080 already 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 necessary

Theory 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/day49

Bring forward (or reimplement) Day 45/47 API surface: health, list, get, create + middleware (request ID + access log optional).


Lab 2 — Coverage matrix

Minimum cases:

  1. Health 200
  2. Create 201/200 + returned id
  3. Get existing 200
  4. Get missing 404
  5. Create invalid body 400
  6. Create empty name 400
  7. Method not allowed on patterned route (e.g. DELETE /healthz)
  8. Request ID header present after middleware
go test ./... -count=1
go test ./... -cover

Aim 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 ./... -race

If 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
  • -race clean
  • 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.