Test Middleware Chains in Go From Request to Response
Test Go middleware one piece at a time with a spy handler, then test the whole chain for order, short circuits, panics, headers and context values.

Middleware is easy to write and easy to get subtly wrong, because its bugs live in the interaction: which layer runs first, whether the next handler was called at all, what happens to the response on the way back out. A unit test of a single middleware catches half of them. The other half only appear when the middleware are composed, and they are the ones that show up as a missing log line during an incident, or an unauthenticated request that reached a handler.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article tests a small stack (request ID, logging, panic recovery, authentication) in two layers, first alone and then together, with the assertions that matter for each. It follows middleware basics, the recover middleware and auth middleware, and sits in the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with the standard library only.
The middleware under test
package mw
import (
"context"
"net/http"
"strings"
)
type Middleware func(http.Handler) http.Handler
// Chain applies middleware so the first one in the list is the outermost.
func Chain(h http.Handler, m ...Middleware) http.Handler {
for i := len(m) - 1; i >= 0; i-- {
h = m[i](h)
}
return h
}
type ctxKey string
const userKey ctxKey = "user"
func User(ctx context.Context) (string, bool) {
u, ok := ctx.Value(userKey).(string)
return u, ok
}
// RequestID keeps an incoming X-Request-Id or makes one, and echoes it on the response.
func RequestID(next http.Handler) http.Handler {
n := 0
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
n++
id = "gen-" + string(rune('0'+n))
}
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey("rid"), id)))
})
}
// Recover turns a panic in a later handler into a 500.
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Auth accepts "Bearer secret" and puts the user in the context; anything else stops here.
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || token != "secret" {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, "alice")))
})
}
// Logger records one line per request, after the handler returns, using the status it wrote.
func Logger(log func(string)) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log(r.Method + " " + r.URL.Path + " " + http.StatusText(rec.status))
})
}
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (s *statusWriter) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// Unwrap lets http.ResponseController reach the real writer for Flush, Hijack and deadlines.
func (s *statusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }Chain takes the middleware in reading order, outermost first, which is the part people get backwards when composing by hand. Everything the test file proves rests on that ordering.
One middleware at a time: the spy
A spy handler is the smallest useful test double for middleware. It stands in for next, and records what happened to it.
package mw
import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
type spy struct {
calls int
last *http.Request
do func(http.ResponseWriter, *http.Request)
}
func (s *spy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.calls++
s.last = r
if s.do != nil {
s.do(w, r)
}
}
func serve(h http.Handler, method, path string, headers ...string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil)
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestAuthRejectsWithoutCallingNext(t *testing.T) {
for name, header := range map[string]string{"no header": "", "wrong scheme": "Basic abc", "wrong token": "Bearer nope"} {
t.Run(name, func(t *testing.T) {
next := &spy{}
rec := serve(Auth(next), "GET", "/", "Authorization", header)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d", rec.Code)
}
if next.calls != 0 {
t.Fatalf("handler was reached %d time(s) without valid credentials", next.calls)
}
if rec.Header().Get("WWW-Authenticate") == "" {
t.Error("a 401 should say how to authenticate")
}
})
}
}
func TestAuthPutsTheUserInTheContext(t *testing.T) {
next := &spy{}
rec := serve(Auth(next), "GET", "/", "Authorization", "Bearer secret")
if rec.Code != http.StatusOK || next.calls != 1 {
t.Fatalf("status = %d, calls = %d", rec.Code, next.calls)
}
if u, ok := User(next.last.Context()); !ok || u != "alice" {
t.Errorf("user = %q, %v", u, ok)
}
if _, ok := User(httptest.NewRequest("GET", "/", nil).Context()); ok {
t.Error("a request that skipped Auth must not carry a user")
}
}
func TestRequestIDKeepsOrCreates(t *testing.T) {
h := RequestID(&spy{})
if got := serve(h, "GET", "/", "X-Request-Id", "abc").Header().Get("X-Request-Id"); got != "abc" {
t.Errorf("kept id = %q", got)
}
if got := serve(h, "GET", "/").Header().Get("X-Request-Id"); !strings.HasPrefix(got, "gen-") {
t.Errorf("generated id = %q", got)
}
}
func TestChainOrderAndLogging(t *testing.T) {
var events []string
mark := func(name string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
events = append(events, name+" in")
next.ServeHTTP(w, r)
events = append(events, name+" out")
})
}
}
h := Chain(&spy{do: func(w http.ResponseWriter, r *http.Request) { events = append(events, "handler") }},
mark("A"), mark("B"), mark("C"))
serve(h, "GET", "/")
want := []string{"A in", "B in", "C in", "handler", "C out", "B out", "A out"}
if !reflect.DeepEqual(events, want) {
t.Errorf("order = %v\nwant %v", events, want)
}
}
func TestOrderDecidesWhatTheLoggerSees(t *testing.T) {
boom := &spy{do: func(http.ResponseWriter, *http.Request) { panic("boom") }}
var outer, inner []string
// Logger outside Recover: the panic is turned into a 500 before the logger returns, so it sees the 500.
serve(Chain(boom, Logger(func(s string) { outer = append(outer, s) }), Recover), "GET", "/x")
// Logger inside Recover: the panic unwinds through the logger, which never logs the request at all.
serve(Chain(boom, Recover, Logger(func(s string) { inner = append(inner, s) })), "GET", "/x")
if len(outer) != 1 || !strings.HasSuffix(outer[0], "Internal Server Error") {
t.Errorf("logger outside recover logged %v", outer)
}
if len(inner) != 0 {
t.Errorf("logger inside recover logged %v; the panic skipped it", inner)
}
}
func TestFullStackShortCircuitsAndRecovers(t *testing.T) {
var logs []string
handler := &spy{do: func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/panic" {
panic("handler bug")
}
u, _ := User(r.Context())
w.Write([]byte("hello " + u))
}}
h := Chain(handler, RequestID, Logger(func(s string) { logs = append(logs, s) }), Recover, Auth)
if rec := serve(h, "GET", "/"); rec.Code != 401 || handler.calls != 0 {
t.Errorf("anonymous: status %d, handler calls %d", rec.Code, handler.calls)
}
rec := serve(h, "GET", "/", "Authorization", "Bearer secret")
if rec.Code != 200 || rec.Body.String() != "hello alice" || rec.Header().Get("X-Request-Id") == "" {
t.Errorf("authenticated: %d %q %v", rec.Code, rec.Body, rec.Header())
}
if rec := serve(h, "GET", "/panic", "Authorization", "Bearer secret"); rec.Code != 500 {
t.Errorf("panic: status %d", rec.Code)
}
if len(logs) != 3 {
t.Errorf("logged %d requests, want 3: %v", len(logs), logs)
}
}The first test is the one I would never skip: three flavors of bad credentials, and for each, the spy must show calls == 0. An auth middleware that sets a 401 and then forgets to return still writes the status correctly, so a status-only test passes, while the handler runs underneath and does its work. Counting calls on the spy is the only assertion that catches it.
The context test checks both directions. The spy captured the request it received, so the test can read the user from next.last.Context(), and a fresh request that never passed through Auth has no user, which proves the key is not a global that something else could have set. Keep that second assertion; it is what makes handlers that call User(ctx) safe to write.
The chain: order is behavior
TestChainOrderAndLogging makes the nesting visible. Three markers wrap a handler, and the event log must read A in, B in, C in, handler, C out, B out, A out. If Chain iterated forwards instead of backwards, it would read the other way round and every test that relies on "first in the list is outermost" would still pass individually. This test is the guard.
The next test is the practical consequence. A logger placed outside Recover writes its line after the panic has been converted into a 500, so the log shows Internal Server Error. Swap them, and the panic travels up through the logger, which never reaches its logging statement, and the request vanishes from the logs entirely. Both stacks compile, both serve traffic, and only one tells you about the crash. This is the order mistake that is invisible until an incident.
The last test runs the real stack with a handler that sometimes panics. It asserts all three outcomes: an anonymous request gets a 401 and the handler is untouched, an authenticated one reaches the handler with the user in the context and gets a request ID, and a panic becomes a 500 without taking down the test. It also counts the log lines, three for three requests, since a logger placed inside the auth middleware would miss the rejected one.
Wrappers must keep the writer's abilities
A middleware that wraps the ResponseWriter to capture the status, as Logger does, replaces the writer the handler sees. The wrapper only has the methods it declares, so unless it forwards them, a handler downstream loses Flush, Hijack and the rest. Server sent events stop streaming, WebSockets fail to upgrade, and nothing in the wrapper's own tests notices. The fix is one method, Unwrap() http.ResponseWriter, which http.ResponseController follows to reach the real writer, as covered in the response writer wrapper article. The test is just as short: a handler that flushes through a controller, behind the middleware, and an assertion on rec.Flushed.
package mw
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestLoggerKeepsFlushWorking(t *testing.T) {
stream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("event 1\n"))
if err := http.NewResponseController(w).Flush(); err != nil {
t.Errorf("Flush through the middleware failed: %v", err)
}
})
rec := httptest.NewRecorder()
Logger(func(string) {})(stream).ServeHTTP(rec, httptest.NewRequest("GET", "/events", nil))
if !rec.Flushed {
t.Error("the recorder never saw a flush")
}
}=== RUN TestLoggerKeepsFlushWorking
--- PASS: TestLoggerKeepsFlushWorking (0.00s)
=== RUN TestAuthRejectsWithoutCallingNext
=== RUN TestAuthRejectsWithoutCallingNext/no_header
=== RUN TestAuthRejectsWithoutCallingNext/wrong_scheme
=== RUN TestAuthRejectsWithoutCallingNext/wrong_token
--- PASS: TestAuthRejectsWithoutCallingNext (0.00s)
--- PASS: TestAuthRejectsWithoutCallingNext/no_header (0.00s)
--- PASS: TestAuthRejectsWithoutCallingNext/wrong_scheme (0.00s)
--- PASS: TestAuthRejectsWithoutCallingNext/wrong_token (0.00s)
=== RUN TestAuthPutsTheUserInTheContext
--- PASS: TestAuthPutsTheUserInTheContext (0.00s)
=== RUN TestRequestIDKeepsOrCreates
--- PASS: TestRequestIDKeepsOrCreates (0.00s)
=== RUN TestChainOrderAndLogging
--- PASS: TestChainOrderAndLogging (0.00s)
=== RUN TestOrderDecidesWhatTheLoggerSees
--- PASS: TestOrderDecidesWhatTheLoggerSees (0.00s)
=== RUN TestFullStackShortCircuitsAndRecovers
--- PASS: TestFullStackShortCircuitsAndRecovers (0.00s)
PASS
ok example.test/lab 1.239sI wrote the test before adding Unwrap to statusWriter, and it failed with feature not supported: the controller could not find a flusher under the wrapper. With the method added, it passes. A request-level test like this is the only reliable guard, because the failure is a missing capability, and capabilities are invisible to a compiler.
Configuration is behavior too
Middleware with options, such as a rate limit of ten requests per minute, an allowed origin list or a maximum body size, needs tests at the boundary of each option, because off-by-one mistakes live there. For a limiter, send exactly the limit and check that the last request passes and the next one gets a 429 with a Retry-After; for a body cap, send a body of the limit and then one byte more. Table tests suit this well: the row is the option value and the number of requests, the expectation is the status of the last one.
Also test the default. A constructor that takes Options{} with zero values should behave safely: a zero timeout that means "no timeout", or an empty allowed-origin list that means "allow everything", is a security bug waiting for someone to forget a field. If zero is not a valid configuration, the constructor should reject it, and a test should say so.
Rules of thumb
- Give every middleware at least one test that checks the negative case: when it should not call
next, the spy has zero calls. - Test order wherever order has consequences: logging versus recovery, authentication versus rate limiting (limit before auth if you want to throttle guessing), compression versus ETags.
- Assert on headers on every path. It is easy to set a header only on the success path, and a 401 with no
WWW-Authenticateor a 500 with no request ID is the response you most need to be complete. - Test with the real
Chainfunction you use in production, not a copy in the test file. - Include a streaming or flush test for any middleware that wraps the writer.
- Keep time out of it. If a middleware measures duration or rate limits, inject a clock, as described in testing with a fake clock, so the tests do not sleep. A test suite for middleware that waits on real timers is a suite people stop running, and the tests that matter most, the ones for failure paths, are the first to be skipped.
The wider picture of how these pieces fit a server is in the production HTTP server article, the interface contract is in the http.Handler reference, and the testing tools are documented in the net/http/httptest reference.


