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

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

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

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

Тестирование хендлеров: httptest и chi

Тестирование chi начинается с факта: chi.Router — это http.Handler, поэтому httptest.NewRequest и NewRecorder дают полноценный прогон всего стека без сети. Покажем httptest go в деле: ServeHTTP против NewServer, проверки статуса, тела и заголовков, URLParam и 404/405, тест middleware на хендлере-заглушке — и живое место про зелёный тест на пустом параметре.

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

Спринт «качество» начался с тестирования 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 — это уже готовая цепочка, а не голая маршрутизация.

Минимальный тест без сети

GO
func 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

GO
func 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)

Тест 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: две ветки

GO
func 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обычные тесты пакета + testifyStore и сервисы проверяются без участия роутера

Чек-лист тестов 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() обязателен.

Соберите каркас теста

Восстановите четыре опоры: создание запроса, рекордер, прогон через роутер и проверку статуса.

req := httptest.(http.MethodGet, "/notes/42", nil) rr := httptest.() r.(rr, req) if rr. != http.StatusOK { t.Fatalf("код: %d", rr.Code) }

Соберите тест по строкам

Расставьте строки теста в правильном порядке: от функции до закрывающей скобки.

Строки

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

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

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

Термин

httptest.NewRequest(method, target, body)

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

Входящая *http.Request без сокета: Proto всегда HTTP/1.1, RemoteAddr — «192.0.2.1:1234» (TEST-NET-1, RFC 5737). На ошибку в аргументах паникует — «для удобства в тестах».

1 / 6

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 без токена и вызов заглушки с валидным.

Личные заметкиПусто
Хранятся только в вашем браузере (Chi · Тестирование хендлеров: httptest и chi)

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

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 и сами прогоняют запрос.

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

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

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

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

Redis в Go: кэш, сессии и очереди на go-redis

Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.

  • Подключаться к Redis из Go и настраивать пул соединений
  • Кэшировать результаты запросов и придумывать TTL
  • Работать с хэшами, списками, множествами и ZSET
  • Строить сессии, счётчики, лидерборды и rate limiter