---
title: "Тестирование хендлеров: httptest и chi"
description: "Тестирование chi без сети: chi.Router как http.Handler, httptest.NewRequest и NewRecorder, ServeHTTP против NewServer, статус, тело, заголовки, URLParam и 405."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/20-testing-chi/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

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

*Курс «Chi: роутер для Go HTTP-сервисов», урок 20 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/20-testing-chi/*

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

Спринт «качество» начался с тестирования 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?

- ✅ 200: Write неявно выставляет 200 через внутренний writeHeader рекордера
- 0: Code меняется только явным WriteHeader
- Пустую строку: Code — строковое поле
- 500: рекордер требует явного WriteHeader

ResponseRecorder.Write вызывает writeHeader, а тот — WriteHeader(200): Code становится 200, заодно рекордер проставляет Content-Type через DetectContentType, если хендлер его не задал. Нюанс из докстринга: если хендлер не писал ни WriteHeader, ни Write, rr.Code может остаться 0 — неявные 200 надёжнее брать через rr.Result().StatusCode.

### 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. Отдельный слой — [параметры маршрутов](/courses/chi/lessons/04-urlparam/): тест должен доказать, что {id} из шаблона реально доехал до хендлера.

### Что напечатает тест?

```go
// маршрут: 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)
```

**Что выведет программа?**

- 42: URLParam сам парсит путь из запроса
- ✅ Пустую строку: RouteContext нет — его кладёт роутер
- Панику: URLParam без роутера запрещён
- id: вернётся имя параметра

chi.URLParam читает RouteContext из контекста запроса — его заполняет роутер при совпадении маршрута. Прямой вызов контекста не создаёт, и chi честно возвращает пустую строку — без паники, что и делает такую ошибку скользкой. Через роутер с шаблоном /notes/{id} хендлер получил бы 42.

> **Тест зелёный — сервис 404-ит.** Тесты заметок звали 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: контракт роутера в тестах

Через роутер проверяются и системные ответы, разобранные в уроке про [централизованные ошибки](/courses/chi/lessons/15-error-handling/). Несуществующий путь — 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. Что проверить?

- ✅ rr.Code == 405 и пустое тело: дефолтный хендлер делает WriteHeader(405) и больше ничего
- 405 и заголовок Allow: DELETE, GET — chi выставляет Allow автоматически
- 404: для DELETE этот путь не существует
- 200: DELETE доходит до getNote, ответ игнорируется

Путь смэтчился, метод не разрешён — chi отдаёт 405. В исходниках v5.0.8 дефолтный methodNotAllowedHandler — WriteHeader(405) и w.Write(nil): тело пустое, Allow не ставится. Как только задали свой r.MethodNotAllowed из урока 15, тест обязан проверять уже его контракт — статус, Allow и формат тела.

### Тест 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 | обычные тесты пакета + testify | Store и сервисы проверяются без участия роутера |

> **Спор: ServeHTTP против NewServer.** 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() обязателен.

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

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

```go
req := httptest.___(http.MethodGet, "/notes/42", nil)
rr := httptest.___()
r.___(rr, req)

if rr.___ != http.StatusOK {
        t.Fatalf("код: %d", rr.Code)
}
```

Подсказка: Запрос строит httptest.NewRequest, ответ записывает httptest.NewRecorder, их соединяет r.ServeHTTP, а статус лежит в rr.Code.

Правильные ответы в пропусках: `NewRequest`, `NewRecorder`, `ServeHTTP`, `Code`

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

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

Правильный порядок строк:

1. `func TestGetNote(t *testing.T) {`
2. `req := httptest.NewRequest(http.MethodGet, "/notes/42", nil)`
3. `rr := httptest.NewRecorder()`
4. `r.ServeHTTP(rr, req)`
5. `if rr.Code != http.StatusOK { t.Fatalf("код: %d", rr.Code) }`
6. `}`

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

- **httptest.NewRequest(method, target, body)** — Входящая *http.Request без сокета: Proto всегда HTTP/1.1, RemoteAddr — «192.0.2.1:1234» (TEST-NET-1, RFC 5737). На ошибку в аргументах паникует — «для удобства в тестах».
- **httptest.NewRecorder()** — ResponseRecorder пишет статус, заголовки и тело в память. rr.Code ставится WriteHeader или неявно при Write; если хендлер не писал ничего — Code может быть 0, неявные 200 даёт rr.Result().StatusCode.
- **r.ServeHTTP(rr, req)** — chi.Router реализует http.Handler: тест прогоняет весь стек middleware и маршрутизацию без сети и портов, детерминированно и параллельно.
- **httptest.NewServer(r)** — Настоящий http.Server на 127.0.0.1:0 — случайный свободный порт. ts.URL и ts.Client(); Close блокируется до завершения незавершённых запросов. TLS — через NewTLSServer.
- **chi.URLParam при прямом вызове** — Вернёт пустую строку: RouteContext кладёт роутер при совпадении маршрута. Поэтому хендлер тестируют через ServeHTTP — тест ловит и опечатки в шаблонах вроде /notes/{it}.
- **Дефолтный 405** — methodNotAllowedHandler в chi v5.0.8: WriteHeader(405) и w.Write(nil) — пустое тело без Allow. Тест фиксирует контракт; свой формат — через r.MethodNotAllowed из урока 15.

HTTP-слой под тестами: хендлеры, маршруты, middleware и системные 404/405 проверяются без сети, а пара NewServer прикрыла протокольные сценарии. Живое место с пустым URLParam закрыто правилом «звать через роутер», а контракт ошибок из урока 15 теперь проверяется кодом: статус, Content-Type и error.code в одном тесте. Утверждения короче и говорливее, чем if-ы с t.Fatalf, делает [testify](/courses/testify/) — там же есть готовые assert.HTTPStatusCode и HTTPBody поверх того же httptest. Полная карта уроков — на [странице курса chi](/courses/chi/).

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

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