Testing HTTP Handlers in Go with net/http/httptest
Use httptest.NewRecorder() to test handlers directly in-process — fast, no TCP, no port. Use httptest.NewServer() when you need a real TCP server (e.g., testing an HTTP client). Both are in the standard library. Always call ts.Close() with defer. Use httptest.NewRequest() over http.NewRequest() for cleaner handler tests.
Testing HTTP handlers in Go does not require spinning up a real server. The net/http/httptest package provides two tools — httptest.ResponseRecorder and httptest.Server — that cover every scenario from isolated handler unit tests to full router integration tests. Both are in the standard library, fast, and require zero setup beyond what your handler already needs.
httptest.NewRecorder: In-Process Handler Testing
httptest.NewRecorder() returns a *httptest.ResponseRecorder that implements http.ResponseWriter. You pass it directly to your handler and inspect the recorded response:
func TestGetUser(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
w := httptest.NewRecorder()
GetUserHandler(w, req)
res := w.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
var user User
if err := json.NewDecoder(res.Body).Decode(&user); err != nil {
t.Fatalf("decode body: %v", err)
}
if user.ID != 42 {
t.Errorf("user.ID = %d, want 42", user.ID)
}
}httptest.NewRequest is preferred over http.NewRequest in handler tests because it panics on error (keeping test code clean) and sets RequestURI correctly, which some handlers read. The recorder captures status code, headers, and body without any network involvement.
Inspecting the Response
ResponseRecorder exposes:
w.Code— integer status code (set afterWriteHeaderis called; defaults to 200 ifWriteis called withoutWriteHeader)w.Header()— the response header map (inspect before callingw.Result())w.Body— a*bytes.Bufferwith the written bodyw.Result()— an*http.Responseassembled from the above (more idiomatic for full response inspection)
func TestCreateUser(t *testing.T) {
body := strings.NewReader(`{"name":"alice","email":"alice@example.com"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
CreateUserHandler(w, req)
if w.Code != http.StatusCreated {
t.Errorf("status = %d, want %d", w.Code, http.StatusCreated)
}
contentType := w.Header().Get("Content-Type")
if !strings.Contains(contentType, "application/json") {
t.Errorf("Content-Type = %q, want application/json", contentType)
}
var created User
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode response: %v", err)
}
if created.Name != "alice" {
t.Errorf("name = %q, want alice", created.Name)
}
}Testing Error Responses
Error paths are often the most important to test and the easiest to miss:
func TestGetUser_NotFound(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users/99999", nil)
w := httptest.NewRecorder()
GetUserHandler(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
var errResp ErrorResponse
if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil {
t.Fatalf("decode error response: %v", err)
}
if errResp.Code != "USER_NOT_FOUND" {
t.Errorf("error code = %q, want USER_NOT_FOUND", errResp.Code)
}
}
func TestCreateUser_InvalidJSON(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
CreateUserHandler(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}Testing with Middleware Chains
Handlers in production are rarely called directly — they go through middleware for authentication, logging, and rate limiting. Test the composed chain, not just the raw handler:
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "Bearer secret" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func TestGetProfile_WithAuth(t *testing.T) {
handler := AuthMiddleware(http.HandlerFunc(GetProfileHandler))
t.Run("authenticated request succeeds", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
req.Header.Set("Authorization", "Bearer secret")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
})
t.Run("missing token returns 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
})
t.Run("wrong token returns 401", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
req.Header.Set("Authorization", "Bearer wrong")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
})
}Building the middleware chain once in the test function, then sharing it across subtests, mirrors how production code constructs the chain.
httptest.NewServer: Real TCP Server
When you need a real HTTP server — for testing an HTTP client, webhook handling, or third-party SDK integration — use httptest.NewServer:
func TestFetchExchangeRate(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/rates/USD" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]float64{"EUR": 0.92, "GBP": 0.79})
}))
defer ts.Close()
client := NewExchangeClient(ts.URL) // inject the test server URL
rates, err := client.Fetch("USD")
if err != nil {
t.Fatalf("Fetch: %v", err)
}
if rates["EUR"] != 0.92 {
t.Errorf("EUR rate = %v, want 0.92", rates["EUR"])
}
}ts.URL gives you the full http://127.0.0.1:PORT address. Always defer ts.Close() immediately after creating the server to ensure cleanup even if the test panics. For TLS, use httptest.NewTLSServer() — it generates a self-signed certificate and configures a client available at ts.Client().
Injecting Test Dependencies via Context
Handlers often read values from context.Context — authenticated user, request ID, feature flags. Set them in the test request:
type contextKey string
const userKey contextKey = "user"
func TestAdminOnlyHandler(t *testing.T) {
t.Run("admin user can access", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
ctx := context.WithValue(req.Context(), userKey, User{ID: 1, Role: "admin"})
req = req.WithContext(ctx)
w := httptest.NewRecorder()
AdminHandler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
})
t.Run("regular user gets 403", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
ctx := context.WithValue(req.Context(), userKey, User{ID: 2, Role: "user"})
req = req.WithContext(ctx)
w := httptest.NewRecorder()
AdminHandler(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
})
}Integration Patterns for Router Testing
Testing a full router (e.g., chi, gorilla/mux, or net/http's ServeMux) is identical — pass the router as the handler:
func setupRouter(db *sql.DB) http.Handler {
mux := http.NewServeMux()
h := &UserHandler{db: db}
mux.HandleFunc("GET /users/{id}", h.GetUser)
mux.HandleFunc("POST /users", h.CreateUser)
mux.HandleFunc("DELETE /users/{id}", h.DeleteUser)
return mux
}
func TestUserRouter(t *testing.T) {
db := setupTestDB(t)
router := setupRouter(db)
tests := []struct {
name string
method string
path string
body string
wantStatus int
}{
{"get existing user", "GET", "/users/1", "", http.StatusOK},
{"get nonexistent user", "GET", "/users/9999", "", http.StatusNotFound},
{"create user", "POST", "/users", `{"name":"dave"}`, http.StatusCreated},
{"delete user", "DELETE", "/users/1", "", http.StatusNoContent},
{"unknown route", "GET", "/nonexistent", "", http.StatusNotFound},
}
for _, tt := range tests {
tt := tt
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, tt.path, body)
if tt.body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String())
}
})
}
}This pattern tests route registration, method matching, and handler behavior in a single table. Including w.Body.String() in the error message makes failures self-diagnosing.
Key Takeaways
httptest.NewRecorder is the right tool for handler unit tests — fast, no network, no ports. httptest.NewServer is for client integration tests where you control the upstream. Build your middleware chain before the test loop when testing composed handlers. Use context.WithValue to inject per-request state without modifying handler signatures. Testing the full router, not individual handlers, catches routing mismatches early and mirrors the way production traffic flows.