httptest — Middle¶
Table of Contents¶
- Introduction
- Prerequisites
- Glossary
- TLS test servers
- The self-signed certificate
- server.Client and why you should prefer it
- Customising the transport
- Testing middleware chains
- Testing http.Client code
- NewUnstartedServer and pre-start configuration
- Customising the listener
- Port-zero binding explained
- Manual server on :0 versus httptest
- t.Cleanup, helpers, and shared servers
- Subtests with httptest
- Race-safety across parallel tests
- Testing timeouts
- Testing retries
- Inspecting requests received by the server
- Configurable response servers
- Common mistakes
- Best practices
- Self-assessment checklist
- Summary
- Further reading
Introduction¶
You can now write basic handler tests with ResponseRecorder and client tests with NewServer. This file extends both styles to cover patterns you will use daily on a production codebase:
- TLS test servers with the package's self-signed certificate.
- Middleware chain testing — does the auth middleware actually inject the user into the context?
- Testing arbitrary
*http.Clientcode — even code you didn't write. - Pre-start configuration via
NewUnstartedServerfor setting timeouts, error logs, and HTTP/2. - Parallel-safe cleanup with
t.Cleanupandt.Helper. - Testing retry logic by deliberately failing the first N requests.
Everything here builds on junior.md. If you skipped that file, go read it first — the recorded-handler and server-with-cleanup patterns are assumed.
Prerequisites¶
- You completed the junior file.
- You know that
t.Parallelmakes a test run alongside its siblings. - You know
sync.Mutexandsync/atomicexist. - You have used the
contextpackage at least once (context.WithCancel,context.WithTimeout). - You can write middleware as a function
func(http.Handler) http.Handler.
Glossary¶
- TLS — Transport Layer Security. Used by HTTPS.
- Self-signed certificate — a certificate whose signer is the certificate itself, not a trusted Certificate Authority. The browser/client must explicitly trust it.
- Root CA pool — the set of certificates a TLS client trusts as anchors.
crypto/tls.Config.RootCAs. - Transport — the
http.RoundTripperan*http.Clientuses to send requests. Default:http.DefaultTransport. - Middleware — a function that wraps a handler with extra behavior. Signature
func(http.Handler) http.Handler.
TLS test servers¶
httptest.NewTLSServer(handler) returns a *Server that speaks HTTPS. It listens on 127.0.0.1:0, generates a self-signed certificate at startup, and configures the underlying *http.Server with that certificate. The URL is https://127.0.0.1:port.
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello over TLS")
}))
t.Cleanup(ts.Close)
If you http.Get(ts.URL) with the default client, you get:
This is good — TLS is doing its job. The default client doesn't know about the test cert. You have two options to make it work:
- Use
ts.Client()(recommended). - Build a custom transport with the cert in the root pool.
Both are covered below.
The self-signed certificate¶
The certificate used by NewTLSServer is built into the package. From the source (src/net/http/httptest/server.go and src/internal/testcert):
- The CN is
example.com. - The Subject Alternative Names cover
example.com,localhost,127.0.0.1, and::1. - Validity:
Jan 1 1970toDec 31 2049. You will be retired before it expires. - Key type and size are fixed; modern Go versions may generate a fresh keypair at process start (see the source for specifics).
The cert is not signed by any CA. Trying to verify it against the system trust store fails. Verification works only when the test client has the cert installed in its RootCAs pool.
You can fetch the cert directly:
This is useful when your code under test takes a *x509.CertPool (not a *http.Client) and you need to install the cert manually.
server.Client and why you should prefer it¶
*Server.Client() returns an *http.Client whose Transport is configured to trust the test server's certificate. Use it as a drop-in for http.DefaultClient:
Three reasons to prefer it:
-
It works for both
NewServerandNewTLSServer. For the plain HTTP variant, the returned client is roughly equivalent to a default client. Usingts.Client()everywhere means a test that starts as plain HTTP and later upgrades to TLS doesn't need to change. -
It doesn't reuse global state.
http.DefaultClientandhttp.DefaultTransportare shared globals. If a previous test mutated them (e.g. set a customProxy), your test inherits the mutation.ts.Client()returns a fresh client. -
The TLS config is right. The cert is in the root pool. No
InsecureSkipVerify. No surprise during a future TLS upgrade.
The returned client is your client; mutate it freely.
Customising the transport¶
If your code under test takes an *http.Transport (not a client), you may want to build the transport yourself but reuse the cert. Pattern:
ts := httptest.NewTLSServer(handler)
t.Cleanup(ts.Close)
certPool := x509.NewCertPool()
certPool.AddCert(ts.Certificate())
transport := &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: certPool,
},
}
client := &http.Client{Transport: transport}
resp, err := client.Get(ts.URL)
Or copy the configuration from ts.Client():
client := ts.Client()
clone := client.Transport.(*http.Transport).Clone()
clone.MaxIdleConns = 0
clone.DisableKeepAlives = true
newClient := &http.Client{Transport: clone}
This is the right path when your production code accepts a *http.Transport for, say, propagating tracing headers. The test still uses the test cert; the test still verifies the chain.
Anti-pattern:
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
This works but disables verification entirely. If a future regression breaks your production TLS code, this test will not catch it. Always prefer adding the test cert to the root pool.
Testing middleware chains¶
A middleware in Go looks like:
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
Testing it requires two things: a fake next to see what was passed, and assertions on the ResponseWriter. The recorder handles the second; you write the first.
func TestLogger(t *testing.T) {
var got *http.Request
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r
w.WriteHeader(http.StatusOK)
})
mw := Logger(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/path", nil)
mw.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if got == nil {
t.Fatal("inner handler not called")
}
if got.URL.Path != "/path" {
t.Fatalf("inner saw path = %q", got.URL.Path)
}
}
The pattern generalises. For a middleware that injects a value into the context:
type userKey struct{}
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := r.Header.Get("Authorization")
if tok != "Bearer good" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey{}, "alice")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func TestAuthMiddleware(t *testing.T) {
tests := []struct {
name string
token string
wantCode int
wantUser string
}{
{"valid", "Bearer good", 200, "alice"},
{"invalid", "Bearer bad", 401, ""},
{"missing", "", 401, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var seenUser string
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if v, ok := r.Context().Value(userKey{}).(string); ok {
seenUser = v
}
w.WriteHeader(http.StatusOK)
})
mw := AuthMiddleware(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
if tc.token != "" {
req.Header.Set("Authorization", tc.token)
}
mw.ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantCode)
}
if seenUser != tc.wantUser {
t.Fatalf("user = %q, want %q", seenUser, tc.wantUser)
}
})
}
}
A handful of patterns to note in this code:
- We use a closure (
seenUser) to record what the inner handler observed. The test is sequential, so no mutex is needed. - We test the middleware in isolation, not the whole pipeline. The inner handler is a stub.
- We test all three branches: valid, invalid, missing. The matrix is small enough to enumerate.
Chained middleware tests:
func TestChain(t *testing.T) {
chain := Logger(RequestID(AuthMiddleware(myHandler)))
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer good")
chain.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
if rec.Header().Get("X-Request-ID") == "" {
t.Fatal("missing X-Request-ID")
}
}
Test the chain as a whole when the interaction between middlewares matters. Test each one in isolation when their contracts are independent.
Testing http.Client code¶
If you wrote a function
then testing it is mechanical: build a server that returns the canned response, hand the function the server's URL.
func TestFetchUser(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/42" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":42,"name":"Ada"}`)
}))
t.Cleanup(ts.Close)
u, err := FetchUser(ts.Client(), ts.URL, 42)
if err != nil {
t.Fatal(err)
}
if u.ID != 42 || u.Name != "Ada" {
t.Fatalf("got %+v", u)
}
}
The key dependency-injection point is that FetchUser takes client and baseURL as parameters. If your function instead uses http.DefaultClient.Get("https://api.example.com/..."), you cannot test it with httptest. Refactor.
// Untestable
func FetchUser(id int) (*User, error) {
resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%d", id))
// ...
}
// Testable
type APIClient struct {
HTTPClient *http.Client
BaseURL string
}
func (c *APIClient) FetchUser(id int) (*User, error) {
resp, err := c.HTTPClient.Get(fmt.Sprintf("%s/users/%d", c.BaseURL, id))
// ...
}
The refactor is small and improves more than testability — it makes the production code configurable for different environments (staging, prod).
NewUnstartedServer and pre-start configuration¶
httptest.NewServer starts the server immediately. You cannot set timeouts, swap the error log, or enable HTTP/2 after the fact. NewUnstartedServer solves this:
ts := httptest.NewUnstartedServer(handler)
ts.Config.ReadTimeout = 500 * time.Millisecond
ts.Config.WriteTimeout = 500 * time.Millisecond
ts.Config.ErrorLog = log.New(io.Discard, "", 0) // silence noisy logs
ts.Start()
t.Cleanup(ts.Close)
ts.Config is the underlying *http.Server. Anything you can set on a real server, you can set here before calling Start(). After Start() mutating Config is undefined; do it earlier.
For HTTP/2:
ts := httptest.NewUnstartedServer(handler)
ts.EnableHTTP2 = true
ts.StartTLS() // HTTP/2 requires TLS
t.Cleanup(ts.Close)
resp, _ := ts.Client().Get(ts.URL)
fmt.Println(resp.Proto) // "HTTP/2.0"
Note: EnableHTTP2 is only honoured by StartTLS, not Start. HTTP/2 over plaintext (h2c) is not supported by the stdlib *http.Server without extra packages.
Customising the listener¶
The default listener binds to 127.0.0.1:0. If you need a different address (e.g. to test IPv6 paths), build the listener yourself:
l, err := net.Listen("tcp", "[::1]:0")
if err != nil {
t.Fatal(err)
}
ts := httptest.NewUnstartedServer(handler)
ts.Listener = l
ts.Start()
t.Cleanup(ts.Close)
ts.URL is populated from the listener's address, so even with a custom listener you get a usable URL.
Don't do this just to pick a fixed port — that breaks parallel tests. Do it when you genuinely need IPv6, a Unix socket, or a custom net.Listener that injects fault behavior.
Port-zero binding explained¶
When you call net.Listen("tcp", "127.0.0.1:0") (or httptest.NewServer does it for you), the kernel:
- Allocates an ephemeral port from the configured range (
net.ipv4.ip_local_port_rangeon Linux). - Binds the socket to
127.0.0.1:port. - Returns the listener with
Addr()reporting the assigned port.
Ephemeral ports are typically in 49152-65535 on Linux, 32768-60999 on default Linux configurations, or a wider range on other systems. They are released when the socket closes — eventually. There's a TIME_WAIT state of up to 60 seconds.
In practice, this never matters for tests under a thousand iterations. It matters when you run go test -count=100000 on a build server and start getting "address already in use". Fix by calling Close (you should be calling it anyway) and by spacing out runs.
Manual server on :0 versus httptest¶
Before httptest existed, the pattern was:
l, _ := net.Listen("tcp", "127.0.0.1:0")
srv := &http.Server{Handler: handler}
go srv.Serve(l)
url := "http://" + l.Addr().String()
// ... tests ...
srv.Shutdown(context.Background())
Compare to:
The manual style:
- Forces you to write the readiness check yourself (
go srv.Serve(l)returns before listening? No —Listenis what binds;Serveis the accept loop). Actually the manual style happens to work becauseListenopens the socket synchronously. But it's fragile. - Forces you to write
Shutdowninstead ofClose. - Forces you to construct the URL string yourself.
- Provides no TLS helper.
There is exactly one case where the manual style wins: when you need a net.Listener that isn't TCP — say, a net.Pipe for in-memory transport. Even then, NewUnstartedServer with a custom listener gets you most of the way.
t.Cleanup, helpers, and shared servers¶
For a suite that creates similar test servers, factor out a helper:
func startAPIServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/users", listUsers)
mux.HandleFunc("/users/", getUser)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts
}
func TestUsersList(t *testing.T) {
ts := startAPIServer(t)
// ...
}
Three things to note:
t.Helper()makes test failures point to the caller's line, not the helper's.- The helper registers
t.Cleanupitself, so callers don't have to remember. - The helper takes a
*testing.T, so it can uset.Fatalif setup fails.
For a truly shared server across all tests in a package (e.g. a stateless catalog API):
var sharedServer *httptest.Server
func TestMain(m *testing.M) {
sharedServer = httptest.NewServer(buildHandler())
code := m.Run()
sharedServer.Close()
os.Exit(code)
}
func TestSomething(t *testing.T) {
resp, _ := sharedServer.Client().Get(sharedServer.URL + "/path")
// ...
}
Use sparingly. State across tests is hard to reason about.
Subtests with httptest¶
Subtests via t.Run are the right structure for table-driven HTTP tests:
func TestHandlers(t *testing.T) {
ts := httptest.NewServer(myMux)
t.Cleanup(ts.Close)
tests := []struct {
name string
path string
code int
body string
}{
{"home", "/", 200, "welcome"},
{"not_found", "/missing", 404, "404 page not found\n"},
{"redirect", "/old", 301, ""},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
resp, err := ts.Client().Get(ts.URL + tc.path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.code {
t.Fatalf("code = %d", resp.StatusCode)
}
})
}
}
Adding t.Parallel() to each subtest is safe as long as the server is stateless. If the handler mutates state, you have a race.
Race-safety across parallel tests¶
The race detector (go test -race) is your friend. A test suite that runs clean under -race is one where:
- Every handler write to shared state is protected.
- Every test that uses
t.Parallelhas independent server state (or shared-but-locked state). - Every assertion happens after the goroutine that wrote the asserted value has synchronised.
Common race patterns and fixes:
Race 1. Handler increments a counter; test reads it.
Fix: atomic.Int64.
var n atomic.Int64
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n.Add(1)
})
// test: n.Load()
Race 2. Handler appends to a slice; test reads it.
var got []string
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.URL.Path) // RACE
})
Fix: mutex around the slice, snapshot before assertion.
var (
mu sync.Mutex
got []string
)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
got = append(got, r.URL.Path)
mu.Unlock()
})
// test: mu.Lock(); defer mu.Unlock(); ...
Race 3. Closure captures a variable later mutated by the test goroutine.
for i := 0; i < 3; i++ {
t.Run(fmt.Sprint(i), func(t *testing.T) {
t.Parallel()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, i) // RACE — i mutates
}))
// ...
})
}
Fix: capture i in the loop.
Run go test -race ./... on every commit. Race-free tests are a property of the test, not the production code; you have to actively maintain it.
Testing timeouts¶
Server timeouts (ReadTimeout, WriteTimeout, IdleTimeout) bound how long the server tolerates slow clients. Test them with NewUnstartedServer:
func TestServerWriteTimeout(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
fmt.Fprint(w, "late")
})
ts := httptest.NewUnstartedServer(handler)
ts.Config.WriteTimeout = 50 * time.Millisecond
ts.Start()
t.Cleanup(ts.Close)
_, err := ts.Client().Get(ts.URL)
if err == nil {
t.Fatal("expected timeout error")
}
}
Client timeouts on the calling side:
client := ts.Client()
client.Timeout = 50 * time.Millisecond
_, err := client.Get(ts.URL)
if !errors.Is(err, context.DeadlineExceeded) && !os.IsTimeout(err) {
t.Fatalf("err = %v", err)
}
Keep test timeouts short (tens of milliseconds, not seconds). A slow test is a flaky test.
Testing retries¶
Retry logic needs a server that fails the first N requests. The classic pattern:
func TestRetry(t *testing.T) {
var attempts atomic.Int64
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
if n < 3 {
http.Error(w, "transient", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok"))
}))
t.Cleanup(ts.Close)
client := NewRetryClient(ts.Client(), 5) // your retry wrapper
resp, err := client.Get(ts.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := attempts.Load(); got != 3 {
t.Fatalf("attempts = %d, want 3", got)
}
}
Two atomic.Int64 patterns appear together: count attempts, return success on the third. The test verifies both that the final response is correct and that the retry happened the right number of times.
Variants:
- Fail with different status codes (5xx vs 4xx) to test that only 5xx is retried.
- Fail with a connection reset (use
ts.CloseClientConnections()mid-flight) to test network-level retries. - Return a
Retry-Afterheader to test backoff respect.
Inspecting requests received by the server¶
When you want to assert "the client sent the right request", record the request inside the handler:
func TestClientHeaders(t *testing.T) {
var got *http.Request
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r // safe — only one request in this test
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(ts.Close)
client := ts.Client()
req, _ := http.NewRequest("POST", ts.URL+"/path", strings.NewReader("body"))
req.Header.Set("X-Foo", "bar")
client.Do(req)
if got == nil {
t.Fatal("server never received a request")
}
if got.Header.Get("X-Foo") != "bar" {
t.Fatalf("X-Foo = %q", got.Header.Get("X-Foo"))
}
body, _ := io.ReadAll(got.Body)
if string(body) != "body" {
t.Fatalf("body = %q", body)
}
}
For multiple requests, collect them in a slice under a mutex (the race-2 pattern above).
Caveat: got = r captures a pointer to a request that the server may still be reading from. If you want to capture the bytes, read the body inside the handler before the assignment.
Configurable response servers¶
A small library of helpers makes most tests one-liners:
type respConfig struct {
Status int
Header http.Header
Body []byte
Delay time.Duration
}
func server(t *testing.T, cfg respConfig) *httptest.Server {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if cfg.Delay > 0 {
time.Sleep(cfg.Delay)
}
for k, v := range cfg.Header {
w.Header()[k] = v
}
if cfg.Status != 0 {
w.WriteHeader(cfg.Status)
}
w.Write(cfg.Body)
}))
t.Cleanup(ts.Close)
return ts
}
Used like:
Don't over-engineer. If your test needs an unusual response, write the handler inline. A 5-line handler in a test is clearer than a 50-line config struct.
Common mistakes¶
Calling ts.Close() twice. Idempotent in Go 1.20+, but earlier versions would panic. Always call once.
Calling ts.Start() twice. Panics. Use NewUnstartedServer only when you need pre-start configuration; then Start() once.
Setting ts.Config after Start. Has no effect. The server has already captured the values.
Using InsecureSkipVerify instead of ts.Client(). Masks real verification bugs.
Mutating ts.URL to "fix" it. Don't. Read-only.
Calling t.Cleanup(ts.Close) from a goroutine. Cleanups must be registered on the test goroutine. Doing it from a worker goroutine is a race on *testing.T.
Expecting EnableHTTP2 = true to upgrade a plain NewServer. It's only honoured by StartTLS. HTTP/2 cannot run cleartext through httptest.
Best practices¶
- One server per test, unless it's stateless. Stateless = no in-memory mutation. The catalog returns the same JSON every time? Share it via
TestMain. ts.Client()instead ofhttp.DefaultClient. Future-proofs TLS upgrades and avoids global state.- Helpers for setup, never for assertions. A helper that asserts hides the failing line.
t.Helper()in every test helper. Failures point to the caller.-raceon every CI run. Without it, the test passes locally and fails in production.- Short timeouts. Anything over 200ms in a unit test is a smell.
- Keep handlers in tests under 20 lines. Long handlers in tests are a sign your production handler is too complex.
Self-assessment checklist¶
- You can write a TLS test with
httptest.NewTLSServerand assert againstts.Client(). - You can build a custom transport that trusts the test cert.
- You can test a middleware in isolation with a stub
nexthandler. - You can refactor a function that calls
http.Getto be testable. - You know when to use
NewUnstartedServerversusNewServer. - You can write a parallel-safe handler that records every request without a race.
- You can test retry logic by failing the first N requests.
- You can test client and server timeouts with sub-second values.
- You always pair
httptest.NewServerwitht.Cleanupfrom at.Helper()-marked helper.
Summary¶
You have learned how to test TLS, middleware chains, and *http.Client code with httptest. You know to use ts.Client() for TLS verification, NewUnstartedServer for pre-start configuration, and helpers with t.Helper() to keep test code tidy. You can write retries, timeouts, and request-inspection tests without flakes.
Next:
senior.md— streaming, chunked encoding, hijack limits, context and deadline propagation.professional.md— OAuth, webhooks, multi-server fan-out, go-vcr/gock interop.
Further reading¶
src/net/http/httptest/server.go— the source forNewServer,NewTLSServer, andClient().src/net/http/serve_test.go— extensive use ofhttptestto test the server itself.- Russ Cox, "Don't use Go's default HTTP client (in production)" — sets up the case for injecting your own
*http.Client. golang.org/x/net/http2— for tests that need explicit HTTP/2 frame inspection.
Appendix A — Authentication middleware deep dive¶
Authentication is one of the most common middlewares in production code. Let's build a thorough test suite for a JWT-style middleware that:
- Reads a token from the
Authorization: Bearer <token>header. - Validates the token against a secret.
- Injects the claims (user ID, scope) into the request context.
- Returns 401 on missing or invalid tokens.
The middleware:
type claims struct {
UserID string
Scope []string
}
type claimsKey struct{}
func ClaimsFromContext(ctx context.Context) (claims, bool) {
c, ok := ctx.Value(claimsKey{}).(claims)
return c, ok
}
func AuthMiddleware(verify func(token string) (claims, error)) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "missing bearer token", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
c, err := verify(token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey{}, c)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
The test suite — note how a small fakeVerify is sufficient; we don't need a real JWT library:
func fakeVerify(token string) (claims, error) {
switch token {
case "good-admin":
return claims{UserID: "u1", Scope: []string{"admin"}}, nil
case "good-user":
return claims{UserID: "u2", Scope: []string{"read"}}, nil
default:
return claims{}, errors.New("bad token")
}
}
func TestAuth_Valid(t *testing.T) {
var seen claims
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, _ := ClaimsFromContext(r.Context())
seen = c
w.WriteHeader(http.StatusOK)
})
mw := AuthMiddleware(fakeVerify)(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer good-admin")
mw.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if seen.UserID != "u1" {
t.Fatalf("UserID = %q", seen.UserID)
}
if len(seen.Scope) != 1 || seen.Scope[0] != "admin" {
t.Fatalf("Scope = %v", seen.Scope)
}
}
func TestAuth_Missing(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("inner should not be called")
})
mw := AuthMiddleware(fakeVerify)(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
mw.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
}
func TestAuth_Invalid(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("inner should not be called")
})
mw := AuthMiddleware(fakeVerify)(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", "Bearer wrong")
mw.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
}
func TestAuth_MalformedHeader(t *testing.T) {
cases := []string{
"Basic dXNlcjpwYXNz", // wrong scheme
"Bearer", // no token
"bearer good-admin", // wrong case
"Bearer good-admin", // double space
}
for _, h := range cases {
h := h
t.Run(h, func(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
mw := AuthMiddleware(fakeVerify)(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Authorization", h)
mw.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d for header %q", rec.Code, h)
}
})
}
}
Observations.
- We don't test the JWT library. We test the middleware's contract: did it parse the header, did it call
verify, did it inject claims on success, did it short-circuit on failure? - We assert that
inneris not called on failure. A subtle bug class: middleware that returns 401 but also callsnextanyway. - We enumerate the malformed-header cases as a table. Four lines of test data, one parameterised test function.
For end-to-end testing — combining AuthMiddleware with a httptest.NewServer — the pattern is the same as in Testing http.Client code. Mount the middleware on a mux, start the server, hit it with a real HTTP client.
Appendix B — Rate-limit middleware testing¶
A rate limiter that allows N requests per second. Token-bucket style. The middleware:
type Limiter interface {
Allow() bool
}
func RateLimit(l Limiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !l.Allow() {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
To test, inject a fake limiter:
type allowN struct {
remaining atomic.Int64
}
func (a *allowN) Allow() bool {
return a.remaining.Add(-1) >= 0
}
func TestRateLimit(t *testing.T) {
l := &allowN{}
l.remaining.Store(3)
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mw := RateLimit(l)(inner)
ts := httptest.NewServer(mw)
t.Cleanup(ts.Close)
var ok, throttled int
for i := 0; i < 5; i++ {
resp, _ := ts.Client().Get(ts.URL)
resp.Body.Close()
switch resp.StatusCode {
case 200:
ok++
case 429:
throttled++
}
}
if ok != 3 || throttled != 2 {
t.Fatalf("ok=%d throttled=%d", ok, throttled)
}
}
A real time-windowed limiter is harder to test deterministically. Strategies:
- Inject a clock. Make the limiter take
func() time.Time. Pass a fake clock in tests. - Tight bursts. Hit the endpoint as fast as Go can in a loop; assert that more than N responses come back 429. Doesn't pin the exact count but catches gross misconfiguration.
- Per-test limiter. Build a fresh limiter per test; no state across tests.
Don't time.Sleep between requests to "let the bucket refill". Slow, flaky, unnecessary.
Appendix C — Logging middleware testing¶
A logger middleware that writes structured logs and propagates a request ID:
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 = newID() // assume deterministic in tests; inject if needed
}
ctx := context.WithValue(r.Context(), reqIDKey{}, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func Logger(w io.Writer) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
id, _ := r.Context().Value(reqIDKey{}).(string)
start := time.Now()
wrapped := &statusRecorder{ResponseWriter: rw, status: 200}
next.ServeHTTP(wrapped, r)
fmt.Fprintf(w, "%s %s %d %s %v\n", id, r.Method, wrapped.status, r.URL.Path, time.Since(start))
})
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
Test:
func TestLogger_WritesLine(t *testing.T) {
var buf bytes.Buffer
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
})
chain := RequestID(Logger(&buf)(inner))
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/users", nil)
req.Header.Set("X-Request-ID", "test-id")
chain.ServeHTTP(rec, req)
out := buf.String()
if !strings.Contains(out, "test-id POST 201 /users") {
t.Fatalf("log line = %q", out)
}
}
Two key choices.
- The logger takes
io.Writer. In production it'sos.Stderr; in tests it's a*bytes.Buffer. Standard dependency injection. - The status is captured by wrapping the
ResponseWriter. The wrapper implementsWriteHeaderand forwards. This is a common pattern; it's also testable on its own:
func TestStatusRecorder(t *testing.T) {
rec := httptest.NewRecorder()
sr := &statusRecorder{ResponseWriter: rec, status: 200}
sr.WriteHeader(http.StatusTeapot)
if sr.status != http.StatusTeapot {
t.Fatalf("recorded status = %d", sr.status)
}
if rec.Code != http.StatusTeapot {
t.Fatalf("inner code = %d", rec.Code)
}
}
Three layers — the wrapper, the logger, the chain — each tested independently. Composition makes the tests trivial.
Appendix D — A cheat sheet for parallelisation¶
| Situation | Use t.Parallel? | Use t.Cleanup? | Notes |
|---|---|---|---|
NewRecorder test, no shared state | Yes | N/A (no resource) | Pure functions, parallel-safe. |
NewServer test, fresh server per test | Yes | Yes | The server is local to the test. |
NewServer test, shared server, stateless handler | Yes | Cleanup the server in TestMain | OK if the handler doesn't mutate. |
NewServer test, shared server, stateful handler | No | Cleanup in TestMain | Mutation across tests is fragile. |
Subtest table with t.Run | Yes per subtest | Once for the server | Standard table-driven pattern. |
Test that mutates http.DefaultClient | No | N/A | Avoid mutating globals entirely. |
When in doubt: parallel + per-test server + t.Cleanup. It scales well and breaks predictably when state is shared.
Appendix E — Testing cookie-based session middleware¶
Session cookies are the basic shape of stateful web auth. Test them by sending a cookie and asserting the handler accepts it.
type sessionStore struct {
mu sync.Mutex
sessions map[string]string // id -> user
}
func SessionMiddleware(s *sessionStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session")
if err != nil {
http.Error(w, "no session", http.StatusUnauthorized)
return
}
s.mu.Lock()
user, ok := s.sessions[c.Value]
s.mu.Unlock()
if !ok {
http.Error(w, "bad session", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func TestSession_Valid(t *testing.T) {
store := &sessionStore{sessions: map[string]string{"sid-1": "alice"}}
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := r.Context().Value(userKey{})
fmt.Fprint(w, u)
})
mw := SessionMiddleware(store)(inner)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "sid-1"})
mw.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("code = %d", rec.Code)
}
if rec.Body.String() != "alice" {
t.Fatalf("body = %q", rec.Body.String())
}
}
For an end-to-end test, use a *http.Client with a cookie jar:
func TestSession_EndToEnd(t *testing.T) {
store := &sessionStore{sessions: map[string]string{"sid-1": "alice"}}
ts := httptest.NewServer(SessionMiddleware(store)(myHandler))
t.Cleanup(ts.Close)
jar, _ := cookiejar.New(nil)
client := ts.Client()
client.Jar = jar
// Manually inject the cookie before the first request.
u, _ := url.Parse(ts.URL)
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "sid-1", Path: "/"}})
resp, err := client.Get(ts.URL + "/me")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("code = %d", resp.StatusCode)
}
}
The jar pattern matters when your test issues a login (which sets the cookie) followed by an authenticated request (which sends it back). With a jar, the client handles cookie storage for you.
Appendix F — File-upload handler testing¶
Multipart uploads need a multipart body. Testing with httptest.NewRequest:
func TestUpload(t *testing.T) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("name", "ada")
fw, _ := mw.CreateFormFile("avatar", "ada.png")
fw.Write([]byte("fake-png-bytes"))
mw.Close()
req := httptest.NewRequest("POST", "/upload", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
UploadHandler(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
}
The handler parses with r.ParseMultipartForm(maxMemory). Common bugs:
- The handler reads the file before checking its
Content-Type(security risk). - The handler accepts files larger than the configured limit (DoS risk).
- The handler doesn't close the file from
r.FormFile, leaking file descriptors.
Each is a separate test case with appropriately-sized inputs.