Chapter 17: httptest.ResponseRecorder¶
Description¶
Use httptest.NewRecorder to capture HTTP handler output without starting a server. The recorder implements http.ResponseWriter and stores the status code, headers, and body. Combined with httptest.NewRequest, you can test handlers in isolation — no network, no server lifecycle, just handler logic.
Code¶
type TaskHandler struct{}
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
respondError(w, http.StatusMethodNotAllowed, "only POST allowed")
return
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
respondError(w, http.StatusUnsupportedMediaType, "JSON required")
return
}
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("invalid JSON: %v", err))
return
}
task.Title = strings.TrimSpace(task.Title)
if task.Title == "" {
respondError(w, http.StatusBadRequest, "title is required")
return
}
// create task...
}
Test¶
type checkHandlerFn func(*testing.T, *httptest.ResponseRecorder)
var checkHandler = func(fns ...checkHandlerFn) []checkHandlerFn { return fns }
func TestTaskHandler_CreateTask(t *testing.T) {
checkStatus := func(want int) checkHandlerFn {
return func(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
assert.Equal(t, want, w.Code)
}
}
checkError := func(want string) checkHandlerFn {
return func(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
var body map[string]string
json.NewDecoder(w.Body).Decode(&body)
assert.Contains(t, body["error"], want)
}
}
checkTaskTitle := func(want string) checkHandlerFn {
return func(t *testing.T, w *httptest.ResponseRecorder) {
t.Helper()
var task Task
json.NewDecoder(w.Body).Decode(&task)
assert.Equal(t, want, task.Title)
}
}
tests := []struct {
name string
method string
ctype string
body string
checks []checkHandlerFn
}{
{
name: "successful creation",
method: http.MethodPost,
ctype: "application/json",
body: `{"title":"Buy milk"}`,
checks: checkHandler(
checkStatus(http.StatusCreated),
checkTaskTitle("Buy milk"),
),
},
{
name: "wrong method",
method: http.MethodGet,
ctype: "application/json",
body: `{"title":"test"}`,
checks: checkHandler(
checkStatus(http.StatusMethodNotAllowed),
checkError("only POST"),
),
},
{
name: "wrong content type",
method: http.MethodPost,
ctype: "text/plain",
body: `{"title":"test"}`,
checks: checkHandler(
checkStatus(http.StatusUnsupportedMediaType),
checkError("JSON required"),
),
},
{
name: "invalid JSON",
method: http.MethodPost,
ctype: "application/json",
body: `{invalid}`,
checks: checkHandler(
checkStatus(http.StatusBadRequest),
checkError("invalid JSON"),
),
},
{
name: "empty title",
method: http.MethodPost,
ctype: "application/json",
body: `{"title":" "}`,
checks: checkHandler(
checkStatus(http.StatusBadRequest),
checkError("title is required"),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(tt.method, "/tasks", strings.NewReader(tt.body))
r.Header.Set("Content-Type", tt.ctype)
h := NewTaskHandler()
h.CreateTask(w, r)
for _, fn := range tt.checks {
fn(t, w)
}
})
}
}
func TestTaskHandler_GetTask(t *testing.T) {
t.Run("existing task", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/tasks/42", nil)
NewTaskHandler().GetTask(w, r)
assert.Equal(t, http.StatusOK, w.Code)
var task Task
require.NoError(t, json.NewDecoder(w.Body).Decode(&task))
assert.Equal(t, "42", task.ID)
})
t.Run("missing id", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/tasks/", nil)
NewTaskHandler().GetTask(w, r)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("wrong method", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/tasks/1", nil)
NewTaskHandler().GetTask(w, r)
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
})
}
Scaffold¶
Generate test scaffolding with go-testgen:
Testing Approach¶
httptest.ResponseRecorder:
- Handler in isolation — the recorder captures exactly what
WriteHeaderandWriteproduce. No routing, no middleware, no server process. Tests focus on handler logic alone. - Closure-check integration —
checkHandlerFnis a typed check function that receives*httptest.ResponseRecorder. Helper factories likecheckStatus(201)andcheckError("required")compose assertions as a slice. - No server lifecycle — no
defer server.Close(), no port allocation, no goroutines. Tests run as fast as any non-HTTP table-driven test. The recorder is created and inspected in the same function. - Request construction —
httptest.NewRequest(method, url, body)creates a valid*http.Requestwith aGETdefault body or configurable reader. Set headers explicitly for content-type, auth, etc.
View source code on GitHub