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¶
func TestTaskHandler_CreateTask(t *testing.T) {
tests := []struct {
name string
method string
ctype string
body string
checks []checkHandlerFn
}{
{
name: "success",
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: `{bad}`,
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)
NewTaskHandler().CreateTask(w, r)
for _, fn := range tt.checks {
fn(t, w) // each check receives the recorder
}
})
}
}
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