Содержание урока · 10 разделов
- Тестирование chi: роутер — это http.Handler
- httptest go: полный стек через NewServer
- Что проверять: статус, тело, заголовки, URLParam
- Тест 404 и 405: контракт роутера в тестах
- Тест middleware: хендлер-заглушка
- Уровень теста → инструмент
- Чек-лист тестов HTTP-слоя
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Спринт «качество» начался с тестирования HTTP-слоя, и команда сразу раскололась. Одни поднимали в каждом тесте настоящий сервер: httptest.NewServer, порты, ожидания, а на машине CI периодически падало «address already in use» — порт занимал параллельный тест из соседнего пакета. Другие звали функции-хендлеры напрямую, как обычные функции: быстро, но проверялась логика без маршрутов — и половина боевых багов ускользала. Оба лагеря тратили силы впустую, потому что пропустили пару абзацев документации: chi.Router реализует http.Handler, а стандартный пакет net/http/httptest умеет строить входящий запрос и записывающий ответ без единого сокета. Три строки — req, rr, r.ServeHTTP(rr, req) — и весь стек middleware с маршрутизацией выполняется в памяти теста: детерминированно, параллельно, за миллисекунды. Когда сети всё-таки нужен настоящий TCP — есть NewServer, но это инструмент точечный, и ниже разберём, где его граница.
Тестирование chi: роутер — это http.Handler#
В mux.go chi есть func (mx Mux) ServeHTTP(w, r) — тот же метод, что у http.Server требует от Handler. Отсюда весь подход: роутер можно подать не только в сервер, но и позвать руками из теста. Пакет httptest go даёт две половины запроса. httptest.NewRequest(method, target, body) возвращает входящую http.Request без сокета: Proto всегда HTTP/1.1, RemoteAddr подставлен «192.0.2.1:1234» — адрес из TEST-NET-1, зарезервированного RFC 5737 для документации, а на ошибку в аргументах NewRequest паникует — в тестах это удобнее, чем проверка err. httptest.NewRecorder() возвращает ResponseRecorder — http.ResponseWriter, который пишет статус, заголовки и тело в память, чтобы их прочитал тест. Тестирование chi сводится к связке: собрать роутер той же функцией, что и main, прогнать через него запрос и сверить записи рекордера. Middleware при этом отрабатывают все: стек оборачивает внутренний routeHTTP, и ServeHTTP — это уже готовая цепочка, а не голая маршрутизация.
Минимальный тест без сети
GOfunc TestListNotes(t *testing.T) {
r := newRouter(storeStub) // chi.NewRouter + стек + маршруты
req := httptest.NewRequest(http.MethodGet, "/notes", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("код: %d, хотим 200", rr.Code)
}
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type: %q, хотим application/json", ct)
}
}newRouter — та же сборка, что и в main: chi.NewRouter, Use, маршруты; раскладку пакетов брали в уроке про subrouters. Запрос идёт через полный роутер: RequestID, Logger и остальные middleware отрабатывают, как на живом сервере. Проверяются и статус, и заголовок: Content-Type из урока про JSON API закреплён тестом, а не договорённостью в чате. Запуск — локальным Go командой go test ./...: ни портов, ни сети, тесты гоняются параллельно.
Проверка: что в rr.Code
Хендлер ответил только w.Write([]byte("ok")) — WriteHeader не вызывал. Что лежит в rr.Code?
httptest go: полный стек через NewServer#
Вторая половина пакета — httptest.NewServer(handler): он поднимает настоящий http.Server на 127.0.0.1 с портом :0, то есть случайным свободным, отдаёт ts.URL и настоящий клиент ts.Client(). По докстрингу Close блокируется, пока не завершатся все незавершённые запросы, — defer ts.Close() обязателен, иначе слушатели утекут между тестами. Что здесь появляется сверх ServeHTTP: реальный TCP и парсинг протокола net/http, keep-alive-соединения, поведение настоящего клиента — редиректы, cookies, таймауты, — а httptest.NewTLSServer добавляет TLS с сертификатом сервера. Это инструменты для тестов самого сервера и пути «клиент → сеть → стек»: healthz для балансировщика, интеграционный дым, проверка таймаутов http.Server. Цена — сокеты, порты, медленнее и больше поверхность для гонок. Практичный расклад: основные тесты — ServeHTTP с рекордером, пара NewServer на критический путь, где важен именно протокол, а не логика хендлера.
Тест через NewServer
GOfunc TestHealthzLive(t *testing.T) {
r := newRouter(storeStub)
ts := httptest.NewServer(r)
defer ts.Close()
resp, err := ts.Client().Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("запрос: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != "ok" {
t.Fatalf("тело: %q, хотим ok", body)
}
}Сервер живёт по-настоящему: слушает свободный порт, протокол разбирает net/http, клиент ходит по loopback. ts.Client() — клиент, согласованный с этим сервером: для NewTLSServer он уже доверяет его сертификату. defer ts.Close() закрывает слушатель и дожидается хвостов запросов. Проверьте тест локальным Go с -v: в выводе видно, как ОС выдаёт случайный порт, и запрос идёт по сети, пусть и внутри машины.
Что проверять: статус, тело, заголовки, URLParam#
Первое правило: запрос — через роутер, а не напрямую в хендлер. Маршрут должен реально смэтчиться: chi.URLParam наполняется только роутером, при прямом вызове в контексте нет RouteContext, и параметр — пустая строка. Отсюда главная ценность таких тестов — они ловят опечатки в шаблонах: r.Get("/notes/{it}") против chi.URLParam(r, "id") даст пустой параметр на всех запросах, и тест через роутер упадёт сразу, а не на проде. Проверки по слоям: статус — rr.Code после явного WriteHeader либо rr.Result().StatusCode для неявных; тело — rr.Body.String() для текста, для JSON — json.Unmarshal в структуру и сравнение полей, строковое сравнение сериализованного JSON хрупко из-за порядка ключей; заголовки — rr.Header().Get, тот же Content-Type и будущий Allow. Отдельный слой — параметры маршрутов: тест должен доказать, что {id} из шаблона реально доехал до хендлера.
Что напечатает тест?
Прочитайте код и выберите, что он выведет:
// маршрут: r.Get("/notes/{id}", getNote)
// внутри getNote:
fmt.Print(chi.URLParam(r, "id"))
// тест зовёт напрямую, минуя роутер:
req := httptest.NewRequest(http.MethodGet, "/notes/42", nil)
rr := httptest.NewRecorder()
getNote(rr, req)Тесты заметок звали getNote напрямую, минуя роутер. URLParam возвращал пустую строку, мок-Store отдавал заметку на любой id — тест требовал 200 и получал его. На проде GET /notes/42 отвечал 404 всем: шаблон маршрута был r.Get("/notes/{it}", getNote), chi.URLParam(r, "id") — пусто, поиск пустого id ничего не находил. Один тест через r.ServeHTTP(rr, req) поймал бы это в CI: на /notes/42 он получил бы 404 вместо 200 и уронил сборку. Правило, которое стоит в ревью: хендлер тестируется так, как его зовёт chi — через ServeHTTP и роутер.
Тест 404 и 405: контракт роутера в тестах#
Через роутер проверяются и системные ответы, разобранные в уроке про централизованные ошибки. Несуществующий путь — 404: либо дефолтный «404 page not found» text/plain, либо ваш JSON из r.NotFound. Путь существует, метод не тот — 405: дефолтный хендлер chi v5.0.8 делает WriteHeader(405) и пустое тело, даже Allow не ставит; после подмены в r.MethodNotAllowed возвращается ваш формат со списком разрешённых методов. Тест фиксирует, какой из двух контрактов действует сейчас: обновление chi или чужой рефакторинг не должны менять ответ молча. Побочный бонус: опечатка в шаблоне проявляется как 404 — тот же тест, что проверяет позитивный сценарий, ловит и сломанный маршрут, если ожидаемый статус и тело прописаны явно, а не «что-нибудь да пришло».
Проверка: чужой метод на маршруте
В роутере r.Get("/notes/{id}") и r.MethodNotAllowed не задан. Тест шлёт DELETE /notes/5 через r.ServeHTTP. Что проверить?
Тест middleware: хендлер-заглушка#
Middleware из урока про собственные слои проверяется обёрткой над заглушкой: next-хендлер записывает факт «меня позвали» и не делает ничего. Заглушка-шпион — булево поле или счётчик в замыкании — отделяет два принципиально разных исхода: middleware ответил сам (заглушка не позвана) или пропустил дальше (позвана). По слоям проверяется всё, что middleware делает: выставленный статус, добавленный заголовок, значение в контексте после r.WithContext, короткое замыкание при отказе. Звать middleware можно напрямую — mw(next).ServeHTTP(rr, req): быстрее и точнее для юнита конкретного слоя; через собранный роутер — когда проверяется порядок слоёв, что Logger видит честный статус от errorHandler или что RequireAdmin стоит раньше хендлера. Напрямую — для поведения одного middleware, через роутер — для композиции; полноценно нужны оба уровня, и таблица ниже собирает их в один список.
Тест RequireAuth: две ветки
GOfunc TestRequireAuth(t *testing.T) {
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true // шпион: middleware пропустил дальше
})
mw := RequireAuth("secret")
// без токена: 401, заглушка не позвана
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
mw(next).ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized || called {
t.Fatalf("без токена: код %d, called=%v", rr.Code, called)
}
// валидный токен: заглушка позвана
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.Header.Set("Authorization", "Bearer "+token("secret"))
rr2 := httptest.NewRecorder()
mw(next).ServeHTTP(rr2, req2)
if !called || rr2.Code != http.StatusOK {
t.Fatalf("с токеном: код %d, called=%v", rr2.Code, called)
}
}Заглушка-шпион called разделяет «ответил сам» и «пропустил дальше»: в первой ветке 401 и called=false, во второй — 200 и called=true. mw(next) — тот же func(http.Handler) http.Handler из урока про собственные middleware. Токен собирает тестовая функция token() с известной подписью — детерминированно, без внешнего сервиса. Каждый слой стека проверяется таким тестом отдельно; порядок слоёв — отдельным тестом через собранный роутер.
Уровень теста → инструмент#
| Уровень | Инструмент | Что даёт |
|---|---|---|
| Хендлер: статус, тело, заголовки | httptest.NewRequest + NewRecorder + r.ServeHTTP | весь стек без сети: миллисекунды, детерминированно, параллельно |
| Маршруты и параметры | запрос через роутер, а не прямой вызов хендлера | ловит опечатки шаблонов, проверяет URLParam и 404 |
| Middleware | обёртка над хендлером-заглушкой-шпионом | изоляция слоя: статус, заголовок, контекст, прерывание цепочки |
| Полный стек с сетью | httptest.NewServer + ts.Client(), TLS — NewTLSServer | реальный TCP и протокол, поведение настоящего клиента |
| Формат ошибок | тест статуса + Content-Type + error.code | контракт ошибок закреплён кодом, а не договорённостью |
| Логика без HTTP | обычные тесты пакета + testify | Store и сервисы проверяются без участия роутера |
ServeHTTP с рекордером — быстрый дефолт: без портов и сокетов, параллельно, стабильно, в каждом тесте пакета. NewServer — честный стек с сетью и настоящим клиентом: медленнее, но видит то, что рекордер не эмулирует — реальный протокол и TLS. Мой расклад: девять из десяти тестов — ServeHTTP, пара NewServer на критический путь — healthz, авторизация, то, что доходит до клиента через прокси. Граница простая: проверяется поведение http.Server (таймауты, TLS, keep-alive) — NewServer не заменить; проверяется логика хендлера — NewServer лишь медленнее без пользы.
Чек-лист тестов HTTP-слоя#
- Роутер — http.Handler: r.ServeHTTP(rr, req) прогоняет весь стек без сети и портов.
- Запросы — через роутер, не напрямую: URLParam, 404 и 405 существуют только в маршрутизации.
- Статус: rr.Code после явного WriteHeader; неявные 200 — через rr.Result().StatusCode.
- JSON-ответ: Unmarshal rr.Body в структуру, а не сравнение строк; Content-Type проверять заголовком.
- Middleware — хендлер-заглушка со шпионом: middleware ответил сам или пропустил дальше.
- NewServer — точечно: TLS, таймауты http.Server, настоящий клиент; defer ts.Close() обязателен.
Соберите каркас теста
Восстановите четыре опоры: создание запроса, рекордер, прогон через роутер и проверку статуса.
Соберите тест по строкам
Расставьте строки теста в правильном порядке: от функции до закрывающей скобки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: httptest и chi
HTTP-слой под тестами: хендлеры, маршруты, middleware и системные 404/405 проверяются без сети, а пара NewServer прикрыла протокольные сценарии. Живое место с пустым URLParam закрыто правилом «звать через роутер», а контракт ошибок из урока 15 теперь проверяется кодом: статус, Content-Type и error.code в одном тесте. Утверждения короче и говорливее, чем if-ы с t.Fatalf, делает testify — там же есть готовые assert.HTTPStatusCode и HTTPBody поверх того же httptest. Полная карта уроков — на странице курса chi.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- chi.Router — http.Handler: httptest.NewRequest + NewRecorder + r.ServeHTTP прогоняют весь стек middleware без сети.
- Запросы — только через роутер: URLParam, 404 и 405 существуют в маршрутизации, при прямом вызове их нет.
- rr.Code ставит WriteHeader; если хендлер не писал ничего, Code может быть 0 — неявные 200 берут из rr.Result().StatusCode.
- Проверять статус, тело, заголовки и формат ошибок из урока 15: Content-Type и error.code закрепляются тестом.
- Дефолтный 405 — пустое тело без Allow; тест фиксирует, какой контракт действует после подмены r.MethodNotAllowed.
- Middleware — хендлер-заглушка со шпионом: middleware ответил сам или пропустил дальше.
- NewServer — точечно: TLS, таймауты http.Server, настоящий клиент; defer ts.Close() обязателен.
Домашнее задание
Покройте тестами два хендлера каркаса: через httptest.NewRequest, NewRecorder и r.ServeHTTP проверьте статус, Content-Type и распарсенное тело JSON. Отдельный тест шлёт DELETE на GET-маршрут и ожидает 405 с пустым телом. Middleware авторизации проверьте хендлером-заглушкой: 401 без токена и вызов заглушки с валидным.
Личные заметкиПусто
Вопросы и ответы по уроку
ServeHTTP или NewServer — что выбрать для большинства тестов?
ServeHTTP с рекордером: тесты без сети быстрее, детерминированнее и безопасно параллелятся, а middleware и маршрутизация отрабатывают полностью. NewServer нужен, когда проверяется сам http.Server: TLS, таймауты, keep-alive, поведение настоящего клиента. Типовой расклад — девять из десяти тестов ServeHTTP и пара NewServer на критический путь.
Почему chi.URLParam возвращает пустую строку при прямом вызове?
Параметры кладёт роутер: при совпадении маршрута он заполняет RouteContext в контексте запроса, откуда URLParam их читает. Прямой вызов хендлера минует маршрутизацию — контекста нет, и chi честно возвращает пустую строку. Поэтому тест, который зовёт хендлер напрямую, не проверяет ни параметры, ни шаблоны маршрутов.
Нужен ли testify для тестов chi?
Нет — стандартных if с t.Fatalf достаточно, но testify делает утверждения короче и информативнее: assert.Equal на тело, require на предусловия. Отдельный бонус — готовые assert.HTTPStatusCode, HTTPSuccess и HTTPBody, которые под капотом используют тот же httptest: они принимают хендлер, метод и URL и сами прогоняют запрос.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Аутентификация: JWT и сессии как middleware
- Производительность chi: что под капотом
- Деплой: chi за reverse-прокси
- Chi против Gin, Echo и стандартного mux
- Subrouters и структура большого сервиса
- Порядок middleware: почему он важен
- Валидация параметров и тел запросов
- Graceful shutdown: выключаемся вежливо
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter