Skip to content
gohacks.dev
Testing

Test Handlers in Go With the httptest Recorder Package

Test net/http handlers and middleware in Go without a server: httptest.NewRequest, NewRecorder, a table of status and body cases, headers and path values.

8 min read
Summarize in AI
Flat illustration of a test report with one failing test.

The net/http/httptest package lets a test call a handler like an ordinary function. There is no listener, no port and no network: you build a request, give the handler a recorder in place of the real response writer, and read what the handler wrote. It is the fastest way to test the web layer of a Go program, and it covers more than people expect, including routing, middleware, path values and headers.

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

The example below is a small notes API on Go 1.25.6, tested end to end at the handler level. The client side of the same story is in testing clients with a fake RoundTripper.

The handlers under test

notes.goGo
package main

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

type Note struct {
	ID   int    `json:"id"`
	Text string `json:"text"`
}

type Server struct {
	notes map[int]Note
}

func NewServer() *Server {
	return &Server{notes: map[int]Note{1: {1, "buy milk"}}}
}

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

func requireToken(token string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") != "Bearer "+token {
			w.Header().Set("WWW-Authenticate", "Bearer")
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func (s *Server) get(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		http.Error(w, "bad id", http.StatusBadRequest)
		return
	}
	n, ok := s.notes[id]
	if !ok {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(n)
}

func (s *Server) create(w http.ResponseWriter, r *http.Request) {
	var n Note
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&n); err != nil || strings.TrimSpace(n.Text) == "" {
		http.Error(w, "text is required", http.StatusUnprocessableEntity)
		return
	}
	n.ID = len(s.notes) + 1
	s.notes[n.ID] = n
	w.Header().Set("Location", "/notes/"+strconv.Itoa(n.ID))
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(n)
}

func main() {}

Routes builds a ServeMux with the method-and-path patterns that arrived in Go 1.22, GET /notes/{id} and POST /notes, and wraps the mux in a token check. r.PathValue("id") reads the wildcard. create limits the body with http.MaxBytesReader, validates the text, assigns an ID and answers 201 with a Location header.

A request, a recorder, a call

notes_test.goGo
package main

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func do(h http.Handler, method, target, body, token string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, target, strings.NewReader(body))
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestNotes(t *testing.T) {
	h := NewServer().Routes()

	tests := []struct {
		name, method, target, body, token string
		wantCode                          int
		wantBody                          string
	}{
		{"no token", "GET", "/notes/1", "", "", 401, "unauthorized\n"},
		{"get", "GET", "/notes/1", "", "secret", 200, `{"id":1,"text":"buy milk"}` + "\n"},
		{"missing", "GET", "/notes/9", "", "secret", 404, "404 page not found\n"},
		{"bad id", "GET", "/notes/abc", "", "secret", 400, "bad id\n"},
		{"wrong method", "DELETE", "/notes/1", "", "secret", 405, "Method Not Allowed\n"},
		{"create", "POST", "/notes", `{"text":"call Ana"}`, "secret", 201, `{"id":2,"text":"call Ana"}` + "\n"},
		{"create empty", "POST", "/notes", `{"text":" "}`, "secret", 422, "text is required\n"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			rec := do(h, tt.method, tt.target, tt.body, tt.token)
			if rec.Code != tt.wantCode {
				t.Errorf("status = %d, want %d", rec.Code, tt.wantCode)
			}
			if rec.Body.String() != tt.wantBody {
				t.Errorf("body = %q, want %q", rec.Body.String(), tt.wantBody)
			}
		})
	}
}

func TestHeaders(t *testing.T) {
	h := NewServer().Routes()

	rec := do(h, "POST", "/notes", `{"text":"x"}`, "secret")
	res := rec.Result()
	defer res.Body.Close()
	t.Logf("Location=%q Content-Type=%q", res.Header.Get("Location"), res.Header.Get("Content-Type"))

	rec = do(h, "GET", "/notes/1", "", "")
	t.Logf("WWW-Authenticate=%q", rec.Header().Get("WWW-Authenticate"))
}

func TestRecorderDefaults(t *testing.T) {
	rec := httptest.NewRecorder()
	rec.WriteString("hi")
	t.Logf("a handler that writes without WriteHeader reads as Code=%d", rec.Code)

	rec = httptest.NewRecorder()
	t.Logf("a handler that writes nothing reads as Code=%d", rec.Code)
}
Shell
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|=== PAUSE|=== CONT|PASS|ok)' | sed -E 's/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: #    #'
Text
--- PASS: TestNotes
    --- PASS: TestNotes/no_token
    --- PASS: TestNotes/get
    --- PASS: TestNotes/missing
    --- PASS: TestNotes/bad_id
    --- PASS: TestNotes/wrong_method
    --- PASS: TestNotes/create
    --- PASS: TestNotes/create_empty
    Location="/notes/2" Content-Type="application/json"
    WWW-Authenticate="Bearer"
--- PASS: TestHeaders
    a handler that writes without WriteHeader reads as Code=200
    a handler that writes nothing reads as Code=200
--- PASS: TestRecorderDefaults

The do helper is the whole technique. httptest.NewRequest(method, target, body) returns a *http.Request ready for a server handler: the target is parsed, RequestURI and RemoteAddr are set, and unlike http.NewRequest it panics instead of returning an error, because a malformed target in a test is a typo. httptest.NewRecorder() returns an *httptest.ResponseRecorder, which implements http.ResponseWriter and keeps what was written. After ServeHTTP, the recorder's Code, Body and Header() hold the response.

Test the router, not just the handler

h := NewServer().Routes() is the outermost handler, so every request goes through the token check and the mux. That is deliberate. If the tests called s.get directly, r.PathValue("id") would be empty, because the value is set by the mux during matching, and the middleware would never run. Testing at the top gives you what a client would see.

The table shows what that buys:

  • no token gets 401 from the middleware before any handler runs, and the body is the text http.Error writes, including its trailing newline.
  • missing gets 404 page not found, written by http.NotFound when the map lookup fails.
  • bad id shows the handler's own 400. The route matched with abc as the value, and the handler rejects it.
  • wrong method is the instructive one. The route GET /notes/{id} exists, so a DELETE to the same path gets 405 Method Not Allowed from the mux itself, and your handler never sees it. A test of get in isolation could not find that behaviour.
  • create and create empty check the 201 path and the validation path.

Compare the body as an exact string when the format is a contract. JSON written by json.Encoder ends with a newline, so the expected values end with a newline, as in the table. For larger responses, decode into a struct and compare fields, or compare with a golden file.

Headers and the Result method

rec.Header() returns the headers the handler set. For a full response as a client would see it, call rec.Result(), which returns an *http.Response with status, headers and a body reader; close the body as you would for any response. The headers test reads Location and Content-Type from it and finds /notes/2 and application/json, which is the contract for a created resource. It also shows WWW-Authenticate: Bearer on the rejected request, which is required for a 401 by the HTTP specification.

One distinction matters. rec.Header() reflects the header map as it stands, including values set after the response was written, which a real client would never see. rec.Result().Header is a snapshot taken at the moment of WriteHeader, so it shows what was actually sent. A handler that sets a header after writing the body has a bug that only Result() exposes.

Defaults that surprise

The last test shows two behaviours of a fresh recorder. If a handler writes a body without calling WriteHeader, Code reads 200, as in a real server. If it writes nothing at all, Code is still 200, because the recorder starts there. So a handler that forgets to write anything passes a test that only checks for 200. Assert on the body or on an explicit status you expect, and add a case for the empty response if it is a real possibility.

Middleware on its own

A middleware is a function from handler to handler, so it can be tested with a stub as the inner handler. Pass a handler that records whether it was called, send a request without the token, and assert two things: the status is 401 and the stub was never reached. Then send the right token and assert the stub ran exactly once. That pair of tests proves the guard cannot be skipped, which is the property you care about, and it takes a dozen lines. The same shape works for logging, request IDs, panics recovered into a 500, CORS headers and gzip, each with a stub that reports what it saw in the request, for example a value placed in the context.

What the recorder does not cover

The recorder bypasses the network stack, so it does not test connection handling, timeouts on the server, TLS, HTTP/2 or the effect of Content-Length and chunking. It also does not run the server's request-body limits unless your handler applies them, as create does. For those, use httptest.NewServer, which starts a real server on a loopback port and returns its URL; keep a few tests of that kind and let the recorder carry the many. Handlers with side effects need their dependencies injected, for instance the map of notes here, or a database behind an interface as in mocking with small interfaces.

Two practical notes. Create a fresh server for each test, so state such as the next ID does not leak between tests; the table above shares one for brevity, which is why create always yields ID 2. And run the suite with -race when handlers touch shared state, since the recorder lets you call handlers from many goroutines.

The package reference is pkg.go.dev/net/http/httptest, the routing patterns are described in the http.ServeMux documentation, and the Go blog explains the 1.22 pattern syntax in Routing Enhancements for Go 1.22.

Also inWeb
Tagged""
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.