Testing HTTP handlers in Go: what developers should know
HTTP handlers are the backbone of any API, yet testing them often feels awkward in many languages. In Go, the standard library gives us httptest, a package that removes friction and lets us verify handler behavior without spinning up actual servers or making real network calls. Whether you're building microservices, REST APIs, or internal tools, knowing how to test HTTP handlers properly will save your team hours of debugging and catch edge cases before production.
Why httptest matters in production code
When we deploy APIs to production, we need confidence that our handlers respond correctly to different request states: valid input, edge cases, missing headers, timeouts, database failures. Manual testing or integration tests alone aren't fast enough for a proper CI/CD pipeline. The standard library's httptest package lets us unit test handlers in milliseconds, without external dependencies, and without needing to bind to a real port.
We've seen teams skip handler-level testing and only write integration tests, then spend weeks tracking down why a particular endpoint behaves differently in staging than in local development. The issue? They never tested the handler in isolation. By the time a request reaches your handler, it's already been through middleware, routing, and potentially a reverse proxy. Testing handlers directly, before they hit the network, catches bugs early.
Using httptest.NewRecorder to capture responses
httptest.NewRecorder is the core tool for handler testing. It implements http.ResponseWriter, so your handler writes to it as if writing to a real client. After the handler finishes, we read the captured status code, headers, and body.
Let's say we have a simple handler that returns user data:
package handlers
import (
"encoding/json"
"net/http"
)
func GetUser(w http.ResponseWriter, r *http.Request) {
user := map[string]interface{}{
"id": 123,
"name": "Alice",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(user)
}Now we test it with httptest.NewRecorder:
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestGetUser(t *testing.T) {
req := httptest.NewRequest("GET", "/user", nil)
w := httptest.NewRecorder()
GetUser(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
if w.Header().Get("Content-Type") != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", w.Header().Get("Content-Type"))
}
}We create a test request with httptest.NewRequest, then create a recorder. The handler writes to the recorder, and we inspect the result. No server binding, no network I/O. The test runs in microseconds.
This approach scales well. We can test different request methods, paths, query strings, and request bodies by varying the NewRequest call. For a POST with a JSON body:
func TestCreateUser(t *testing.T) {
body := strings.NewReader(`{"name":"Bob"}`)
req := httptest.NewRequest("POST", "/user", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
CreateUser(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected 201, got %d", w.Code)
}
}Using httptest.NewServer for client-side tests
Sometimes we need to test a client that makes HTTP calls, not just a handler. httptest.NewServer spins up a temporary HTTP server that responds to requests during the test, then shuts down automatically.
Imagine we have a client that fetches data from an external service:
type APIClient struct {
BaseURL string
Client *http.Client
}
func (c *APIClient) FetchUser(id string) (map[string]interface{}, error) {
resp, err := c.Client.Get(c.BaseURL + "/user/" + id)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var user map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return user, nil
}We test this client by creating a test server:
func TestFetchUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "456",
"name": "Charlie",
})
}))
defer server.Close()
client := &APIClient{
BaseURL: server.URL,
Client: &http.Client{},
}
user, err := client.FetchUser("456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user["name"] != "Charlie" {
t.Errorf("expected name Charlie, got %v", user["name"])
}
}The test server listens on a random available port, so tests run in parallel without port conflicts. We can simulate different server responses by changing the handler logic inside the test, making it easy to test error paths and edge cases in our client code.
Asserting status, headers, and body content
A robust test checks more than just the status code. We should verify headers, body structure, and specific values. Let's look at a more complete example:
func TestUserEndpointComplete(t *testing.T) {
req := httptest.NewRequest("GET", "/user/123", nil)
w := httptest.NewRecorder()
GetUser(w, req)
// Check status
if w.Code != http.StatusOK {
t.Errorf("status: expected 200, got %d", w.Code)
}
// Check headers
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type: expected application/json, got %s", ct)
}
if cache := w.Header().Get("Cache-Control"); cache == "" {
t.Errorf("Cache-Control header missing")
}
// Check body
var user map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&user); err != nil {
t.Fatalf("body decode error: %v", err)
}
if user["id"] != float64(123) {
t.Errorf("user id: expected 123, got %v", user["id"])
}
}For JSON responses, we decode the body into a struct or map and verify the fields match our expectations. For text or HTML responses, we can use string matching or regular expressions. The key is asserting all three layers: status, headers, and body.
Testing middleware separately
Middleware often wraps handlers to add logging, authentication, or error recovery. Testing middleware in isolation ensures it doesn't accidentally strip headers, swallow errors, or break the response chain.
Here's a simple authentication middleware:
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
if token != "valid-token" {
http.Error(w, "invalid token", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}We test it by wrapping a simple handler:
func TestAuthMiddleware(t *testing.T) {
testCases := []struct {
name string
token string
expectedStatus int
}{
{"no token", "", http.StatusUnauthorized},
{"invalid token", "bad-token", http.StatusForbidden},
{"valid token", "valid-token", http.StatusOK},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
wrapped := AuthMiddleware(handler)
req := httptest.NewRequest("GET", "/protected", nil)
if tc.token != "" {
req.Header.Set("Authorization", tc.token)
}
w := httptest.NewRecorder()
wrapped.ServeHTTP(w, req)
if w.Code != tc.expectedStatus {
t.Errorf("expected %d, got %d", tc.expectedStatus, w.Code)
}
})
}
}By testing middleware with table-driven subtests, we cover all authentication paths: missing token, invalid token, and valid token. This ensures the middleware correctly passes through valid requests and rejects invalid ones.
Common mistakes
Forgetting to check the response body: A handler might return status 200 but send corrupted JSON or an empty body. Always decode and verify the body content, not just the status code.
Testing with a real database: Handler tests should be fast and isolated. If your handler queries a database, mock the database or use an in-memory store during tests. Reaching the network makes tests flaky and slow.
Not testing error paths: We tend to test the happy path first and skip error scenarios. Test what happens when authentication fails, when required headers are missing, when the request body is malformed, and when downstream services timeout.
Ignoring header order or case sensitivity: HTTP headers are case-insensitive, and `httptest.NewRecorder` preserves the order they were written. Don't assume a specific order. Use w.Header().Get("X-Custom"), which is case-insensitive.
Not setting Content-Type in requests: Some handlers check the request's Content-Type header to decide how to parse the body. If you send JSON but don't set the header, the handler might reject it. Always set req.Header.Set("Content-Type", "application/json") when sending JSON.
Key takeaways
httptest.NewRecordercaptures handler responses without a real server; use it for unit tests of your handlers.httptest.NewRequestandhttptest.NewRecorderwork together to test handler logic in microseconds.httptest.NewServercreates a temporary HTTP server for testing clients that make outbound HTTP calls.- Assert status code, response headers, and decoded body content to catch regressions early.
- Test middleware in isolation using table-driven subtests to cover authentication, authorization, and error paths.
- Keep handler tests fast by mocking dependencies; avoid real network I/O or database queries in unit tests.
- Use subtests and table-driven testing patterns to cover multiple scenarios without duplicating test code.






Comments
Loading comments…