Skip to content
gohacks.dev
Web

Test HTTP Handlers in Go Without Starting a Real Server

Call handlers directly with httptest.NewRecorder and NewRequest, set path values by hand, fake the store behind them, and assert on status, headers and JSON.

9 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

The fastest way to lose faith in handler tests is to write them against a running server on a fixed port. They are slow to start, fail when another test holds the port, and report "connection refused" when the real problem is a typo. A handler is func(http.ResponseWriter, *http.Request). You can call it. Go ships the pieces to build a request and to capture what the handler writes, and a test using them runs in a few microseconds without ever opening a socket.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds a small bookmarks handler, tests it with a recorder across the cases that matter, and shows the one trap that surprises people when a handler reads a path wildcard. It sits in the web cluster that ends in a production HTTP server, uses the routing from the Go 1.22 mux patterns and JSON responses, and has a sibling for the opposite direction in mock HTTP servers with httptest. Everything ran on Go 1.25.6 with the standard library only.

The handler and what it depends on

A handler that talks to a database directly cannot be tested without one. The usual fix is to put the data access behind an interface that the handler's package defines, with only the methods the handler needs. The real implementation can be a struct over database/sql; the test uses a map.

handler.goGo
package bookmarks

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
)

type Bookmark struct {
	ID    int    `json:"id"`
	URL   string `json:"url"`
	Title string `json:"title"`
}

var ErrNotFound = errors.New("not found")

// Store is what the handlers need, nothing more.
type Store interface {
	Get(id int) (Bookmark, error)
	Add(b Bookmark) (Bookmark, error)
}

type Server struct{ Store Store }

func (s *Server) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /bookmarks/{id}", s.get)
	mux.HandleFunc("POST /bookmarks", s.create)
	return mux
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func (s *Server) get(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be a number"})
		return
	}
	b, err := s.Store.Get(id)
	if errors.Is(err, ErrNotFound) {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such bookmark"})
		return
	}
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	writeJSON(w, http.StatusOK, b)
}

func (s *Server) create(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
	var in Bookmark
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(&in); err != nil || in.URL == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON with a url"})
		return
	}
	saved, err := s.Store.Add(in)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	w.Header().Set("Location", "/bookmarks/"+strconv.Itoa(saved.ID))
	writeJSON(w, http.StatusCreated, saved)
}

The handler has three responsibilities that need tests: decoding input, calling the store, and translating the outcome into a status code, headers and a body. Notice that the error cases are decisions made in the handler, so each one deserves a test: a bad ID, a missing record, a store failure, bad JSON, an unknown field.

The tests

handler_test.goGo
package bookmarks

import (
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// fakeStore is an in-memory Store; err forces a failure.
type fakeStore struct {
	items map[int]Bookmark
	next  int
	err   error
}

func (f *fakeStore) Get(id int) (Bookmark, error) {
	if f.err != nil {
		return Bookmark{}, f.err
	}
	b, ok := f.items[id]
	if !ok {
		return Bookmark{}, ErrNotFound
	}
	return b, nil
}

func (f *fakeStore) Add(b Bookmark) (Bookmark, error) {
	if f.err != nil {
		return Bookmark{}, f.err
	}
	f.next++
	b.ID = f.next
	f.items[b.ID] = b
	return b, nil
}

func newServer(err error) *Server {
	return &Server{Store: &fakeStore{items: map[int]Bookmark{1: {ID: 1, URL: "https://go.dev", Title: "Go"}}, next: 1, err: err}}
}

func TestGetThroughTheMux(t *testing.T) {
	tests := []struct {
		name   string
		path   string
		err    error
		status int
	}{
		{"found", "/bookmarks/1", nil, http.StatusOK},
		{"missing", "/bookmarks/9", nil, http.StatusNotFound},
		{"not a number", "/bookmarks/abc", nil, http.StatusBadRequest},
		{"store failure", "/bookmarks/1", errors.New("db down"), http.StatusInternalServerError},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			rec := httptest.NewRecorder()
			newServer(tc.err).Routes().ServeHTTP(rec, httptest.NewRequest("GET", tc.path, nil))
			if rec.Code != tc.status {
				t.Fatalf("status = %d, want %d (body %s)", rec.Code, tc.status, rec.Body)
			}
			if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
				t.Errorf("Content-Type = %q", ct)
			}
		})
	}
}

func TestGetBodyIsDecodedNotCompared(t *testing.T) {
	rec := httptest.NewRecorder()
	newServer(nil).Routes().ServeHTTP(rec, httptest.NewRequest("GET", "/bookmarks/1", nil))
	var got Bookmark
	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
		t.Fatal(err)
	}
	if got.Title != "Go" || got.URL != "https://go.dev" {
		t.Errorf("got %+v", got)
	}
}

func TestDirectCallNeedsSetPathValue(t *testing.T) {
	s := newServer(nil)

	// Calling the handler method directly skips the mux, so the wildcard has no value yet.
	rec := httptest.NewRecorder()
	s.get(rec, httptest.NewRequest("GET", "/bookmarks/1", nil))
	if rec.Code != http.StatusBadRequest {
		t.Errorf("without SetPathValue: status = %d, want 400", rec.Code)
	}

	req := httptest.NewRequest("GET", "/bookmarks/1", nil)
	req.SetPathValue("id", "1")
	rec = httptest.NewRecorder()
	s.get(rec, req)
	if rec.Code != http.StatusOK {
		t.Errorf("with SetPathValue: status = %d, want 200", rec.Code)
	}
}

func TestCreate(t *testing.T) {
	tests := []struct {
		name   string
		body   string
		status int
	}{
		{"valid", `{"url":"https://pkg.go.dev","title":"Packages"}`, http.StatusCreated},
		{"missing url", `{"title":"no url"}`, http.StatusBadRequest},
		{"unknown field", `{"url":"https://x.test","admin":true}`, http.StatusBadRequest},
		{"not json", `url=https://x.test`, http.StatusBadRequest},
		{"empty body", ``, http.StatusBadRequest},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest("POST", "/bookmarks", strings.NewReader(tc.body))
			rec := httptest.NewRecorder()
			newServer(nil).Routes().ServeHTTP(rec, req)
			if rec.Code != tc.status {
				t.Fatalf("status = %d, want %d (body %s)", rec.Code, tc.status, rec.Body)
			}
			if tc.status == http.StatusCreated && rec.Header().Get("Location") != "/bookmarks/2" {
				t.Errorf("Location = %q", rec.Header().Get("Location"))
			}
		})
	}
}

func TestWrongMethodIs405(t *testing.T) {
	rec := httptest.NewRecorder()
	newServer(nil).Routes().ServeHTTP(rec, httptest.NewRequest("DELETE", "/bookmarks/1", nil))
	if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") == "" {
		t.Errorf("status = %d, Allow = %q", rec.Code, rec.Header().Get("Allow"))
	}
}
Text
=== RUN   TestGetThroughTheMux
=== RUN   TestGetThroughTheMux/found
=== RUN   TestGetThroughTheMux/missing
=== RUN   TestGetThroughTheMux/not_a_number
=== RUN   TestGetThroughTheMux/store_failure
--- PASS: TestGetThroughTheMux (0.00s)
    --- PASS: TestGetThroughTheMux/found (0.00s)
    --- PASS: TestGetThroughTheMux/missing (0.00s)
    --- PASS: TestGetThroughTheMux/not_a_number (0.00s)
    --- PASS: TestGetThroughTheMux/store_failure (0.00s)
=== RUN   TestGetBodyIsDecodedNotCompared
--- PASS: TestGetBodyIsDecodedNotCompared (0.00s)
=== RUN   TestDirectCallNeedsSetPathValue
--- PASS: TestDirectCallNeedsSetPathValue (0.00s)
=== RUN   TestCreate
=== RUN   TestCreate/valid
=== RUN   TestCreate/missing_url
=== RUN   TestCreate/unknown_field
=== RUN   TestCreate/not_json
=== RUN   TestCreate/empty_body
--- PASS: TestCreate (0.00s)
    --- PASS: TestCreate/valid (0.00s)
    --- PASS: TestCreate/missing_url (0.00s)
    --- PASS: TestCreate/unknown_field (0.00s)
    --- PASS: TestCreate/not_json (0.00s)
    --- PASS: TestCreate/empty_body (0.00s)
=== RUN   TestWrongMethodIs405
--- PASS: TestWrongMethodIs405 (0.00s)
PASS
ok  	example.test/lab	1.378s

Four decisions in that file are worth explaining. First, most tests go through Routes(), the mux, not the handler method. That tests the pattern as well: that GET /bookmarks/{id} is registered, that the wildcard is filled, that a DELETE gets a 405 with an Allow header. A test that skips the mux can pass while the route is misregistered.

Second, the TestDirectCallNeedsSetPathValue test documents the trap. When you call s.get directly, nothing has parsed the URL against a pattern, so r.PathValue("id") returns an empty string, Atoi("") fails, and the handler answers 400 for a perfectly valid URL. The fix is req.SetPathValue("id", "1"), added in Go 1.22 for exactly this purpose. People discover the problem as "my test says 400 and I can't see why", so it is worth one test that shows it plainly.

Third, the body test decodes the JSON and asserts on fields. It does not compare against a string such as a literal copy of the JSON with its trailing newline. Exact-bytes assertions break when someone adds a field or changes the encoder's escaping, and they say nothing about whether the contract held. If clients depend on field names, assert those names; if a golden file makes sense for a large response, see golden files.

Fourth, the store fake returns an error on demand, which lets the 500 path run. The fake is twenty lines. A mocking framework is not needed here, and a hand-written fake is easier to read when it fails. The same dependency style is described in dependency injection for handlers.

What the recorder does and does not tell you

httptest.ResponseRecorder implements http.ResponseWriter and keeps the status in rec.Code, the headers in rec.Header() and the body in rec.Body. Two behaviors differ from a real server. If the handler never calls WriteHeader, Code is 200, as in production. But the recorder does not enforce things a real server does: it will not complain about a header set after the body, and it does not apply Content-Length or chunking. Use rec.Result() when you want a real *http.Response, for example to pass to a helper that expects one, and remember to close its body.

It also cannot test anything below the handler: timeouts, connection handling, TLS, HTTP/2, Flusher behavior on a real connection. For those, run a server, as in the httptest article. A good balance is hundreds of recorder tests that cover the logic and a handful of full-server tests that cover the wire. The package is documented at pkg.go.dev/net/http/httptest, and the Go tutorial on adding a test covers the table-driven style the tests above use.

Context, cookies and streaming

Handlers often read things that are not in the URL: a user placed in the context by middleware, a session cookie, a header. In a handler test, set them on the request before the call. req = req.WithContext(context.WithValue(req.Context(), userKey, "alice")) simulates the auth middleware without running it, which keeps the handler test focused on the handler. req.AddCookie(&http.Cookie{Name: "session", Value: "abc"}) and req.Header.Set("Accept-Language", "de") do the same for the other inputs. If the handler's behavior depends on the user, add a row for the anonymous case as well, which should fail closed.

A handler that streams (server sent events, a large export) calls Flush. The recorder implements http.Flusher and remembers it: rec.Flushed is true after a flush, so a test can assert that a streaming handler flushes at all, which is the property that goes missing when someone wraps the writer in middleware. For the content, read rec.Body after the handler returns; it holds everything written, including chunks written before each flush.

Habits that pay off

Run the whole file with -race; the cost is small and it catches shared state in handlers, such as a package-level map written without a lock. Make the table fields describe the scenario, not the implementation: "store failure" reads better than "case 4". Keep the request builder close to the test, so a reader sees the exact method, path, headers and body without chasing helpers. And test the failure paths as carefully as the success path. In my experience the handler bugs that reach production are almost always in the branches that were never exercised: the empty body, the unknown field, the error that was swallowed.

Also inTesting
Taggednet http
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.