Урок курса
Тестирование HTTP-обработчиков через httptest
Golang с нуляВ прошлом уроке мы построили Task API: newMux(store) регистрирует обработчики, Store защищён мьютексом, ошибки возвращаются единообразным JSON-объектом {"error": "..."}. Теперь нужно это всё проверить — и делать это без реального TCP-порта, без curl, без запуска сервера. Пакет net/http/httptest даёт ровно такую возможность.
httptest.NewRequest и NewRecorder: создание запроса и захват ответа без сети
Идея проста: обработчик — это функция func(http.ResponseWriter, *http.Request). Значит, достаточно подать ей синтетический запрос и подставить объект, который запишет ответ в память. Именно это делают httptest.NewRequest и httptest.NewRecorder.
httptest.NewRequest(method, target, body) возвращает *http.Request, готовый к передаче обработчику. Аргумент target — это путь вида "/tasks/1", body — io.Reader с JSON-телом или nil. Сетевого соединения нет: запрос существует только в памяти процесса.
Для POST и PATCH с телом типичный вызов выглядит так:
bodyJSON := `{"title": "buy milk"}`
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(bodyJSON))
req.Header.Set("Content-Type", "application/json")
Заголовок Content-Type нужно выставить явно — NewRequest не добавляет его автоматически. Если ваш обработчик проверяет этот заголовок или полагается на json.NewDecoder(r.Body), отсутствие заголовка не сломает декодирование, но может нарушить логику валидации.
httptest.NewRecorder() создаёт *httptest.ResponseRecorder. Это реализация http.ResponseWriter, которая пишет статус, заголовки и тело в собственные поля в памяти.
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
resp := rr.Result()
После ServeHTTP всё, что обработчик записал в ResponseWriter, доступно через resp. Ключевой момент: используйте rr.Result(), а не rr.Header() напрямую.
Почему это важно? rr.Header() возвращает живую http.Header map — ту, в которую обработчик пишет заголовки. Но HTTP-семантика такова: заголовки фиксируются в момент вызова WriteHeader (или первого Write). Если обработчик ошибочно добавит заголовок уже после записи статуса, rr.Header() покажет его, а rr.Result().Header — нет. rr.Result() делает снимок именно того, что увидел бы клиент.
resp := rr.Result()
fmt.Println(resp.StatusCode) // 201
fmt.Println(resp.Header.Get("Content-Type")) // application/json
// resp.Body — io.ReadCloser, читается как обычно
Для GET и DELETE тело запроса равно nil:
req := httptest.NewRequest(http.MethodGet, "/tasks", nil)
Это всё, что нужно для одного вызова обработчика. Никакого http.ListenAndServe, никакого открытого порта.
Табличная матрица подтестов с изолированным store и mux на каждый случай
Когда сценариев много — GET по списку, POST с валидным телом, POST с невалидным, PATCH несуществующей задачи, DELETE, неизвестный метод — удобно описать их таблицей. Та же техника t.Run с таблицей случаев, которую мы применяли для обычных функций, работает и здесь.
Структура одного случая:
type testCase struct {
name string
method string
url string
body string
setup func(*Store) // подготовка данных до запроса
wantStatus int
wantCT bool // ожидаем application/json?
checkBody func(t *testing.T, resp *http.Response)
}
Поле setup — ключевое дополнение. Многие сценарии требуют предварительного состояния в хранилище: PATCH может менять задачу, только если она уже существует, DELETE возвращает 204 только для существующего ресурса. setup вызывается после создания store и до отправки запроса, поэтому состояние гарантированно готово к моменту ServeHTTP.
Почему это важно: ловушка с общим store. Рассмотрим типичную ошибку:
// НЕПРАВИЛЬНО
store := NewStore()
mux := newMux(store)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.url, strings.NewReader(tc.body))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
// ...
})
}
Если POST-случай идёт первым и добавляет задачу в store, следующий GET-случай увидит её. Тест для пустого списка внезапно получит непустой. Подтесты становятся зависимы от порядка — именно то, что делает тесты ненадёжными.
Правильный подход — создавать store и mux внутри каждого t.Run, а setup выполнять между ними:
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
store := NewStore()
if tc.setup != nil {
tc.setup(store)
}
mux := newMux(store)
var bodyReader io.Reader
if tc.body != "" {
bodyReader = strings.NewReader(tc.body)
}
req := httptest.NewRequest(tc.method, tc.url, bodyReader)
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
resp := rr.Result()
if resp.StatusCode != tc.wantStatus {
t.Errorf("status: got %d, want %d", resp.StatusCode, tc.wantStatus)
}
if tc.wantCT {
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type: got %q", ct)
}
}
if tc.checkBody != nil {
tc.checkBody(t, resp)
}
})
}
Примечание про
tc := tc. В Go 1.22+ переменная цикла получает отдельное значение на каждой итерации, поэтому явный захватtc := tcбольше не обязателен. Если ваш проект поддерживает Go до 1.22, добавьте эту строку первой внутриt.Run.
Каждый подтест стартует с чистым хранилищем. Порядок строк в таблице не влияет на результат — можно переставлять их, добавлять новые, запускать конкретный подтест через -run.
Таблица случаев для нашего API покрывает следующие сценарии:
cases := []testCase{
{
name: "GET /tasks empty",
method: http.MethodGet, url: "/tasks",
wantStatus: http.StatusOK, wantCT: true,
},
{
name: "POST valid",
method: http.MethodPost, url: "/tasks",
body: `{"title":"buy milk"}`,
wantStatus: http.StatusCreated, wantCT: true,
},
{
name: "POST invalid JSON",
method: http.MethodPost, url: "/tasks",
body: `not-json`,
wantStatus: http.StatusBadRequest, wantCT: true,
},
{
name: "POST empty title",
method: http.MethodPost, url: "/tasks",
body: `{"title":""}`,
wantStatus: http.StatusBadRequest, wantCT: true,
},
{
name: "PATCH done=false",
method: http.MethodPatch, url: "/tasks/1",
body: `{"done":false}`,
// setup создаёт задачу с ID=1 и done=true до запроса
setup: func(s *Store) {
s.Add(Task{Title: "buy milk", Done: true})
},
wantStatus: http.StatusOK, wantCT: true,
},
{
name: "PATCH non-existent",
method: http.MethodPatch, url: "/tasks/999",
body: `{"done":true}`,
wantStatus: http.StatusNotFound, wantCT: true,
},
{
name: "DELETE existing",
method: http.MethodDelete, url: "/tasks/1",
setup: func(s *Store) {
s.Add(Task{Title: "buy milk"})
},
wantStatus: http.StatusNoContent,
},
{
name: "PUT method not allowed",
method: http.MethodPut, url: "/tasks",
wantStatus: http.StatusMethodNotAllowed, wantCT: true,
},
}
Обратите внимание на сценарии с setup: PATCH done=false и DELETE существующей задачи требуют, чтобы запись в хранилище появилась до вызова ServeHTTP. Именно поэтому setup выполняется после NewStore, но до newMux — обработчик получает уже заполненный store.
Проверка статуса, Content-Type, декодированного JSON и мутационная верификация теста
Статус проверяется прямолинейно: resp.StatusCode != tc.wantStatus. Интереснее три класса проверок тела — успешный ответ, ответ об ошибке и пустое тело.
Успешный ответ. POST возвращает созданную задачу, GET возвращает список. Декодируем и сверяем конкретные поля:
checkBody: func(t *testing.T, resp *http.Response) {
var task Task
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
t.Fatalf("decode: %v", err)
}
if task.Title != "buy milk" {
t.Errorf("title: got %q", task.Title)
}
if task.ID == 0 {
t.Error("ID must be non-zero")
}
},
Ответ об ошибке. Для 400, 404, 405 API возвращает {"error": "..."}. Нас не интересует конкретный текст — формулировка может измениться. Проверяем только то, что поле непустое:
checkBody: func(t *testing.T, resp *http.Response) {
var e struct{ Error string `json:"error"` }
if err := json.NewDecoder(resp.Body).Decode(&e); err != nil {
t.Fatalf("decode error response: %v", err)
}
if e.Error == "" {
t.Error("expected non-empty error field")
}
},
Такой тест не сломается при рефакторинге текста сообщений, но сломается, если обработчик перестанет возвращать JSON вообще.
Пустое тело (DELETE 204). После успешного удаления тело должно быть пустым. Функция checkBody получает resp *http.Response, поэтому читаем resp.Body напрямую:
checkBody: func(t *testing.T, resp *http.Response) {
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if len(body) != 0 {
t.Errorf("expected empty body, got %d bytes: %q", len(body), body)
}
},
Важно: для DELETE случай не устанавливает wantCT: true, поэтому проверка Content-Type не выполняется — 204 не предполагает тела и заголовка типа содержимого.
Мутационная проверка. После того как go test ./... даёт PASS по всем подтестам, нужно убедиться, что тесты реально что-то проверяют, а не просто проходят. Для этого намеренно ломаем одну деталь обработчика.
Пример 1: обработчик POST возвращает http.StatusCreated (201). Меняем на http.StatusOK (200):
// было
w.WriteHeader(http.StatusCreated)
// стало (мутация)
w.WriteHeader(http.StatusOK)
Подтест "POST valid" должен упасть с сообщением вида status: got 200, want 201. Остальные подтесты остаются зелёными.
Пример 2: ветка невалидного JSON — было writeError(w, 400, "invalid json"), стало голый return:
// мутация
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
return // без ответа
}
Recorder запишет статус 200 (дефолт) и пустое тело. Подтест "POST invalid JSON" ожидает статус 400 и JSON с непустым error — оба ассерта падают.
Важно выбирать мутацию, которая точечно меняет контракт. Если удалить только вызов декодирования, но следующая валидация title == "" всё равно вернёт 400 — мутация не делает тест красным. Это признак слабого теста: нужно либо усилить checkBody так, чтобы он различал «400 из-за невалидного JSON» и «400 из-за пустого title», либо выбрать другую точку для мутации.
Попробуйте решить
Разработчик создаёт один store := NewStore() перед циклом по таблице подтестов и передаёт его во все t.Run. POST-подтест добавляет задачу. Какой эффект это даёт для GET-подтеста, если он выполняется следующим?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
