httptest — Tasks¶
Hands-on exercises that move from the simplest ResponseRecorder test to a multi-server fan-out. Solve them in order. Each task has a description, a starter handler or client, and an explicit acceptance criterion. Aim to finish each task in under twenty minutes; if you take longer, re-read the relevant section in junior.md or middle.md.
Task 1 — Test a handler with NewRecorder¶
Goal. Write the smallest possible handler test using httptest.NewRecorder and httptest.NewRequest.
The handler:
func HelloHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "world"
}
fmt.Fprintf(w, "hello, %s", name)
}
Requirements.
- Use
httptest.NewRequest("GET", "/?name=Ada", nil). - Use
httptest.NewRecorder(). - Call
HelloHandler(rec, req)directly. - Assert
rec.Code == 200. - Assert
rec.Body.String() == "hello, Ada".
Acceptance.
Use go test -run TestHelloHandler -v.
Task 2 — Test a client against NewServer¶
Goal. Write a function that fetches a JSON object from a URL and decodes it. Test it against httptest.NewServer.
The function:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func FetchUser(client *http.Client, baseURL string, id int) (*User, error) {
resp, err := client.Get(fmt.Sprintf("%s/users/%d", baseURL, id))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return nil, err
}
return &u, nil
}
Requirements.
- Build a test server with
httptest.NewServerwhose handler returns{"id":42,"name":"Ada"}for/users/42and 404 otherwise. - Register
t.Cleanup(server.Close). - Call
FetchUser(http.DefaultClient, server.URL, 42)and assert the returned*User. - Add a sub-test that requests
/users/99and expects an error.
Acceptance.
=== RUN TestFetchUser
=== RUN TestFetchUser/found
=== RUN TestFetchUser/not_found
--- PASS: TestFetchUser (0.00s)
Task 3 — Test a TLS handshake¶
Goal. Show that httptest.NewTLSServer rejects clients without the test cert and accepts server.Client().
Setup.
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(ts.Close)
Requirements.
- Call
http.Get(ts.URL)with the default client. Assert the returned error contains"certificate signed by unknown authority". - Call
ts.Client().Get(ts.URL). Assert no error andresp.StatusCode == 204. - Bonus: build a custom
*http.ClientwhoseTransport.TLSClientConfig.RootCAsincludests.Certificate()and verify it also succeeds.
Acceptance. Both positive sub-tests pass; the negative sub-test asserts on the error message.
Task 4 — Build a redirect-chain test¶
Goal. Test a client that must follow exactly three redirects.
Server.
mux := http.NewServeMux()
mux.HandleFunc("/a", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/b", http.StatusFound)
})
mux.HandleFunc("/b", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/c", http.StatusFound)
})
mux.HandleFunc("/c", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/d", http.StatusFound)
})
mux.HandleFunc("/d", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "done")
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
Requirements.
- Use the default
http.Client(which follows up to 10 redirects). - Issue
GET /aand assert the final body is"done". - Configure a second client with
CheckRedirect: func(...) error { return http.ErrUseLastResponse }. Assert it stops at/aand the response hasLocation: /b. - Configure a third client whose
CheckRedirectreturns an error after the second redirect. Assert the returned error wrapshttp.Client's error type.
Acceptance. Three sub-tests, all PASS.
Task 5 — Middleware that injects a request ID¶
Goal. Test a middleware that adds an X-Request-ID header to the response and propagates it through r.Context() to the next handler.
Middleware sketch.
type reqIDKey struct{}
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = "test-id"
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), reqIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Requirements.
- Use
NewRecorderonly — no server. - Wrap a tiny inner handler that asserts
r.Context().Value(reqIDKey{}) == "test-id". - Assert
rec.Header().Get("X-Request-ID") == "test-id". - Add a second sub-test that pre-sets
X-Request-ID: from-clienton the request and verifies it round-trips.
Acceptance. Both sub-tests pass; rec.Code == 200.
Task 6 — Race-detector sweep¶
Goal. Run the entire test suite from Tasks 1-5 with -race -count=10 and confirm no race detector hits.
Requirements.
- Add
t.Parallel()to every test function in the suite. - Add
t.Cleanupinstead ofdeferfor everyserver.Close(). - Run
go test -race -count=10 ./.... Assert "ok" with noWARNING: DATA RACEoutput.
Acceptance.
Task 7 — Replay canned responses¶
Goal. Build a tiny canned-response server keyed by URL path.
API.
func CannedServer(t *testing.T, responses map[string]string) *httptest.Server {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
fmt.Fprint(w, body)
})
ts := httptest.NewServer(h)
t.Cleanup(ts.Close)
return ts
}
Requirements.
- Write a test that uses
CannedServerwith three paths. - Each sub-test should
t.Parallel(). - Assert each path returns the expected body and status.
Acceptance. Three sub-tests, all PASS, run under -race.
Stretch — Server with EnableHTTP2¶
Build an unstarted server, set EnableHTTP2 = true, call StartTLS, and assert via resp.Proto == "HTTP/2.0". This requires importing golang.org/x/net/http2 only if you need to inspect frames; for the protocol check, ts.Client() is sufficient.
Task 8 — Test a context-cancellation handler¶
Goal. Verify that a handler returns early when the client cancels.
The handler:
func LongHandler(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(5 * time.Second):
fmt.Fprint(w, "late")
case <-r.Context().Done():
return
}
}
Requirements.
- Start the server with
httptest.NewServer(LongHandler). - Issue a request with
context.WithCancel. - Cancel after 50ms.
- Assert the request returns within 200ms (not 5 seconds).
- Use a
sync.WaitGroupor done-channel to know when the handler actually returns.
Acceptance. Test completes under 500ms total.
Task 9 — Test a streaming JSON producer¶
Goal. Test a handler that emits a JSON array element-by-element using a json.Encoder and Flusher.
Handler.
func StreamJSON(w http.ResponseWriter, r *http.Request) {
flusher := w.(http.Flusher)
w.Header().Set("Content-Type", "application/x-ndjson")
enc := json.NewEncoder(w)
for i := 0; i < 5; i++ {
enc.Encode(map[string]int{"i": i})
flusher.Flush()
}
}
Requirements.
- Start the handler in a
httptest.NewServer. - Read the response with a
bufio.Scannerline-by-line. - Decode each line into a
map[string]int. - Assert exactly 5 objects, each with the correct
ivalue.
Acceptance.
Task 10 — Test middleware composition order¶
Goal. Three middlewares, in order: A, B, C. Each appends a token to a header. The expected final value is "A B C".
Setup.
func appender(token string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v := r.Header.Get("X-Trace")
if v != "" {
v += " "
}
v += token
r.Header.Set("X-Trace", v)
next.ServeHTTP(w, r)
})
}
}
Requirements.
- Compose:
appender("A")(appender("B")(appender("C")(inner))). - The inner handler echoes
X-Traceinto the response. - Test that the response body is
"A B C". - Bonus: test that reversing the order gives
"C B A".
Acceptance. Two sub-tests, both PASS.
Task 11 — Test connection-reuse behavior¶
Goal. Demonstrate that ts.Client() reuses connections by default.
Requirements.
- Build a handler that records
r.RemoteAddr(port differs per connection). - Issue 5 requests with
ts.Client(). - Collect all observed
RemoteAddrvalues. - Assert that exactly one unique remote port appears (connection was reused).
- Build a second client with
Transport.DisableKeepAlives = true. Issue 5 requests. Assert 5 unique ports.
Acceptance. Both assertions hold; tests pass under -race.
Task 12 — Goleak integration¶
Goal. Add goleak to the test suite from previous tasks and verify no goroutine leaks.
Requirements.
- Add
import "go.uber.org/goleak"to a singleTestMain. - Call
goleak.VerifyTestMain(m). - Run the full suite. Verify exit code is 0.
- Introduce a deliberate leak in one task (start a goroutine that never returns). Verify goleak fails.
Acceptance. With the leak, exit code is non-zero; output mentions the leaked stack.
Task 13 — Compare NewRecorder and NewServer cost¶
Goal. Write two benchmarks comparing the cost per request of NewRecorder vs NewServer.
Setup.
func BenchmarkRecorder(b *testing.B) {
req := httptest.NewRequest("GET", "/", nil)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
rec := httptest.NewRecorder()
MyHandler(rec, req)
}
}
func BenchmarkServer(b *testing.B) {
ts := httptest.NewServer(http.HandlerFunc(MyHandler))
defer ts.Close()
client := ts.Client()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
resp, _ := client.Get(ts.URL)
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
Requirements.
- Run both with
go test -bench=. -benchmem -count=5. - Record ns/op and allocs/op for each.
- Compare with
benchstat. - Write a one-paragraph summary of the gap.
Acceptance. The recorder benchmark should be 10-50x faster per op.