---
title: "Тестирование HTTP-хендлеров с testify"
description: "Тестирование http go-хендлеров с httptest и testify: NewRecorder и NewRequest без сети, проверки Code, Body, заголовков, JSON-ответы и middleware."
course: "Testify: тестирование Go с assert и require"
url: "https://golangmaster.ru/courses/testify/lessons/19-http-testy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Testify: тестирование Go с assert и require», урок 19 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/testify/lessons/19-http-testy/*

Тестирование HTTP go-хендлеров обычно тонет в церемониях: поднять сервер, выбрать порт, дождаться готовности. В уроке обходимся без сети: httptest.NewRequest собирает запрос, httptest.NewRecorder подставляет ResponseWriter, хендлер вызывается напрямую, а assert проверяет статус, тело и заголовки. Дальше — JSON-ответы, тест 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](/courses/testify/lessons/03-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)?

- Пустую строку: хост приходит только из сети
- ✅ example.com — значение, которое подставляет httptest
- localhost:8080
- Панику: хост обязателен в target

Target — просто путь, хоста в нём нет, и httptest подставляет example.com. Паника случается по другому поводу: неразбираемый target или недопустимый метод. RemoteAddr при этом всегда 192.0.2.1:1234 — тестовый адрес из RFC 5737, который ничего не резолвит.

### Тестирование 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 на каждый аспект читается лучше одного гигантского сравнения, а если кейсов много — раскладывайте их в [табличные тесты](/courses/testify/lessons/13-tablichnye-testy/): один каркас с 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, если хендлер стримил |

> **Тест зелёный, а Content-Type забыли.** История с продакшена: хендлер писал 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 по полям — сравнение структур после распаковки работает так же, как в [уроке про сравнение структур](/courses/testify/lessons/05-sravnenie-struktur/). Имена полей подбираются без тегов и без учёта регистра: поля 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, как и все тестовые пакеты курса.

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

```go
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)
}
```

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

- Тест зелёный: WriteHeader не вызывался, Code остался нетронутым
- ✅ Тест красный: первый Write неявно выставил 200, а ждём 418
- Тест красный: паника — пустой метод в NewRequest запрещён
- Тест зелёный: recorder подставит 418 автоматически

Пустой метод легален: NewRequest считает его GET. Хендлер не вызывал WriteHeader, но первый Write сам проводит заголовки — recorder выставляет Code 200, как сделал бы настоящий сервер. Ожидание 418 не сходится, тест красный, и это правильный расклад: хендлер, который обязан отвечать 418, вызывает WriteHeader(http.StatusTeapot) сам.

### Тест 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") после завершения хендлера?

- application/json — recorder распознаёт JSON
- ✅ text/plain; charset=utf-8 — сработал http.DetectContentType, как на настоящем сервере
- Пустую строку — recorder не выставляет заголовки сам
- Заголовок появится только если хендлер вызвал WriteHeader

Recorder повторяет поведение настоящего сервера: при первом Write без Content-Type он прогоняет начало тела через http.DetectContentType, а тот для JSON возвращает text/plain; charset=utf-8. Именно поэтому assert на заголовок ловит пропущенный Set ещё до прода. Честное application/json появляется, только если хендлер выставил заголовок до первого Write.

> **Спор: тесты без сервера против интеграционных.** Спор возникает на каждом ревью: прямые вызовы хендлера против интеграционных через 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 и сверка статуса.

```go
func TestHealth(t *testing.T) {
        req := httptest.___("GET", "/health", nil)
        rr := httptest.____()

        Health(rr, req)

        assert.Equal(t, http.StatusOK, rr.___)
}
```

Подсказка: Запрос собирает NewRequest, ответ пишет NewRecorder, статус живёт в поле Code.

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

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

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

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

1. `req := httptest.NewRequest("GET", "/health", nil)`
2. `rr := httptest.NewRecorder()`
3. `handler := http.HandlerFunc(Health)`
4. `handler(rr, req)`
5. `assert.Equal(t, http.StatusOK, rr.Code)`
6. `assert.Equal(t, "ok", rr.Body.String())`

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

- **httptest.NewRecorder()** — Возвращает *httptest.ResponseRecorder — реализацию http.ResponseWriter, которая записывает Code, тело и заголовки в поля вместо сети. Стартует с Code 200.
- **httptest.NewRequest(method, target, body)** — Входящий серверный запрос без сети: пустой метод — GET, target — путь или URL, неверные аргументы — паника. RemoteAddr — 192.0.2.1:1234.
- **rr.Body** — *bytes.Buffer с тем, что хендлер отправил в Write: читайте через rr.Body.String() или rr.Body.Bytes() для json.Unmarshal.
- **rr.Header().Get(name)** — Карта заголовков, которую мутировал хендлер; имена канонические, регистр не важен. Для снимка на момент первого Write — rr.Result().Header.
- **rr.Result()** — *http.Response: StatusCode, снимок заголовков, тело как ReadCloser. Вызывается после завершения хендлера; DeepEqual по нему авторы не советуют.
- **rr.Flushed** — True, если хендлер вызывал Flush: проверка стриминга без сети и без портов.

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

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

- Хендлер — обычная функция: вызывайте её с 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 закрывает эту зону — поднятие сервера в тесте занимает микросекунды, порты выбираются автоматически. Всё остальное покрытие оставляйте прямым вызовам: они быстрее и точнее в диагностике.
