Go httptest: Testing HTTP Handlers
Go's standard library includes everything you need to test HTTP handlers without external dependencies. The `net/http/httptest` package embodies Go's testing philosophy: keep it simple, keep it in...
Key Insights
- Go’s
httptestpackage provides two primary tools:ResponseRecorderfor unit testing handlers without network overhead, andServerfor integration tests requiring real HTTP connections. - Table-driven tests are the idiomatic way to achieve comprehensive HTTP handler coverage, letting you test dozens of scenarios with minimal code duplication.
- Always test middleware in isolation first, then verify the complete chain—this catches subtle bugs where middleware order matters.
Introduction to httptest
Go’s standard library includes everything you need to test HTTP handlers without external dependencies. The net/http/httptest package embodies Go’s testing philosophy: keep it simple, keep it in the standard library, and make it fast.
Testing HTTP handlers matters because your API is a contract. When that contract breaks, clients break. The httptest package lets you verify handler behavior without spinning up servers, making network calls, or dealing with port conflicts. Tests run in milliseconds, not seconds.
The package provides two main tools: ResponseRecorder for capturing handler output in memory, and Server for when you need a real HTTP server. Most of your tests will use the former. Let’s dig in.
Understanding httptest.ResponseRecorder
ResponseRecorder implements http.ResponseWriter, capturing everything your handler writes. Instead of sending bytes over a network, it stores them in memory for inspection.
The key fields you’ll use:
Code: The HTTP status code (defaults to 200 if not explicitly set)Body: A*bytes.Buffercontaining the response bodyHeader(): Returns the response headers
Here’s a basic handler and its test:
package api
import (
"encoding/json"
"net/http"
)
type HealthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
}
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(HealthResponse{
Status: "healthy",
Version: "1.2.3",
})
}
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)
if rec.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", rec.Code)
}
contentType := rec.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("expected Content-Type application/json, got %s", contentType)
}
var resp HealthResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.Status != "healthy" {
t.Errorf("expected status healthy, got %s", resp.Status)
}
}
Notice how NewRequest creates a request without any network involvement. The first argument is the method, second is the target URL (only the path matters for handler testing), and third is the request body.
Testing Different HTTP Methods and Routes
Real APIs handle multiple methods with request bodies. Here’s a POST handler that creates a user:
package api
import (
"encoding/json"
"net/http"
)
type CreateUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
http.Error(w, "email is required", http.StatusBadRequest)
return
}
user := User{
ID: "usr_123",
Email: req.Email,
Name: req.Name,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func TestCreateUserHandler(t *testing.T) {
body := strings.NewReader(`{"email":"test@example.com","name":"Test User"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
CreateUserHandler(rec, req)
if rec.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d", rec.Code)
}
var user User
if err := json.NewDecoder(rec.Body).Decode(&user); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if user.Email != "test@example.com" {
t.Errorf("expected email test@example.com, got %s", user.Email)
}
if user.ID == "" {
t.Error("expected user ID to be set")
}
}
The request body is an io.Reader—use strings.NewReader for string bodies or bytes.NewBuffer for byte slices. Don’t forget to set the Content-Type header when your handler expects it.
Testing Middleware
Middleware wraps handlers to add cross-cutting concerns. Test them in isolation before testing the full chain.
package api
import (
"net/http"
"strings"
)
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, "unauthorized", http.StatusUnauthorized)
return
}
if !strings.HasPrefix(token, "Bearer ") {
http.Error(w, "invalid token format", http.StatusUnauthorized)
return
}
// In reality, validate the token here
next.ServeHTTP(w, r)
})
}
func TestAuthMiddleware(t *testing.T) {
// Create a simple handler that the middleware wraps
protected := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("protected content"))
})
handler := AuthMiddleware(protected)
t.Run("missing token returns 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
})
t.Run("invalid token format returns 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "InvalidFormat token123")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
})
t.Run("valid token allows access", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "Bearer valid_token")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
})
}
The pattern is straightforward: create a dummy handler, wrap it with middleware, and verify the middleware’s behavior for both allowed and rejected requests.
Using httptest.Server for Integration Tests
Sometimes you need a real HTTP server. Maybe you’re testing client code, verifying connection handling, or testing multiple endpoints in sequence. httptest.NewServer spins up an actual server on a random available port.
func TestAPIIntegration(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/health", HealthHandler)
mux.HandleFunc("/users", CreateUserHandler)
server := httptest.NewServer(mux)
defer server.Close()
client := server.Client()
t.Run("health check", func(t *testing.T) {
resp, err := client.Get(server.URL + "/health")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("create user", func(t *testing.T) {
body := strings.NewReader(`{"email":"integration@test.com","name":"Integration"}`)
resp, err := client.Post(server.URL+"/users", "application/json", body)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("expected 201, got %d", resp.StatusCode)
}
var user User
json.NewDecoder(resp.Body).Decode(&user)
if user.Email != "integration@test.com" {
t.Errorf("unexpected email: %s", user.Email)
}
})
}
Use server.Client() to get an *http.Client configured for the test server. Always call server.Close() in a defer to clean up resources.
When should you use Server vs ResponseRecorder? Use ResponseRecorder for unit tests—it’s faster and simpler. Use Server when testing client code, connection pooling, timeouts, or when you need multiple requests in a realistic sequence.
Table-Driven Tests for HTTP Handlers
Table-driven tests are Go’s answer to parameterized testing. They’re perfect for HTTP handlers where you need to test many scenarios.
func TestCreateUserHandler_TableDriven(t *testing.T) {
tests := []struct {
name string
method string
body string
expectedStatus int
expectedBody string
}{
{
name: "successful creation",
method: http.MethodPost,
body: `{"email":"valid@example.com","name":"Valid User"}`,
expectedStatus: http.StatusCreated,
},
{
name: "missing email",
method: http.MethodPost,
body: `{"name":"No Email"}`,
expectedStatus: http.StatusBadRequest,
expectedBody: "email is required",
},
{
name: "invalid json",
method: http.MethodPost,
body: `{invalid json`,
expectedStatus: http.StatusBadRequest,
expectedBody: "invalid request body",
},
{
name: "wrong method",
method: http.MethodGet,
body: "",
expectedStatus: http.StatusMethodNotAllowed,
},
{
name: "empty body",
method: http.MethodPost,
body: "",
expectedStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body io.Reader
if tt.body != "" {
body = strings.NewReader(tt.body)
}
req := httptest.NewRequest(tt.method, "/users", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
CreateUserHandler(rec, req)
if rec.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, rec.Code)
}
if tt.expectedBody != "" {
if !strings.Contains(rec.Body.String(), tt.expectedBody) {
t.Errorf("expected body to contain %q, got %q", tt.expectedBody, rec.Body.String())
}
}
})
}
}
This pattern scales beautifully. Adding a new test case is a single struct literal. The test logic stays DRY while coverage grows.
Best Practices and Common Pitfalls
Create test helpers for repeated setup. If you’re creating requests with auth headers in every test, extract that into a helper function.
func newAuthenticatedRequest(t *testing.T, method, path string, body io.Reader) *http.Request {
t.Helper()
req := httptest.NewRequest(method, path, body)
req.Header.Set("Authorization", "Bearer test_token")
req.Header.Set("Content-Type", "application/json")
return req
}
Mock external dependencies. If your handler calls a database or external API, inject those dependencies and mock them in tests. Don’t let tests hit real services.
Test error responses thoroughly. Happy paths are easy. The bugs hide in error handling. Test malformed input, missing fields, unauthorized access, and not-found scenarios.
Don’t ignore the response body on errors. Your API probably returns error details in the body. Verify them.
Clean up test servers. Always defer server.Close(). Leaked servers cause port exhaustion and flaky tests.
Use t.Parallel() carefully. Parallel tests are faster but can conflict if they share state. Each test should be independent.
The httptest package gives you everything needed for fast, reliable HTTP handler tests. Start with ResponseRecorder for unit tests, graduate to Server for integration tests, and use table-driven tests to maximize coverage with minimal code. Your API’s reliability depends on it.