Урок курса
Тестирование 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», либо выбрать другую точку для мутации.
Задача 1
Разработчик создаёт один store := NewStore() перед циклом по таблице подтестов и передаёт его во все t.Run. POST-подтест добавляет задачу. Какой эффект это даёт для GET-подтеста, если он выполняется следующим?
Задача 2
После вызова mux.ServeHTTP(rr, req) тест читает rr.Code. Обработчик вызвал json.NewEncoder(w).Encode(task), но не вызывал w.WriteHeader явно. Какое значение будет у rr.Code?
Задача 3
Тест проверяет ответ на DELETE /tasks/1 для существующей задачи. Какой набор из трёх проверок корректен согласно контракту Task API?
Задача 4
Матрица HTTP-тестов Task API
В каждом сервисе, работающем с задачами, рано или поздно накапливается набор правил: что считать успехом, что — ошибкой, как реагировать на отсутствующий ресурс или запрос с непонятным методом. Эти правила живут в обработчиках, но их настоящая проверка происходит только тогда, когда кто-то целенаправленно пытается сломать систему — подаёт кривой JSON, обращается к несуществующему идентификатору, использует метод, о котором сервер не имеет представления.
Перед тобой готовая реализация Task API: хранилище задач, маршрутизатор, обработчики. Реализация корректна, но лишена доказательств. Твоя задача — написать полный набор HTTP-тестов, которые эти доказательства предъявят: убедятся, что создание задачи возвращает нужный статус, что обновление флага выполнения отражается в ответе, что удаление оставляет пустое тело, а некорректные запросы получают именно тот код, который им и положен.
Каждый сценарий должен жить в собственном, изолированном окружении — со своим хранилищем и своим маршрутизатором, — чтобы побочный эффект одной проверки не мог исказить результат другой. Ответ сервера нужно читать уже после того, как он зафиксирован в записывающем регистраторе, а не в процессе его формирования. Именно такая дисциплина превращает набор вызовов в надёжную страховочную сетку.
Что нужно сделать
Допишите handlers_test.go: создайте таблицу минимум из семи сценариев — GET 200, POST 201, PATCH с переходом true → false 200, DELETE 204 с пустым телом, сломанный JSON 400, отсутствующий ID 404 и неподдерживаемый метод 405. Для каждого элемента таблицы запускайте отдельный t.Run, а внутри него создавайте свежие Store и mux.
Запрос создаётся через httptest.NewRequest, ответ — через httptest.NewRecorder. После ServeHTTP вызовите rr.Result() и проверяйте resp.StatusCode, resp.Header и декодированное resp.Body; не сравнивайте точный текст поля error. Неизменяемый meta-test реально запускает ваш suite сначала с корректным handler, затем с набором целевых мутаций. Поэтому допустима любая разумная структура тестов, но пустой или поверхностный suite не пройдёт.
Продолжить с сохранением прогресса
Перейдите к уроку в учебном кабинете.
