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