HTTP-сервер, JSON API и итоговый проект

Тестирование 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.

Начать заново?

Помощник начнёт новый диалог по этой задаче. Текущая переписка останется в истории до конца срока хранения.

О переписке

Можно приложить до трёх фото PNG, JPEG или WebP по 5 МБ.

Переписка и фото хранятся 90 дней после последнего принятого сообщения. Преподаватель получит доступ к диалогу, когда вы попросите его помочь.

Проверка решения остаётся отдельным действием.