JavaRush /Courses /Go SELF /Unit tests for HTTP handlers: httptest.NewRecorder and ...

Unit tests for HTTP handlers: httptest.NewRecorder and httptest.NewRequest

Go SELF
Level 64, Lesson 0
Available

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
rr.Code
The primary signal of the operation result
Content-Type
rr.Header().Get("Content-Type")
The client understands the response format
JSON structure
json.NewDecoder(rr.Body).Decode(...)
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.

1
Task
Go SELF, level 64, lesson 0
Locked
Health signal
Health signal
1
Task
Go SELF, level 64, lesson 0
Locked
Text greeting
Text greeting
1
Task
Go SELF, level 64, lesson 0
Locked
JSON ping
JSON ping
1
Task
Go SELF, level 64, lesson 0
Locked
Error converter
Error converter
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION