Testing Go HTTP Handlers with httptest
HTTP handlers are one of the easiest parts of a Go service to test well. You usually do not need to bind a real network port, start the entire application, or depend on an external test framework.
Go’s standard library provides net/http/httptest, which can construct HTTP requests, capture handler responses, and even start temporary HTTP servers when a real client-server round trip matters.
This guide builds a small JSON endpoint and tests it at several useful levels.
The handler we will test
Suppose an API exposes a health endpoint that returns JSON:
package api
import (
"encoding/json"
"net/http"
)
type healthResponse struct {
Status string `json:"status"`
}
func HealthHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(healthResponse{Status: "ok"}); err != nil {
return
}
}The endpoint has three observable behaviors worth testing:
GETreturns status200 OK.- The response has an
application/jsoncontent type. - Unsupported methods return
405 Method Not Allowedand advertiseGETthrough theAllowheader.
These are API behaviors. Tests should focus on them instead of internal implementation details.
Test a handler without opening a port
httptest.NewRequest creates a request suitable for passing directly to an http.Handler. httptest.NewRecorder implements http.ResponseWriter and records what the handler writes.
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
if got := res.Header.Get("Content-Type"); got != "application/json" {
t.Fatalf("Content-Type = %q, want %q", got, "application/json")
}
var body healthResponse
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Status != "ok" {
t.Fatalf("status body = %q, want %q", body.Status, "ok")
}
}No TCP socket is involved. The test calls the handler as a normal Go function while preserving the HTTP request and response interfaces.
Why use Result()?
A recorder exposes fields such as Code and Body, but rec.Result() produces an *http.Response. That makes tests resemble normal HTTP client code and gives you a natural place to inspect status, headers, and the response body.
Test error paths too
A successful request is only one part of an endpoint’s contract. For this handler, sending POST should produce a 405 response.
func TestHealthHandlerRejectsPost(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf(
"status = %d, want %d",
res.StatusCode,
http.StatusMethodNotAllowed,
)
}
if got := res.Header.Get("Allow"); got != http.MethodGet {
t.Fatalf("Allow = %q, want %q", got, http.MethodGet)
}
}Testing failure paths is especially valuable for handlers because clients depend on status codes and headers just as much as they depend on successful response bodies.
Use table-driven tests for repeated cases
When several requests should exercise the same handler, a table keeps the setup in one place.
func TestHealthHandlerMethods(t *testing.T) {
tests := []struct {
name string
method string
wantStatus int
}{
{
name: "get",
method: http.MethodGet,
wantStatus: http.StatusOK,
},
{
name: "post",
method: http.MethodPost,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "delete",
method: http.MethodDelete,
wantStatus: http.StatusMethodNotAllowed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}Table-driven tests work particularly well for validation rules, query parameters, authentication states, and status-code matrices.
Do not force every test into a table, though. If individual cases require substantially different setup or assertions, separate tests are often easier to read.
Test JSON semantically, not as a raw string
A fragile JSON assertion might look like this:
if rec.Body.String() != "{\"status\":\"ok\"}\n" {
t.Fatal("unexpected body")
}It works for this exact encoder output, but it couples the test to whitespace and serialization details.
Decoding JSON into a struct is usually more robust:
var got healthResponse
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got.Status != "ok" {
t.Fatalf("Status = %q, want %q", got.Status, "ok")
}If the JSON structure itself is the contract, decoding into a struct also makes missing or incorrect fields easier to diagnose.
Add request bodies and headers
httptest.NewRequest accepts an io.Reader, so JSON request bodies can be created without special helpers.
body := strings.NewReader(`{"name":"Ada"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")Use obviously fake credentials such as test-token. Tests should never contain production tokens or copied secrets.
Query parameters work normally as part of the target URL:
req := httptest.NewRequest(
http.MethodGet,
"/users?limit=20&active=true",
nil,
)The handler can then read r.URL.Query() exactly as it would in production.
Test middleware and routing together
Direct handler tests are fast, but sometimes you need to verify middleware or route registration as well. Build the same http.Handler tree your application uses and pass that to the recorder.
mux := http.NewServeMux()
mux.HandleFunc("GET /health", HealthHandler)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}Testing through the mux catches mistakes that a direct function call cannot, such as an incorrect route pattern.
Method-aware ServeMux patterns such as "GET /health" require Go 1.22 or newer. If a project supports older Go versions, register a path-only pattern and check the method inside the handler instead.
Use httptest.Server when a real HTTP client matters
Some code does not accept an http.Handler. Instead, it calls a remote service through http.Client. httptest.NewServer is useful for this case because it starts a temporary local HTTP server and gives you its URL.
func TestClientRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/users/42" {
t.Fatalf("path = %q, want %q", r.URL.Path, "/users/42")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":42,"name":"Ada"}`))
}))
defer server.Close()
res, err := server.Client().Get(server.URL + "/users/42")
if err != nil {
t.Fatalf("GET test server: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
}This is slower than calling a handler directly because it performs a real HTTP round trip, but it is still self-contained and does not depend on an external service.
A useful rule is:
- Use
NewRecorderfor handler behavior. - Use your router or middleware stack when routing behavior matters.
- Use
NewServerwhen the code under test is an HTTP client or when a real round trip is important.
Common pitfalls
Only testing status codes
A 200 response can still contain the wrong content type or invalid JSON. Assert the parts of the response that form the endpoint’s public contract.
Comparing implementation details
Tests that depend on private helper calls or exact JSON whitespace become expensive to maintain. Prefer externally observable behavior.
Forgetting negative cases
Invalid methods, malformed JSON, missing authentication, invalid query parameters, and dependency failures often contain more bugs than the happy path.
Starting a production server in tests
Calling http.ListenAndServe makes tests depend on port availability and complicates cleanup. httptest avoids fixed ports and gives tests ownership of the server lifecycle.
Sharing mutable state between tests
Handlers that depend on package-level variables can make tests influence one another. Prefer constructing dependencies explicitly and giving each test its own state.
A practical project layout
A small service might keep the handler and its tests together:
internal/
└── api/
├── health.go
└── health_test.goRun the package tests with:
go test ./internal/apiRun all tests in the module with:
go test ./...For additional race detection during development and CI:
go test -race ./...The race detector adds overhead, so it is commonly used as an additional verification step rather than as the only test command.
Complete example
The following pair of files can be copied into a Go module and run directly with go test.
health.go:
package api
import (
"encoding/json"
"net/http"
)
type healthResponse struct {
Status string `json:"status"`
}
func HealthHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(healthResponse{Status: "ok"}); err != nil {
return
}
}health_test.go:
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
if got := res.Header.Get("Content-Type"); got != "application/json" {
t.Fatalf("Content-Type = %q, want %q", got, "application/json")
}
var body healthResponse
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Status != "ok" {
t.Fatalf("body status = %q, want %q", body.Status, "ok")
}
}
func TestHealthHandlerRejectsPost(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf(
"status = %d, want %d",
res.StatusCode,
http.StatusMethodNotAllowed,
)
}
if got := res.Header.Get("Allow"); got != http.MethodGet {
t.Fatalf("Allow = %q, want %q", got, http.MethodGet)
}
}The tests require no third-party packages, network services, credentials, or fixed ports.
Final checklist
Before considering an HTTP handler well tested, check whether its tests cover:
- successful requests;
- important response headers;
- decoded response data;
- invalid methods and inputs;
- authentication or authorization failures when applicable;
- router and middleware behavior when relevant;
- external HTTP calls through controlled test servers rather than live services.
net/http/httptest keeps these tests close to real HTTP semantics without turning every handler test into a full integration environment. Start with direct handler tests, then add router-level or temporary-server tests only where the extra realism catches behavior you actually depend on.