Testing HTTP Handlers in Go with net/http/httptest

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 after WriteHeader is called; defaults to 200 if Write is called without WriteHeader)
  • w.Header() — the response header map (inspect before calling w.Result())
  • w.Body — a *bytes.Buffer with the written body
  • w.Result() — an *http.Response assembled 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.

Read more

Start now free