httptest for Handler Integration Tests
The net/http/httptest package tests HTTP handlers without manual TCP setup.
ResponseRecorder supports fast unit-style handler tests; NewServer supports integration tests that exercise real http.Client round trips.
Summary
Handler tests should assert status codes, headers, and bodies against expected behavior.
httptest.NewRequest constructs *http.Request values with method, URL, and body.
httptest.ResponseRecorder implements http.ResponseWriter and records what the handler wrote.
httptest.NewServer wraps a handler with an ephemeral http.Server and returns a base URL for client calls.
Table-driven tests keep route matrices maintainable.
Recipe
Quick-reference recipe card - copy-paste ready.
func TestHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/items", strings.NewReader(`{"name":"a"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
}When to reach for this:
- Verifying handlers return correct status, content type, and JSON shape.
- Testing middleware short-circuit paths (401, 403) without running a server.
- Exercising full client/server integration with TLS-free local listeners.
Working Example
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type item struct {
Name string `json:"name"`
}
func itemsHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]item{{Name: "seed"}})
case http.MethodPost:
var in item
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(in)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func TestItemsHandler_recorder(t *testing.T) {
tests := []struct {
name string
method string
body string
want int
}{
{"get ok", http.MethodGet, "", http.StatusOK},
{"post ok", http.MethodPost, `{"name":"x"}`, http.StatusCreated},
{"post bad json", http.MethodPost, `{`, http.StatusBadRequest},
{"patch not allowed", http.MethodPatch, "", http.StatusMethodNotAllowed},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var body io.Reader
if tc.body != "" {
body = strings.NewReader(tc.body)
}
req := httptest.NewRequest(tc.method, "/items", body)
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
itemsHandler(rec, req)
if rec.Code != tc.want {
t.Fatalf("got %d want %d body=%q", rec.Code, tc.want, rec.Body.String())
}
})
}
}
func TestItemsHandler_newServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(itemsHandler))
defer srv.Close()
resp, err := http.Get(srv.URL + "/items")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d", resp.StatusCode)
}
}What this demonstrates:
- Table-driven tests with
ResponseRecorderfor direct handler invocation. - JSON body setup via
strings.NewReaderonNewRequest. - Method-not-allowed branch coverage.
httptest.NewServerblack-box test usinghttp.Getagainstsrv.URL.
Deep Dive
How It Works
NewRecorderreturns aResponseRecorderwithCode,Header, andBodyfields populated afterServeHTTP.NewRequestsetsURL.PathandHostsuitable for handler code that readsr.URLandr.Host.NewServerpicks a free port, starts listening in a goroutine, and blocks shutdown onClose().NewTLSServerprovides TLS for testing cert validation paths.
Testing Patterns
| Pattern | Tool | Best for |
|---|---|---|
| Handler unit test | ResponseRecorder | Status/body/header assertions |
| Middleware test | Recorder + stub next | Short-circuit behavior |
| Mux routing test | mux.ServeHTTP(rec, req) | Pattern matching |
| Client integration | NewServer + http.Client | Full round trip |
Go Notes
// Assert JSON without brittle string compares:
var got item
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
// Run server tests in parallel with their own srv:
t.Parallel()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)Gotchas
- Not calling
Close()onNewServer- Leaks goroutines and ports in repeated test runs. Fix:defer srv.Close()ort.Cleanup(srv.Close). - Reusing global mux across tests - Route registration races with
t.Parallel. Fix: Create freshhttp.NewServeMux()per test or uset.Parallelonly with isolated handlers. - Comparing entire JSON strings - Field order is not stable. Fix: Unmarshal to structs or use
json.RawMessagecomparisons. - Forgetting Content-Type on POST - Decoder errors mask handler logic bugs. Fix: Set headers in tests exactly as clients would.
- Using
http.DefaultClientwithout timeout in tests - Hanging tests if server stalls. Fix: Shorthttp.Client{Timeout: ...}in integration tests. - Assuming
rec.Codedefaults to 200 - Zero means unset untilWriteHeaderor firstWrite. Fix: CheckCodeafter handler returns; default is 200 only after write.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| ginkgo/gomega HTTP matchers | BDD-style specs preferred | Standard testing package suffices |
| dockerized integration | Full stack with real DB | Handler logic only needs in-memory tests |
| recorded fixtures | Stable third-party API contracts | Testing your own handlers in-process |
manual net.Listen | Custom TLS or HTTP/2 setup | httptest.NewServer covers most cases |
FAQs
When should I use Recorder vs NewServer?
Use ResponseRecorder for direct handler unit tests.
Use NewServer when the test must exercise http.Client, redirects, or TLS.
How do I test authenticated routes?
Set Authorization or session cookies on httptest.NewRequest before invoking the handler chain.
Can I test ServeMux patterns?
Yes - call mux.ServeHTTP(rec, req) with method and path matching registered patterns.
How do I capture response headers?
Read rec.Header().Get("Content-Type") after the handler returns.
rec.Result() returns an *http.Response snapshot for advanced cases.
Does NewRequest set a context?
It uses context.Background() by default.
Use req.WithContext(ctx) to test cancellation behavior.
How do I test middleware that wraps ResponseWriter?
Pass a custom ResponseWriter or use recorder and assert the wrapper observed the status.
Can httptest run HTTP/2 tests?
NewUnstartedServer plus manual ConfigureServer can enable HTTP/2.
Most handler tests do not require HTTP/2-specific behavior.
How do I test file uploads?
Build multipart.Writer body, set Content-Type boundary header, and pass the buffer to NewRequest.
Should integration tests use t.Parallel?
Yes when each subtest owns its own server or mux.
Avoid parallel tests mutating shared global http.DefaultServeMux.
How do I assert error log output?
Redirect log output to bytes.Buffer in tests or inject a logger interface into handlers.
What about testing ReverseProxy handlers?
Start a NewServer for the fake upstream and mount ReverseProxy as the handler under test.
How do I test handler panics?
Exercise recovery middleware with a handler that panics and assert 500 status on the recorder.
Related
- net/http Basics - recorder intro example
- HTTP Servers, Handlers & ServeMux Patterns - mux routing tests
- Middleware Chains & Request Context - middleware test patterns
- Testing Code That Accepts context.Context - ctx in handlers
- HTTP Clients, Transport & Connection Pooling - client timeouts in tests
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).