Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~18 мин +20 XP

Тестирование HTTP-хендлеров с testify

Тестирование HTTP go-хендлеров обычно тонет в церемониях: поднять сервер, выбрать порт, дождаться готовности. В уроке обходимся без сети: httptest.NewRequest собирает запрос, httptest.NewRecorder подставляет ResponseWriter, хендлер вызывается напрямую, а assert проверяет статус, тело и заголовки. Дальше — JSON-ответы, тест middleware и граница, за которой нужен настоящий сервер.

Содержание урока · 10 разделов

Хендлер отдает JSON пять минут, а тест на него превращается в ритуал: поднять сервер, отыскать свободный порт, дождаться готовности, не забыть закрыть listener. Для тестирования http go-хендлеров весь этот церемониал не нужен: хендлер в Go — обычная функция с сигнатурой func(w http.ResponseWriter, r *http.Request), и вызвать её можно руками, подсунув фиктивные запрос и ResponseWriter. Это и делает пакет net/http/httptest из стандартной библиотеки: httptest.NewRequest собирает входящий запрос, httptest.NewRecorder подставляет ResponseWriter, который всё записывает, а не отправляет. Recorder — деталь тестовой инфраструктуры, продакшен-код он не трогает: сигнатура хендлера остаётся прежней. Проверки ложатся на записанный ответ как на обычную переменную — тем же assert, что разобран в уроке про assert и require. Ниже: recorder, запросы без сети, JSON-ответы и тест middleware — а в конце обсудим, когда всё-таки понадобится настоящий сервер.

httptest.NewRecorder: ResponseWriter без сети#

Заглянем в исходники: httptest.NewRecorder возвращает httptest.ResponseRecorder — структуру, которая реализует интерфейс http.ResponseWriter и вместо отправки в сокет складывает всё в поля. Write дописывает байты в Body (это bytes.Buffer), WriteHeader меняет поле Code, Header отдаёт карту заголовков, Flush ставит флаг Flushed. Всё, что хендлер сделал с ответом, остаётся лежать в этих полях — читайте после вызова. Бонусы синхронности: тест не открывает портов, не ждёт листенера, не ловит гонку между стартом сервера и первым запросом; хендлер отрабатывает в той же горутине, что и тест, и к моменту следующей строки кода ответ уже собран. Recorder инициализируется с Code 200 — на этой неочевидности сыплются отчёты, разберём ниже.

Первый тест: статус, тело, вызов напрямую

GO
func Profile(w http.ResponseWriter, r *http.Request) {
        id := r.URL.Query().Get("id")
        u, err := store.Find(id)
        if err != nil {
                http.Error(w, "not found", http.StatusNotFound)
                return
        }
        w.Write([]byte(u.Name))
}

func TestProfileFound(t *testing.T) {
        req := httptest.NewRequest("GET", "/profile?id=42", nil)
        rr := httptest.NewRecorder()

        Profile(rr, req) // вызов без сети, как обычной функции

        assert.Equal(t, http.StatusOK, rr.Code)
        assert.Equal(t, "Anna", rr.Body.String())
}

Три строки каркаса: NewRequest собирает GET с query-параметром, NewRecorder готовит чистый ответ, вызов Profile(rr, req) прогоняет хендлер как обычную функцию. Дальше привычный assert: Code сверяется с http.StatusOK, тело — со строкой из Body.String(). Пакет net/http/httptest входит в стандартную библиотеку, testify нужен только для проверок. Прогон — локальный go test: тестовые пакеты и HTTP в песочнице не исполняются.

httptest.NewRequest: запрос без сети#

Сигнатура простая: httptest.NewRequest(method, target string, body io.Reader) *http.Request — возвращает входящий серверный запрос, готовый к передаче хендлеру. Нюансы, которые лучше знать заранее. Пустой метод означает GET; target — либо путь, либо абсолютный URL, из которого берётся Host, а без хоста подставляется example.com. Протокол всегда HTTP/1.1, а RemoteAddr — 192.0.2.1:1234, адрес из зарезервированного TEST-NET диапазона RFC 5737: если хендлер логирует клиента, в тестах вы увидите именно его. На некорректные аргументы NewRequest паникует — в тестах это удобно: ошибку сборки запроса не обрабатывают, она валит тест сразу с внятной причиной. Тело — любой io.Reader: strings.NewReader(json) для POST, nil для GET; для strings.Reader и bytes.Buffer ContentLength выставится сам.

Проверка: что в запросе по умолчанию

Что вернёт req.Host после вызова httptest.NewRequest("GET", "/profile", nil)?

Тестирование HTTP в Go: rr.Code, rr.Body и заголовки#

Поле за полем. Статус — rr.Code: recorder инициализируется двумястами, а WriteHeader хендлера перезаписывает значение, поэтому для явных статусов проверка честная. Тело — rr.Body.String(): буфер содержит ровно то, что хендлер передал в Write. Заголовки — rr.Header().Get: recorder не отправляет их, а хранит в карте, и Get читает каноническое имя без капризов регистра. Для http.HandlerFunc запись короче: handler(rr, req); для объектов с методом ServeHTTP — h.ServeHTTP(rr, req), суть одна. Отдельный assert на каждый аспект читается лучше одного гигантского сравнения, а если кейсов много — раскладывайте их в табличные тесты: один каркас с recorder, слайс ожиданий. Для полного ответа есть rr.Result() *http.Response: StatusCode, снимок заголовков и тело в виде ReadCloser — удобно, когда проверяемый код читает ответ как клиент. Снимок заголовков делается при первом Write: заголовок, выставленный после записи тела, в Result() уже не попадёт. Целиком Result не сравнивайте через DeepEqual — авторы пакета прямо просят этого не делать: состав полей может пополняться.

Что проверяем → поле recorder#

Что проверяемПоле или методНюанс
статус-кодrr.Coderecorder стартует с 200; явный WriteHeader перезаписывает
тело ответаrr.Body.String()ровно то, что ушло в Write, без сетевой обработки
заголовокrr.Header().Get("Content-Type")канонические имена, регистр не важен
полный ответrr.Result()снимок заголовков при первом Write; без DeepEqual целиком
вызов Flushrr.Flushedtrue, если хендлер стримил

JSON-ответ: Unmarshal в структуру и assert#

Сырое тело сравнивать со строкой-эталоном хрупко: лишний пробел, перестановка полей сериализатором, перевод строки в конце — и тест красный без изменения поведения. Правильный путь — распаковать JSON и сверить данные: json.Unmarshal(rr.Body.Bytes(), &got) в структуру или карту, дальше обычный assert.Equal по полям — сравнение структур после распаковки работает так же, как в уроке про сравнение структур. Имена полей подбираются без тегов и без учёта регистра: поля Name достаточно для ключа name. Карту map[string]any берите, пока формат ответа ещё гуляет: значения достаются type assertion вида got["name"].(string). Ветку ошибки проверяйте отдельно: у 404 тело уже не JSON, и assert.ErrorIs по ошибке хендлера скажет больше, чем сравнение текста. Для большого тела запускайте json.Valid до Unmarshal: невалидный ответ даст внятную ошибку вместо каскада пустых полей.

Разбор JSON-ответа в тесте

GO
type profileResp struct {
        Name string
        Age  int
}

func TestProfileJSON(t *testing.T) {
        req := httptest.NewRequest("GET", "/profile?id=42", nil)
        rr := httptest.NewRecorder()

        Profile(rr, req)

        assert.Equal(t, http.StatusOK, rr.Code)
        assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))

        var got profileResp
        require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got))
        assert.Equal(t, "Anna", got.Name)
        assert.Equal(t, 29, got.Age)
}

Проверка Content-Type стоит до распаковки: битый заголовок увидим сразу, а не по странному падению Unmarshal. Для Unmarshal взят require — с невалидным телом проверять дальше нечего, тесту пора останавливаться. Теги не нужны: json.Unmarshal сопоставляет поля структуры ключам без учёта регистра. Тестовый каркас запускается локальным go test, как и все тестовые пакеты курса.

Что решит прогон?

Прочитайте код и выберите, что он выведет:

func TestCode(t *testing.T) {
        h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                w.Write([]byte("ok"))
        })
        req := httptest.NewRequest("", "/health", nil)
        rr := httptest.NewRecorder()

        h(rr, req)

        assert.Equal(t, http.StatusTeapot, rr.Code)
}

Тест middleware через хендлер-заглушку#

Middleware — тоже хендлер: обёртка вида func(http.Handler) http.Handler, и её тестируют тем же recorder'ом, только внутрь подсовывают заглушку. Схема: собираете финальный хендлер middleware(next), зовёте его с recorder'ом, проверяете и заглушку, и артефакт обёртки. Заглушка обычно поднимает флаг — переменная рядом с тестом, — а утверждения смотрят на два следа: флаг вызова (next дошёл) и побочный эффект (появился X-Request-ID, перезаписан путь, обогащён контекст). Порядок важен: middleware, ставящий заголовок после вызова next, опоздает, если next уже начал писать тело — recorder зафиксирует снимок заголовков при первом Write, и промах станет видимым в тесте. Так же изолированно проверяется авторизация: без токена заглушка не вызывается вовсе, флаг остался опущен, статус 401.

Middleware под recorder'ом

GO
func WithRequestID(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                id := strconv.FormatInt(time.Now().UnixNano(), 10)
                w.Header().Set("X-Request-ID", id)
                next.ServeHTTP(w, r)
        })
}

func TestMiddlewareSetsHeader(t *testing.T) {
        called := false
        next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                called = true
        })

        req := httptest.NewRequest("GET", "/", nil)
        rr := httptest.NewRecorder()

        WithRequestID(next).ServeHTTP(rr, req)

        assert.True(t, called)
        assert.NotEmpty(t, rr.Header().Get("X-Request-ID"))
}

Заглушка next не пишет ни байта: она только поднимает флаг called — так тест отделяет работу middleware от поведения настоящего хендлера. Вызов WithRequestID(next).ServeHTTP(rr, req) прогоняет обёртку целиком: заголовок появился, цепочка дошла до заглушки. assert.NotEmpty вместо Equal — значение идентификатора не важно, важно наличие. Два независимых следа ловят промах независимо: забытый вызов next или забытый заголовок.

Проверка: сниффинг Content-Type

Хендлер вызвал w.Write(jsonBytes) без установки Content-Type. Что вернёт rr.Result().Header.Get("Content-Type") после завершения хендлера?

Чек-лист хендлер-теста#

  • Каркас один и тот же: NewRequest, NewRecorder, прямой вызов хендлера или обёртки.
  • Статус — rr.Code; помните, что recorder стартует с 200.
  • Content-Type проверяйте assert-ом до распаковки тела: сниффинг и у recorder, и на проде.
  • JSON распаковывайте в структуру или карту и сверяйте поля, а не сырую строку.
  • Middleware тестируйте через хендлер-заглушку: флаг вызова плюс артефакт обёртки.
  • Полный ответ — rr.Result(): снимок заголовков при первом Write, без DeepEqual целиком.

Соберите тест без сети

Допишите каркас: сборка запроса, recorder и сверка статуса.

func TestHealth(t *testing.T) { req := httptest.("GET", "/health", nil) rr := httptest._() Health(rr, req) assert.Equal(t, http.StatusOK, rr.) }

Соберите тест хендлера

Расставьте строки теста: каркас, вызов, две сверки.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: httptest и recorder

Термин

httptest.NewRecorder()

нажмите, чтобы перевернуть
Определение

Возвращает *httptest.ResponseRecorder — реализацию http.ResponseWriter, которая записывает Code, тело и заголовки в поля вместо сети. Стартует с Code 200.

1 / 6

Каркас recorder плюс request закрывает большинство тестов хендлеров: статус, тело, заголовки, middleware — всё без портов и сокетов. Что не закрылось этим уроком — асинхронность: если хендлер запускает фоновую горутину, которая дописывает кэш или счётчик, assert на следующей строке может прийти раньше результата. С этим справится assert.Eventually из следующего урока. Дальше по курсу — сравнение JSON в тестах и фикстуры для тяжёлых данных: тема ответов продолжится, но уже на уровне содержимого. И гигиена напоследок: recorder создавайте заново в каждом тесте — общий recorder между кейсами аккумулирует чужие ответы и ломает сверку. Карта уроков — на странице testify-курса; если хендлеры у вас пока без тестов, начните с одного — самого доходного по багам.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Хендлер — обычная функция: вызывайте её с recorder'ом, сервер и порты не нужны.
  • httptest.NewRequest собирает входящий запрос: пустой метод — GET, неверные аргументы — паника.
  • Recorder стартует с Code 200, а первый Write проводит заголовки, как на настоящем сервере.
  • Статус, тело и заголовки проверяются по полям: rr.Code, rr.Body.String(), rr.Header().Get.
  • JSON распаковывайте в структуру или карту: сравнение сырой строки хрупко.
  • Middleware тестируется хендлером-заглушкой: флаг вызова плюс появившийся заголовок.
  • Один сквозной тест через httptest.NewServer закрывает маршрутизацию и транспорт.

Домашнее задание

Возьмите хендлер из своего проекта и напишите три теста без сети: счастливый путь со сверкой JSON-полей, 404 с проверкой Content-Type и middleware, добавляющий заголовок. В одном тесте сознательно уберите Content-Type у JSON-ответа и посмотрите, что вернёт rr.Result().Header.Get.

Личные заметкиПусто
Хранятся только в вашем браузере (Testify · Тестирование HTTP-хендлеров с testify)

Вопросы и ответы по уроку

Подойдёт ли эта схема для хендлеров gin и echo?

Для чистых функций с w и r — без изменений: под фреймворками лежит тот же http.ResponseWriter. У роутеров gin и echo есть метод ServeHTTP, поэтому recorder и NewRequest работают и на уровне маршрутов. Для gin.Context отдельно есть CreateTestContext — это уже тема фреймворковых уроков.

Чем rr.Code отличается от rr.Result().StatusCode?

Code — поле, которое меняет WriteHeader; recorder инициализирует его двумястами, поэтому даже пустой хендлер покажет 200. Result().StatusCode — статус финального ответа со снимком заголовков на момент первого Write. Для обычных тестов хватает rr.Code, Result берите, когда нужен полный ответ как объект.

Нужен ли настоящий сетевой тест, если все хендлеры покрыты?

Один стоит: прямой вызов не проверяет маршрутизацию, порядок middleware и поведение реального клиента. Сквозной тест через httptest.NewServer с http.Client закрывает эту зону — поднятие сервера в тесте занимает микросекунды, порты выбираются автоматически. Всё остальное покрытие оставляйте прямым вызовам: они быстрее и точнее в диагностике.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Viper25 уроков~9 чпрактика25 уроков уже доступны

Viper: конфигурация приложений на Go

Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.

  • Собирать конфиг из файлов, окружения и флагов в один реестр
  • Читать значения типизированно: Get, GetString, Unmarshal в структуры
  • Задавать приоритеты источников: флаг > env > файл > default
  • Перечитывать конфиг на лету через WatchConfig и OnConfigChange