Story / scenario opener
You write a handler that returns JSON. You run go run main.go, open your browser, hit the endpoint, and see the response. It works. Now you want to write a test. Starting a real server, binding to a port, making an HTTP request, and tearing it down for every test case feels heavy. You don't need a network stack to verify that your handler returns the right status code or writes the correct body. You need a way to call the handler directly, as if the HTTP request had just arrived.
Concept in plain words
The net/http/httptest package gives you a mock server and client that live entirely in memory. You create a request object, create a response recorder, and call ServeHTTP on your handler. The recorder captures everything the handler writes: status code, headers, and body. No ports, no listeners, no network overhead.
Think of httptest like a kitchen tasting spoon. You don't need to plate the dish and serve it to a customer to check the seasoning. You taste it directly from the pot. httptest lets you taste the response directly from the handler. No plating, no serving, just the flavor. The handler thinks it's talking to a real client. The test gets instant access to the result.
Minimal example
Here's the simplest test: create a request, record the response, invoke the handler, and assert the status.
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestHandler verifies the status code of a simple handler.
func TestHandler(t *testing.T) {
// Create a mock request targeting the root path.
req := httptest.NewRequest(http.MethodGet, "/", nil)
// Recorder captures the response written by the handler.
rr := httptest.NewRecorder()
// Wrap the handler function to satisfy the http.Handler interface.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// Execute the handler with the mock request and recorder.
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", rr.Code, http.StatusOK)
}
}
The recorder is a black box that holds the truth.
Walkthrough what happens
httptest.NewRequest builds a fully formed *http.Request struct. You pass the method, URL, and optional body. The function sets up headers, context, and remote address automatically so the request looks realistic. The remote address defaults to 1.2.3.4:5678. If your handler checks the client IP, this default value ensures the test doesn't panic on a nil pointer.
httptest.NewRecorder returns an object that implements http.ResponseWriter. It buffers the status code, headers, and body in memory. When you call handler.ServeHTTP(rr, req), the handler runs exactly as it would on a real server. It writes to the recorder instead of a network socket. After the call, rr.Code, rr.Header(), and rr.Body hold the results. You can inspect them directly in the test.
The http.HandlerFunc adapter appears in the example because handlers are often defined as functions with signature func(http.ResponseWriter, *http.Request). The http.Handler interface requires a ServeHTTP method. http.HandlerFunc is a type that implements that interface by calling the function. This adapter lets you pass a plain function anywhere a handler is expected.
Go code follows standard formatting. Run gofmt to ensure your test code matches the community style. Most editors do this automatically. Don't argue about indentation; let the tool decide.
The handler runs synchronously. The test waits for ServeHTTP to return before checking the recorder. You don't need channels or waits to verify the output.
Realistic example
Real handlers often read input and return structured data. This example tests a handler that parses a query parameter and returns JSON, including a failure case.
// GreetHandler returns JSON or an error based on query params.
func GreetHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
// http.Error writes status and body in one call.
http.Error(w, "missing name", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Hello, " + name})
}
Here's the test covering both paths. Subtests keep the output organized and allow running specific cases with -run.
func TestGreetHandler(t *testing.T) {
t.Run("success", func(t *testing.T) {
// Request includes query parameter for the happy path.
req := httptest.NewRequest(http.MethodGet, "/?name=Alice", nil)
rr := httptest.NewRecorder()
GreetHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
// rr.Header() returns the collected headers.
if rr.Header().Get("Content-Type") != "application/json" {
t.Errorf("wrong content type: %s", rr.Header().Get("Content-Type"))
}
})
t.Run("error", func(t *testing.T) {
// No query parameter triggers the error path.
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
GreetHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
})
}
Use t.Fatalf to stop the test early when a precondition fails. Use t.Errorf for non-fatal assertions. The community prefers subtests over repeating code. Test the happy path, then break it on purpose.
Testing middleware chains
Middleware sits between the router and the handler. Testing middleware requires wrapping a mock handler and checking if the middleware modifies the request or response correctly.
// LoggingMiddleware records the method and path.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture request details before calling the next handler.
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
func TestLoggingMiddleware(t *testing.T) {
// Create a dummy handler that does nothing.
dummy := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
// Wrap the dummy handler with the middleware.
handler := LoggingMiddleware(dummy)
req := httptest.NewRequest(http.MethodPost, "/api/data", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// The middleware should not change the status code.
if rr.Code != http.StatusOK {
t.Errorf("middleware changed status code to %d", rr.Code)
}
}
Wrap the middleware around a no-op handler. Verify the middleware doesn't corrupt the response. Middleware tests follow the same pattern: request, recorder, ServeHTTP, assert.
Pitfalls and compiler errors
A common mistake is checking the body before the handler finishes writing. ServeHTTP is synchronous, so the body is ready immediately after the call. You don't need to wait. Another pitfall involves headers. The recorder collects headers, but some handlers set headers lazily. If you check rr.Header() immediately after ServeHTTP, you get the final set.
If you try to read rr.Body as a string without calling .String(), the compiler rejects the code with cannot use rr.Body (variable of type *bytes.Buffer) as string value. The body is a buffer, not a string. Call rr.Body.String() to get the text. If you forget to import net/http/httptest, you get undefined: httptest. Standard Go error reporting.
Buffers hold bytes. Strings hold text. Convert explicitly.
When you need a real URL, httptest.NewServer starts a server on a random available port. You get a *httptest.Server with a .URL field. Always close the server when the test finishes. Use t.Cleanup instead of defer to ensure cleanup runs after subtests complete.
func TestWithServer(t *testing.T) {
// NewServer starts a listener on a random port.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Cleanup runs after the test and all subtests.
t.Cleanup(srv.Close)
client := http.DefaultClient
resp, err := client.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(resp.Body.Close)
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
t.Cleanup registers a function to run at the end of the test. It runs in reverse order of registration. This pattern prevents port leaks and resource exhaustion. If you forget to close the server, the test leaks a port. The compiler won't catch this. It's a runtime resource leak.
Handlers often need context.Context. httptest.NewRequest creates a request with a background context. If your handler expects a deadline or cancellation, you might need to override the context. Use req = req.WithContext(ctx) to inject a custom context. Context is plumbing. Run it through every long-lived call site.
Decision: when to use this vs alternatives
Use httptest.NewRecorder when you want to test a single handler in isolation without network overhead. Use httptest.NewServer when you need a real URL to test redirects, middleware chains, or client-side behavior. Use httptest.NewUnstartedServer when you must configure server options like TLS or timeouts before listening. Use a real server only for end-to-end tests that verify the full deployment stack, including databases and external services.
Unit test handlers with a recorder. Integration test with a server. Don't mix them.