Skip to content

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:

go-testgen report . --format table
go-testgen gen . TaskHandler.CreateTask

Testing Approach

httptest.ResponseRecorder:

  1. Handler in isolation — the recorder captures exactly what WriteHeader and Write produce. No routing, no middleware, no server process. Tests focus on handler logic alone.
  2. Closure-check integration — checkHandlerFn is a typed check function that receives *httptest.ResponseRecorder. Helper factories like checkStatus(201) and checkError("required") compose assertions as a slice.
  3. 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.
  4. Request construction — httptest.NewRequest(method, url, body) creates a valid *http.Request with a GET default body or configurable reader. Set headers explicitly for content-type, auth, etc.

View source code on GitHub