1. Why unit-test handlers
When you write an HTTP server for the first time, you really want to “test like a user”: start the server, poke it with curl, celebrate, commit. The problem is that this approach quickly becomes slow and stressful: you have to start the server, ports can conflict, and tests turn into mini-integration tests even when you only need to check a single branch of an if.
A unit test for a handler is a way to verify the response logic head-on: which status you set, which headers you added, which JSON actually went into the body. And all of that—without sockets, without a real network, and without magic. We literally call ServeHTTP and look at the result.
What a handler unit test is: a direct call to ServeHTTP
It’s important to get the right mental model: a handler is not “something that only lives inside the server.” A handler is a regular object that knows how to serve a request. The http.Handler interface is just one method: ServeHTTP(w, r). So, to test a handler, we don’t need a server: we need an object that looks like a ResponseWriter, and a *http.Request.
The diagram ends up being almost comically simple:
flowchart LR
T[Test] -->|prepares| RQ[*http.Request]
T[Test] -->|prepares| RR[ResponseRecorder]
T[Test] -->|calls| H[handler.ServeHTTP]
H --> RR
T[Test] -->|checks| RR
That’s exactly the “trick” of net/http/httptest: it gives us tools to pretend to be both client and server at the same time, without leaving the test process.
2. Tools: httptest.NewRecorder and httptest.NewRequest
httptest.NewRecorder: “memory” instead of a real ResponseWriter
A real-server http.ResponseWriter writes data to the network. In a test, we don’t need the network: we need to capture what the handler tried to send to the client. That’s what httptest.ResponseRecorder is for. It accumulates the status code, headers, and response body in memory, so the test can later read them and compare them to the expected values.
The minimal idea is: create a recorder, call the handler, then read rr.Code, rr.Header() and rr.Body.String() (or decode JSON from rr.Body). It’s like a “receipt” after a purchase: you don’t argue with the cashier at the register—you calmly look at the receipt and see what you were charged for.
httptest.NewRequest: build a *http.Request without a real client
For a handler to process anything, it needs a request: method, URL, body, headers. In real life, the request arrives over the network. In a test, we create it ourselves using httptest.NewRequest(method, target, body). This is more convenient and safer than trying to assemble an http.Request manually like LEGO without instructions.
It’s especially important to remember the body: if it’s JSON, we usually pass strings.NewReader(...) or bytes.NewBufferString(...). And if the handler expects a Content-Type header, we set it ourselves—otherwise the test is like “I didn’t buckle up, but I want to test the airbag.”
3. What to verify in a handler response
In HTTP tests, beginners often verify “only the body.” That’s almost always a mistake: the body can be correct but the status can be wrong, and the client (or proxy) will interpret the response differently. Or the status can be correct but the Content-Type is wrong, and the frontend suddenly can’t tell that it’s JSON.
A practical minimum for a JSON response is almost always:
| What we verify | Where to look | Why it matters |
|---|---|---|
| Status code | |
The primary signal of the operation result |
|
|
The client understands the response format |
| JSON structure | |
We verify meaning, not formatting |
And yes: comparing JSON as a “string” is brittle. Spaces, field order, line breaks—any of that can change while the API contract is still intact. So we decode JSON into a structure and compare fields.
4. API scaffolding and an error envelope
To keep tests short, in a real application it’s useful to centralize error writing: the handler does the work and returns an error, and shared glue code decides which status and which error envelope to send. This pattern has long been known in the Go community: a handler function returns an error, and ServeHTTP handles the error once, the same way for all routes.
Below are compact snippets of what we need for tests.
Error envelope types (what the client will see in JSON):
package httpapi
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type errorEnvelope struct {
Error apiError `json:"error"`
}
A skeleton of “handler returns an error” (idea: the wrapper implements ServeHTTP itself):
package httpapi
import "net/http"
type appHandler func(http.ResponseWriter, *http.Request) error
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
writeError(w, err)
}
}
We also need domain (or application-level) error kinds so we can map them to validation/not_found/internal. Here we rely on the fact that in Go, errors are values, and they’re often compared/recognized by type or via errors.Is.
package httpapi
import "errors"
var ErrNotFound = errors.New("not found")
type ValidationError struct {
Fields map[string]string
}
func (e ValidationError) Error() string { return "validation error" }
Next, writeError should produce the correct status and envelope. We won’t make it huge (we’re testing the idea, not writing a “big fat monster”):
package httpapi
import (
"encoding/json"
"errors"
"net/http"
)
func writeError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if errors.Is(err, ErrNotFound) {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(errorEnvelope{Error: apiError{
Code: "not_found", Message: "resource not found",
}})
return
}
if v, ok := err.(ValidationError); ok {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(errorEnvelope{Error: apiError{
Code: "validation", Message: "invalid request", Fields: v.Fields,
}})
return
}
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(errorEnvelope{Error: apiError{
Code: "internal", Message: "internal error",
}})
}
Notice an important safety rule: for 500 we do not return err.Error() to the outside. The message is fixed. We leave the internal cause for logs, but that’s not today’s topic—today we’ll use a test to lock in that there is no leak.
5. Unit test examples
First unit test: a simple handler that returns 204
It’s best to start with the simplest possible case: a handler without JSON, without errors, without a body. Why? Because first you practice the mechanics of NewRecorder + NewRequest + ServeHTTP, and only then you add JSON and an error envelope. It’s like learning to drive: first an empty parking lot, then the city.
Here’s a micro example of a handler and a test:
package httpapi
import "net/http"
func health(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusNoContent)
return nil
}
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealth_NoContent(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
appHandler(health).ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status=%d, want %d", rr.Code, http.StatusNoContent)
}
}
Here we test the status. That’s it. No need to complicate things. If this test doesn’t pass, you have a problem with the basic mechanics.
Unit-testing JSON success: verify Content-Type and the structure
Once status codes are no longer “scary,” let’s add a JSON response. Let it be a ping handler that returns { "ok": true }. We intentionally avoid any “task” here so we don’t get distracted by business logic: the goal is to learn to verify JSON structurally.
Handler:
package httpapi
import (
"encoding/json"
"net/http"
)
type okResp struct {
OK bool `json:"ok"`
}
func ping(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(okResp{OK: true})
}
Test:
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestPing_OK(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
appHandler(ping).ServeHTTP(rr, req)
if rr.Header().Get("Content-Type") != "application/json; charset=utf-8" {
t.Fatalf("Content-Type=%q", rr.Header().Get("Content-Type"))
}
var got okResp
_ = json.NewDecoder(rr.Body).Decode(&got)
if !got.OK {
t.Fatalf("ok=false, want true")
}
}
Yes, here we simplistically ignore the decode error (_ = ...) only to keep the code compact. A bit later we’ll make a proper helper that fails nicely and honestly.
Verifying the error envelope: validation and fields
Now the fun part: testing an error as a contract. For this we need a handler that can return ValidationError. Let it be “create a task”: we accept JSON { "title": "..." }, and if title is empty, we return ValidationError{Fields: ...}.
Handler:
package httpapi
import (
"encoding/json"
"net/http"
)
type createTaskReq struct {
Title string `json:"title"`
}
func createTask(w http.ResponseWriter, r *http.Request) error {
var req createTaskReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return ValidationError{Fields: map[string]string{"body": "invalid json"}}
}
if req.Title == "" {
return ValidationError{Fields: map[string]string{"title": "must not be empty"}}
}
w.WriteHeader(http.StatusCreated)
return nil
}
The test should check three things: status 400, Content-Type, and also the envelope structure plus the presence of fields.title.
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateTask_EmptyTitle_ValidationEnvelope(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks",
strings.NewReader(`{"title":""}`))
appHandler(createTask).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want %d", rr.Code, http.StatusBadRequest)
}
}
That’s not enough yet: we still haven’t verified the JSON. Let’s add decoding (via a helper a bit later), but for now I’ll show it “straight up” so it’s clear what we want to extract:
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateTask_EnvelopeFields(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks",
strings.NewReader(`{"title":""}`))
appHandler(createTask).ServeHTTP(rr, req)
var env errorEnvelope
if err := json.NewDecoder(rr.Body).Decode(&env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Error.Code != "validation" {
t.Fatalf("code=%q, want %q", env.Error.Code, "validation")
}
if env.Error.Fields["title"] != "must not be empty" {
t.Fatalf("fields.title=%q", env.Error.Fields["title"])
}
}
This is already a unit test of the contract: the client doesn’t care where exactly you validated title—the client cares that it received a clear, predictable error structure.
Verifying 500: “the internal cause did not leak”
One of the nastiest API mistakes is returning text like "pq: password authentication failed for user..." or "dial tcp ... connection refused" to the user. The user won’t fix your Postgres anyway, but an attacker will be happy. That’s why a 500 response must have a stable message, and the internals should only be in logs.
Let’s make a handler that “simulates” an internal error:
package httpapi
import (
"errors"
"net/http"
)
func alwaysFails(w http.ResponseWriter, r *http.Request) error {
return errors.New("db exploded: boom")
}
And the test: status 500, code=internal, message=internal error, and most importantly—no "db exploded" in the message.
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestInternalError_MessageIsStable(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
appHandler(alwaysFails).ServeHTTP(rr, req)
var env errorEnvelope
_ = json.NewDecoder(rr.Body).Decode(&env)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status=%d", rr.Code)
}
if env.Error.Message != "internal error" {
t.Fatalf("message=%q", env.Error.Message)
}
if env.Error.Message == "db exploded: boom" {
t.Fatalf("leaked internal error to client")
}
}
Yes, the last check looks a bit “toy-like,” but as a teaching test it locks in the rule perfectly: stable text on the outside.
Test helpers: make checks readable
Once you have more than three tests, repetition appears: “decode JSON,” “check status,” “check Content-Type.” If you copy-paste that by hand, tests bloat and start to look like a quarterly accounting report. So we write small helper functions and mark them with t.Helper()—then when a test fails, Go points to the line where the helper was called, not inside the helper.
Helper for JSON decoding:
package httpapi
import (
"encoding/json"
"io"
"testing"
)
func mustJSON(t *testing.T, r io.Reader, dst any) {
t.Helper()
if err := json.NewDecoder(r).Decode(dst); err != nil {
t.Fatalf("decode json: %v", err)
}
}
Helper for the header:
package httpapi
import "testing"
func mustCTJSON(t *testing.T, ct string) {
t.Helper()
if ct != "application/json; charset=utf-8" {
t.Fatalf("Content-Type=%q", ct)
}
}
And now the validation test becomes shorter and nicer:
func TestCreateTask_Validation(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks",
strings.NewReader(`{"title":""}`))
appHandler(createTask).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status=%d", rr.Code)
}
mustCTJSON(t, rr.Header().Get("Content-Type"))
var env errorEnvelope
mustJSON(t, rr.Body, &env)
if env.Error.Fields["title"] == "" {
t.Fatalf("expected fields.title")
}
}
Now the test reads like a story: “made a request → got a response → checked status → checked header → checked JSON.” Almost like a normal person, not like a compiler.
Table-driven unit tests: multiple cases with one function
When a handler has many branches, it’s convenient to keep them in one test as a table: input → expected status → expected code. It saves time and helps you not to forget scenarios. At the same time, it’s important not to turn a table-driven test into a 200-line monster: one handler—one table, and only as many cases as you actually need for branch coverage.
Example: test writeError via a handler that returns different errors. Yes, we test through appHandler, because it is exactly what forms the response for the client (and that is our contract).
func TestErrors_MappedToEnvelope(t *testing.T) {
cases := []struct {
name string
err error
want int
code string
}{
{"notfound", ErrNotFound, http.StatusNotFound, "not_found"},
{"validation", ValidationError{Fields: map[string]string{"x": "bad"}}, http.StatusBadRequest, "validation"},
{"internal", errors.New("x"), http.StatusInternalServerError, "internal"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := appHandler(func(w http.ResponseWriter, r *http.Request) error { return tc.err })
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/x", nil))
var env errorEnvelope
mustJSON(t, rr.Body, &env)
if rr.Code != tc.want || env.Error.Code != tc.code {
t.Fatalf("status=%d code=%q", rr.Code, env.Error.Code)
}
})
}
}
This test is very “applied”: it doesn’t argue about how the internal error typing works; it fixes the external contract: which error turns into what at the HTTP boundary.
6. Common mistakes when unit-testing handlers
Mistake #1: verifying only the body and ignoring the status code.
This is the trap of “I see JSON, so everything is fine.” In practice, the status is often more important to the client: 201 vs 200, 204 with no body, 400 vs 422—these have different meanings. Get used to starting every test with rr.Code, and only then digging into JSON.
Mistake #2: comparing JSON as a string.
Today the encoder uses different spacing, tomorrow you change field order (or add omitempty), and the test fails even though the contract isn’t broken. Decode JSON into a struct (struct) or into map[string]any and verify fields. It’s both more reliable and more readable.
Mistake #3: forgetting about Content-Type.
If you’re building a JSON API, then Content-Type is part of the deal. Without it, clients and proxies may behave differently, and some HTTP clients won’t even attempt to decode JSON automatically. In tests, verifying the header takes one line and saves hours of debugging.
Mistake #4: trying to unit-test routing and {id} at the same time.
A handler unit test is about the response logic of a specific function/object. As soon as you start checking routing, you quietly turn the test into an “almost integration” test. That’s not always bad, but methodologically it’s better to separate concerns: unit is for the handler, and separately for the “wiring.” Otherwise tests get muddy: something fails and it’s unclear what.
Mistake #5: not using t.Helper(), and failures read like a cipher.
Without t.Helper(), a test failure often points to a line inside the helper function, not to the place where you called it. In a small project it’s tolerable, but in a real one you waste time on “where did I call this from?” Mark helpers, and test reports will be more human.
GO TO FULL VERSION