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

Тестирование 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", bodyio.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, никакого открытого порта.