Testing HTTP Handlers in Go with httptest: Recorders, Servers, and Table Tests
Most brittle handler tests fail in the same few ways: they inspect rec.Code before a handler has written anything, they string-compare a JSON body that a field reorder will break, or they forget to close a server response body and lose connection reuse. None of that is hard to avoid once you know where the sharp edges are.
httptest.NewRequest builds a server-side *http.Request, the kind your handler receives. Unlike http.NewRequest, it panics if it cannot construct the request, which keeps malformed test setup from adding error-handling boilerplate to every test.
There are two main ways to drive a handler. httptest.ResponseRecorder lets you call a handler directly and records what it writes, with no sockets or HTTP client. httptest.NewServer and, in Go 1.27+, NewTestServer run a server and let you exercise it through an HTTP client.
The recorder tests your handler logic. The server tests everything between the client and your handler: routing, middleware ordering, TLS, redirects, connection reuse. Most of your tests should be recorder tests. A few should be server tests, and you’ll know which ones because they’ll be testing something the recorder physically cannot see.
Testing a handler with ResponseRecorder
ResponseRecorder implements http.ResponseWriter. It buffers the status code, headers, and body so you can assert on them afterwards.
Here’s a handler with a dependency, which is how real handlers look:
package api
import (
"encoding/json"
"errors"
"net/http"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
}
var ErrNotFound = errors.New("user not found")
// UserStore is the seam we swap out in tests.
type UserStore interface {
Get(id string) (User, error)
}
type Handler struct {
Store UserStore
}
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // Go 1.22+ routing patterns
u, err := h.Store.Get(id)
if errors.Is(err, ErrNotFound) {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
if err != nil {
http.Error(w, `{"error":"internal"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(u); err != nil {
// Headers are already sent, so all we can do is log.
return
}
}
The Store interface keeps this test focused on HTTP behaviour. Instead of dialing a real database, the test can supply a small stub for each result it needs.
Now the test:
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
type stubStore struct {
user User
err error
}
func (s stubStore) Get(string) (User, error) { return s.user, s.err }
func TestGetUser_OK(t *testing.T) {
h := &Handler{Store: stubStore{user: User{ID: "42", Email: "a@b.com"}}}
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
req.SetPathValue("id", "42") // set directly; no mux involved
rec := httptest.NewRecorder()
h.GetUser(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", res.StatusCode, http.StatusOK)
}
if got := res.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
var got User
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("decode body: %v", err)
}
if got.Email != "a@b.com" {
t.Errorf("email = %q, want a@b.com", got.Email)
}
}
Two lines in there do more work than they look like they do.
The first is rec.Result(). Use it instead of poking at the recorder’s fields. HeaderMap is deprecated, and a zero-valued ResponseRecorder can leave Code at 0 if the handler writes nothing. NewRecorder initializes it to 200, and in the happy path above json.Encoder.Encode writes the body anyway. Result() gives you a normal *http.Response and converts a zero status to the implicit 200. Call it only after the handler has returned.
The second is json.NewDecoder(res.Body).Decode(&got). Decoding beats comparing raw body strings, which break the moment someone reorders a struct field or encoding/json adjusts whitespace. Decoding tests the contract; string comparison tests the byte layout, and the byte layout is not your API. If you want more on the decoding side, we covered JSON to struct in Go separately.
Table-driven tests for the failure paths
Failure cases are where handlers break, and they’re nearly identical in shape, which makes them a good fit for a table.
func TestGetUser_Table(t *testing.T) {
tests := []struct {
name string
store stubStore
wantStatus int
wantBody string
}{
{
name: "not found",
store: stubStore{err: ErrNotFound},
wantStatus: http.StatusNotFound,
wantBody: `{"error":"not found"}`,
},
{
name: "store failure",
store: stubStore{err: errors.New("connection reset")},
wantStatus: http.StatusInternalServerError,
wantBody: `{"error":"internal"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := &Handler{Store: tt.store}
req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
rec := httptest.NewRecorder()
h.GetUser(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != tt.wantStatus {
t.Fatalf("status = %d, want %d", res.StatusCode, tt.wantStatus)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if strings.TrimSpace(string(body)) != tt.wantBody {
t.Errorf("body = %q, want %q", strings.TrimSpace(string(body)), tt.wantBody)
}
})
}
}
Here the bodies are fixed error strings written by http.Error, so comparing them directly is fine. http.Error appends a newline, hence the strings.TrimSpace. Unless you set it first, it also sets Content-Type: text/plain; charset=utf-8, which is easy to miss when the error body happens to contain JSON. If that header matters to your API, set it explicitly or use a JSON error helper.
For POST handlers, pass the body as an io.Reader. httptest.NewRequest sets ContentLength automatically for *bytes.Reader, *strings.Reader, *bytes.Buffer, and http.NoBody:
body := strings.NewReader(`{"email":"a@b.com"}`)
req := httptest.NewRequest(http.MethodPost, "/users", body)
req.Header.Set("Content-Type", "application/json")
To test that a handler rejects a malformed body, pass something that isn’t valid JSON. To test how it handles a body read error, pass a reader that fails:
type errReader struct{}
func (errReader) Read([]byte) (int, error) { return 0, errors.New("boom") }
req := httptest.NewRequest(http.MethodPost, "/users", errReader{})
This case is worth testing because json.Decoder.Decode surfaces the reader’s error rather than a syntax error. If your code branches on *json.SyntaxError to decide between 400 and 500, a read failure follows a different path.
Testing middleware
Middleware wraps an http.Handler and returns another one, so you can test it against a stub handler and assert on what the recorder saw.
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
w.Header().Set("WWW-Authenticate", `Bearer realm="api"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func TestRequireAuth(t *testing.T) {
var called bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
})
t.Run("rejects missing token", func(t *testing.T) {
called = false
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
RequireAuth(next).ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", res.StatusCode)
}
if res.Header.Get("WWW-Authenticate") == "" {
t.Error("missing WWW-Authenticate header")
}
if called {
t.Error("next handler should not have been called")
}
})
}
The called flag is the assertion that matters. A 401 on its own doesn’t prove the middleware short-circuited: a buggy version could write the 401 and then call the inner handler anyway, allowing it to touch the database even though the recorded status remains 401. Recording whether next ran tests the actual behaviour.
Integration tests with httptest.NewServer
Recorder tests skip the entire net/http server stack. That’s usually the point. But some behaviour only exists in that stack: route matching, redirect following, gzip negotiation, TLS handshakes, connection reuse, and whatever your client code does with cookies.
httptest.NewServer starts a real server on a loopback interface and gives you a URL and a preconfigured Client():
func TestRouter_Integration(t *testing.T) {
mux := http.NewServeMux()
h := &Handler{
Store: stubStore{user: User{ID: "42", Email: "a@b.com"}},
}
mux.Handle("GET /users/{id}", RequireAuth(http.HandlerFunc(h.GetUser)))
srv := httptest.NewServer(mux)
defer srv.Close()
req, err := http.NewRequest(http.MethodGet, srv.URL+"/users/42", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer secret")
res, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", res.StatusCode)
}
}
Prefer srv.Client(). It is required for NewTLSServer because it trusts the generated certificate, and Go 1.27’s NewTestServer client knows how to route requests over the in-memory network. For a plain NewServer, http.DefaultClient can still call srv.URL; the server’s Close method also closes idle connections from the standard default transport.
NewTLSServer is the HTTPS variant, and Server.Certificate() returns the generated cert if you need to pin it somewhere. For HTTP/2, create the server unstarted, flip the flag, then start it:
srv := httptest.NewUnstartedServer(mux)
srv.EnableHTTP2 = true
srv.StartTLS()
defer srv.Close()
The same pattern covers srv.Config, which is a plain *http.Server, so MaxHeaderBytes, ReadTimeout, and the rest are all yours. Set them before the first call to Client, Start, or StartTLS.
NewTestServer in Go 1.27
Go 1.27 added httptest.NewTestServer(t, handler), and it changes two defaults that were quietly annoying.
It registers a t.Cleanup to shut the server down, so defer srv.Close() disappears. And it fails the test when your handler panics with anything other than http.ErrAbortHandler. Under NewServer, a panicking handler shows up as a client-side connection error plus a stack trace somewhere in the output, which is a roundabout way to discover a nil pointer dereference.
The network change is the bigger one. NewTestServer runs on an in-memory network instead of a loopback listener. No ports get allocated, so port exhaustion and transient bind failures stop happening, and it works with testing/synctest for deterministic concurrency tests. The client from Client() routes every request to the server, whatever hostname you give it:
srv := httptest.NewTestServer(t, mux)
client := srv.Client()
// All of these hit the test server.
client.Get("http://www.example.com/users/42")
client.Get("https://go.dev/users/42")
That last property can help when the code under test owns its destination URL. The trade-off is that Server.Listener is nil and Server.URL reads http://example.com on the in-memory network. If you need a real port, because you’re shelling out to curl or testing something that dials the address itself, call Start() or StartTLS() before Client() and you get a loopback listener instead.
Choosing between the two
Reach for ResponseRecorder when you’re testing what a handler does with its inputs. It’s fast, it’s synchronous, and a failure points at your code rather than at a transport error three layers down.
Reach for NewTestServer when the thing under test lives outside the handler: route patterns, middleware chains, client retry logic, or TLS config. Use NewServer when you need a loopback server directly or must support Go versions before 1.27. Faking an upstream API is another big use: stand up a test server that returns canned responses and you have a third-party service that fails on demand.
One rule covers both: close the response body. In recorder tests the body is an in-memory buffer, so forgetting is usually harmless. In server tests an unclosed body can prevent connection reuse; with a streaming response it can also leave an outstanding request that makes srv.Close() wait. Treat a hanging server test as a reason to check response-body cleanup, but not as proof that cleanup is the cause.
If you’re testing shutdown behaviour rather than request handling, that’s a separate problem, and we covered graceful shutdown for Go HTTP servers in its own post. And if your handlers parse user-supplied input, point a fuzzer at them. The table covers the inputs you thought of; fuzz testing helps find malformed and deeply nested inputs you did not anticipate.