Тестирование HTTP-обработчиков через httptest
Содержание курса
Табличная матрица подтестов с изолированным 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.
