Содержание урока · 10 разделов
- httptest.NewRecorder: ResponseWriter без сети
- httptest.NewRequest: запрос без сети
- Тестирование HTTP в Go: rr.Code, rr.Body и заголовки
- Что проверяем → поле recorder
- JSON-ответ: Unmarshal в структуру и assert
- Тест middleware через хендлер-заглушку
- Чек-лист хендлер-теста
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Хендлер отдает 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 — на этой неочевидности сыплются отчёты, разберём ниже.
Первый тест: статус, тело, вызов напрямую
GOfunc 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.Code | recorder стартует с 200; явный WriteHeader перезаписывает |
| тело ответа | rr.Body.String() | ровно то, что ушло в Write, без сетевой обработки |
| заголовок | rr.Header().Get("Content-Type") | канонические имена, регистр не важен |
| полный ответ | rr.Result() | снимок заголовков при первом Write; без DeepEqual целиком |
| вызов Flush | rr.Flushed | true, если хендлер стримил |
История с продакшена: хендлер писал JSON через w.Write(jsonBytes) и забыл w.Header().Set("Content-Type", "application/json"). Тест проверял только rr.Body.String() — данные совпадали, тест зелёный месяцами. В бою настоящий сервер делает сниффинг: не видя заголовка, он подставляет http.DetectContentType от первых байтов, а для JSON это text/plain; charset=utf-8. Фронтенд вызывал fetch(...).json(), парсер получил text/plain и упал у всех пользователей. Чинили тест: assert.Equal(t, "application/json", rr.Header().Get("Content-Type")). Полезная деталь: recorder ведёт себя как настоящий сервер — при первом Write без Content-Type он подставляет тот же DetectContentType, так что проверка заголовка ловит баг ещё на recorder, без сети.
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-ответа в тесте
GOtype 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'ом
GOfunc 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") после завершения хендлера?
Спор возникает на каждом ревью: прямые вызовы хендлера против интеграционных через httptest.NewServer. Я в основном пишу без сети: быстрее, детерминированнее, падение указывает на конкретный хендлер, а не на «где-то в цепочке». Но у прямого вызова есть слепая зона — маршрутизация и транспорт: путь, метод, роутер, middleware в цепочке, настоящий HTTP-клиент. Условие, при котором я прав: минимум один сквозной тест на маршрут поднимает httptest.NewServer и ходит настоящим http.Client — он ловит забытый роут и middleware, который режет заголовки. Остальное — прямой вызов с recorder'ом, без портов и сокетов.
Чек-лист хендлер-теста#
- Каркас один и тот же: NewRequest, NewRecorder, прямой вызов хендлера или обёртки.
- Статус — rr.Code; помните, что recorder стартует с 200.
- Content-Type проверяйте assert-ом до распаковки тела: сниффинг и у recorder, и на проде.
- JSON распаковывайте в структуру или карту и сверяйте поля, а не сырую строку.
- Middleware тестируйте через хендлер-заглушку: флаг вызова плюс артефакт обёртки.
- Полный ответ — rr.Result(): снимок заголовков при первом Write, без DeepEqual целиком.
Соберите тест без сети
Допишите каркас: сборка запроса, recorder и сверка статуса.
Соберите тест хендлера
Расставьте строки теста: каркас, вызов, две сверки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: httptest и recorder
Каркас 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.
Личные заметкиПусто
Вопросы и ответы по уроку
Подойдёт ли эта схема для хендлеров 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 закрывает эту зону — поднятие сервера в тесте занимает микросекунды, порты выбираются автоматически. Всё остальное покрытие оставляйте прямым вызовам: они быстрее и точнее в диагностике.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- assert.Eventually: тесты асинхронного кода
- JSONify и сравнение JSON в тестах
- Фикстуры и тестовые данные: где их хранить
- Testify и стандартная библиотека: границы
- mockery: генерация моков из интерфейсов
- AssertExpectations: проверяем, что мок вызван
- Аргументы вызова: Anything и свои matcher'ы
- On и Return: настраиваем поведение заглушек
- Все курсы по Go
Следующая ступень учебного пути
Viper: конфигурация приложений на Go
Интерактивный курс Viper для Go: YAML и env-переменные, приоритеты источников, Unmarshal, WatchConfig, профили окружений и секреты. 25 уроков с примерами.
- Собирать конфиг из файлов, окружения и флагов в один реестр
- Читать значения типизированно: Get, GetString, Unmarshal в структуры
- Задавать приоритеты источников: флаг > env > файл > default
- Перечитывать конфиг на лету через WatchConfig и OnConfigChange