---
title: "Тестирование приложений: httptest, e.ServeHTTP и table-driven матрицы"
description: "тестирование echo: httptest.NewRequest и NewRecorder через e.ServeHTTP, e.NewContext без роутера, мок-хранилище, table-driven матрицы, границы Recorder."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/20-testirovanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Тестирование приложений: httptest, e.ServeHTTP и table-driven матрицы

*Курс «Echo: минималистичный быстрый фреймворк», урок 20 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/20-testirovanie/*

тестирование echo — урок, который канон заслужил двадцать раз. У приложения на :1323 есть маршруты, группы, конверт ошибок из [десятого урока](/courses/echo/lessons/10-httperror/), слои из [шестого](/courses/echo/lessons/06-middleware/), JWT-аутентификация из [пятнадцатого](/courses/echo/lessons/15-jwt/), SSE-эндпоинт из [девятнадцатого](/courses/echo/lessons/19-sse/) и ws-маршрут из восемнадцатого — но ни одного теста. Хорошая новость: для echo-приложения тесты пишутся без сети, портов и контейнеров. Запрос собирает httptest.NewRequest из net/http/httptest, ответ ловит httptest.NewRecorder, а e.ServeHTTP прогоняет запрос через весь стек — роутер, middleware, ErrorHandler — как это делает настоящий http.Server. Мы разберём оба уровня теста: хендлер напрямую через e.NewContext и приложение целиком через ServeHTTP, — соберём table-driven матрицу для JWT-гварда, заменим глобальное хранилище моком через интерфейс, поставим middleware под тест подменой next и честно обозначим границы Recorder: что он умеет (Flushed для SSE) и чего не умеет (Hijack для ws). Каждый факт сверен с исходниками: echo.go v4.15.0, recorder.go и httptest.go стандартной библиотеки — включая то, как echo проверяет сам себя: собственные тесты фреймворка зовут e.ServeHTTP(rec, req) с тем же рекордером. Приступаем — httptest echo превращает канон из демо в сопровождаемый проект.

### тестирование echo: два уровня и один рекордер

Тема «тестирование echo» начинается с выбора уровня, и он важнее выбора библиотеки. Первый уровень — тест хендлера или middleware напрямую: контекст собирается через e.NewContext(req, rec), функция вызывается руками, роутер и глобальные слои не участвуют. Это юнит-тест: быстрый, точечный, он проверяет логику функции, а не проводку. Второй уровень — тест всего стека: e.ServeHTTP(rec, req) прогоняет запрос как настоящий сервер — по пулу контекстов, через роутер, applyMiddleware с глобальными middleware и HTTPErrorHandler на исходящих ошибках. Это интеграционный тест вашего HTTP-слоя: он ловит то, что юнит-тест не увидит — забытый маршрут, неправильный порядок слоёв, отказ, который не дошёл до конверта {error, code}.

Оба уровня опираются на одну пару из net/http/httptest — пакета стандартной библиотеки, написанного именно для этого. httptest.NewRequest собирает входящий *http.Request без сокета; httptest.NewRecorder возвращает ResponseRecorder — http.ResponseWriter, который вместо отправки в сеть записывает статус, заголовки и тело в свои поля. Сигнатура ServeHTTP — ровно интерфейс http.Handler: func(w http.ResponseWriter, r *http.Request), поэтому Echo встаёт в httptest.NewServer и в любой тест без адаптеров. Полезная веха для доверия: echo проверяет сам себя тем же способом — в echo_test.go и json_test.go фреймворка стоят строки req := httptest.NewRequest(...) и e.ServeHTTP(rec, req), а ассерты сделаны через testify, который и в вашем go.mod появится рядом с тестами.

### httptest.NewRequest: запрос собирает стандартная библиотека

Запрос для теста — это не просто структура, а набор значений, на которые ваши хендлеры рассчитывают в проде. httptest.NewRequest(method, target, body) документирует их честно, и каждое стоит знать по имени. Target — «request-target» из RFC 7230: путь или абсолютный URL; если хоста в нём нет, Request.Host становится example.com. Протокол всегда HTTP/1.1 — даже если внутри NewRequest пишет в парсер строку с HTTP/1.0, чтобы не требовать Host, а потом чинит версию на внешнюю. Пустой method означает GET. Тело с типами *bytes.Buffer, *bytes.Reader и *strings.Reader получает корректный ContentLength; прочие reader-ы — -1 и заворачивание в io.NopCloser. Наконец, RemoteAddr — «192.0.2.1:1234»: адрес из диапазона TEST-NET, зарезервированного RFC 5737 специально для документации и примеров, чтобы тестовый запрос невозможно было спутать с настоящим.

Две детали, которые экономят часы. Первая: NewRequest паникует на невалидных аргументах — «NewRequest panics on error for ease of use in testing, where a panic is acceptable»; в тесте паника — это и есть провал, ошибки не нужны. Вторая: для target с https:// запрос получает фиктивное, но наполненное TLS.ConnectionState — Version TLS12 и HandshakeComplete true, — поэтому ветки вида c.Request().TLS != nil в тесте проверяются без сертификатов. Конструктора запросов у самого Echo нет — ни e.NewHTTPRequest, ни echo.NewRequest в исходниках v4.15.0 не существует: входящий запрос собирает стандартная библиотека, а Echo оборачивает его в контекст. Это осознанная граница: Echo — обработчик запросов, и его API начинается с уже готового *http.Request.

### Три строки юнит-теста: e.NewContext и хендлер руками

```go
// main_test.go — юнит-тест хендлера без роутера и middleware
func TestGetTask(t *testing.T) {
        e := echo.New()
        req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/42", nil)
        rec := httptest.NewRecorder()
        c := e.NewContext(req, rec)
        c.SetParamNames("id")
        c.SetParamValues("42")

        if assert.NoError(t, getTask(c)) {
                assert.Equal(t, http.StatusOK, rec.Code)
                var task task
                assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &task))
                assert.Equal(t, 42, task.ID)
        }
}
```

Канонический шаблон из документации Echo, собранный из четырёх ходов. e.NewContext(req, rec) возвращает контекст с вашими рекордером и запросом — по исходнику echo.go v4.15.0 это структура с store: make(Map) и хендлером по умолчанию NotFoundHandler: «NewContext returns a Context instance». c.SetParamNames/SetParamValues заполняют пул path-параметров руками — ровно то, что роутер делает на живом запросе через Find; без этих строк c.Param("id") вернёт пустую строку. Хендлер зовётся как обычная функция: getTask(c) — и возвращает error, который ассертится первым: возврат ошибки значением, на котором стоит весь курс, в тесте превращается в отдельную проверку. rec.Code и rec.Body читаются после вызова; для JSON-ответов удобнее unmarshal в структуру, чем сравнивать строку — тест перестаёт бояться перестановки полей.

### Проверьте себя: ошибка хендлера в юнит-тесте

**Вопрос:** Тест вызывает хендлер напрямую: c := e.NewContext(req, rec); err := deleteTask(c). Хендлер вернул echo.NewHTTPError(http.StatusNotFound, "task_not_found"). Что окажется в rec.Code?

- ✅ 200: ErrorHandler не вызывался — при прямом вызове хендлера ошибку обрабатывает тест, а не фреймворк
- 404: NewHTTPError сам записывает статус в рекордер
- 0: запись не происходила, поле Code осталось нулевым
- 500: необработанная ошибка всегда превращается в Internal Server Error

Разделение, на котором спотыкаются все, кто пришёл из тестов через ServeHTTP. e.NewHTTPError — это конструктор значения ошибки; писать ответ он не умеет. Запись в рекордер делает HTTPErrorHandler — и он вызывается только внутри e.ServeHTTP: «if err := h(c); err != nil { e.HTTPErrorHandler(err, c) }» по исходнику echo.go. При прямом вызове хендлера цепочка заканчивается на возврате err — тест обязан сам решить, что с ней делать: ассертнуть тип и код через echo.HTTPError или вызвать e.HTTPErrorHandler(err, c) руками. А 0 отпадает потому, что NewRecorder инициализирует Code значением 200 — «Code: 200» в recorder.go; ноль остаётся только у zero-value ResponseRecorder, которого NewRecorder не даёт.

### e.ServeHTTP: весь стек без сети

ServeHTTP — метод, через который Echo встаёт в http.Server, и в тесте он делает всё то же самое, минуя сокет. Исходник echo.go v4.15.0 стоит процитировать целиком: «c := e.pool.Get().(*context); c.Reset(r, w)» — контекст берётся из пула и привязывается к вашей паре запрос-рекордер; дальше «e.findRouter(r.Host).Find(r.Method, GetPath(r), c)» — роутер ищет узел и раскладывает параметры; «h = applyMiddleware(h, e.middleware...)» — найденный хендлер оборачивается всеми глобальными слоями; «if err := h(c); err != nil { e.HTTPErrorHandler(err, c) }» — исходящая ошибка попадает в ваш ErrorHandler с конвертом; наконец «e.pool.Put(c)» — контекст возвращается в пул. Здесь же живут premiddleware, если вы их завели. Для теста важно следствие: всё, что вы проверяли curl-ом в прошлых уроках — статусы, заголовки, конверты, Allow из 405, — воспроизводится вызовом e.ServeHTTP(rec, req) без порта и без запуска main.

Пул контекстов — та деталь, из-за которой тесты Echo дешёвые: pool.New создаёт контекст через e.NewContext(nil, nil), Reset перецепляет его на новую пару запрос-ответ, и на тысячу тестов приходятся тысячи переиспользований. Путь берётся функцией GetPath — RawPath, а если пусто, Path из URL — «GetPath returns RawPath, if it's empty returns Path from URL», так что проценты в адресе тестируются как на проде. Единственное, чего нет в этой цепочке, — сети: RemoteAddr возьмётся из TEST-NET адреса NewRequest, TLS будет nil или фиктивным, а соединение не потемнеет ни на байт — ровно то, что нужно детерминированному тесту.

### Канон: тест всего стека на /api/v1

```go
// main_test.go — интеграционный тест HTTP-слоя канона
func newTestEcho() *echo.Echo {
        e := echo.New()
        e.HTTPErrorHandler = apiErrorHandler(nil) // конверт {error, code} из урока 10
        v1 := e.Group("/api/v1", echojwt.WithConfig(jwtCfg()))
        v1.GET("/tasks", listTasks)
        v1.GET("/tasks/:id", getTask)
        return e
}

func TestStack(t *testing.T) {
        e := newTestEcho()
        req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
        req.Header.Set("Authorization", "Bearer "+makeAccess("admin"))
        rec := httptest.NewRecorder()

        e.ServeHTTP(rec, req)

        assert.Equal(t, http.StatusOK, rec.Code)
        assert.Equal(t, "application/json; charset=UTF-8",
                rec.Header().Get("Content-Type"))
        assert.Contains(t, rec.Body.String(), ""items"")
}

// как это делает сам echo (echo_test.go, v4.15.0):
//   req := httptest.NewRequest(...)
//   rec := httptest.NewRecorder()
//   e.ServeHTTP(rec, req)
```

newTestEcho — фабрика приложения для тестов: тот же каркас, что и main, но без e.Start и с подставляемыми зависимостями; она же будет нужна каждой матрице ниже. Запрос с валидным access-токеном проходит echojwt на группе — в тесте токен подписывается тем же секретом из конфига, без сети и без сервера. Проверки — по контракту из семнадцатого урока: статус, Content-Type от сериализатора, поле конверта списка в теле. Заметьте отсутствие reset-а между тестами: каждый тест собирает свежую пару req/rec — это не стилистика, а требование; ниже будет вопрос, что сломает переиспользование рекордера. Комментарий в конце — не украшение: именно эти три строки встречаются в echo_test.go, тесты фреймворка держатся на той же тройке, что и ваши.

### Прогноз: несуществующий маршрут через ServeHTTP

```go
// e := newTestEcho() — маршруты только на /api/v1/tasks*
req := httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// что в rec.Code и rec.Body?
```

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

- ✅ 404 и конверт {"error":...,"code":404}: роутер вернул ErrNotFound через ErrorHandler
- 404 и пустое тело: NotFoundHandler только возвращает ошибку, писать нечем
- 200 и пустое тело: запись не происходила, NewRecorder оставил дефолт
- 405: путь близок к /api/v1/tasks, echo угадывает ближайший маршрут

Если маршрута нет, Find оставляет в контексте хендлер по умолчанию — handler: NotFoundHandler из e.NewContext и Reset; он возвращает ErrNotFound, ошибку ловит строка h(c) → e.HTTPErrorHandler(err, c), и ваш apiErrorHandler рисует конверт {error, code} со статусом 404 — тот же, что клиент видит на живом сервере. Вариант с пустым телом был бы правдой, если бы ErrorHandler был дефолтным и маршрут лежал вне /api — но в каноне конверт для API-путей обязателен. 405 здесь нет: для него нужен совпавший путь с другим методом; echo не ищет «ближайший» маршрут. Этот тест — прямой потомок десятого урока: необработанные ошибки тоже часть контракта, и теперь они под тестом.

### Table-driven: матрица вместо кучи тестов

Тест на каждый кейс — это десять функций с копипастой и усталость от добавления одиннадцатого. Идиома table-driven test, которой пропитана стандартная библиотека и собственные тесты Echo — router_test.go держит сотни кейсов в срезах структур, — собирает кейсы в таблицу и гонит её одним циклом. Форма: срез анонимных структур {name, входы, ожидания}, цикл t.Run(tc.name, func(t *testing.T){...}) на каждый кейс. t.Run даёт каждому кейсу имя в отчёте — TestStack/нет_заголовка — и точечный запуск: go test -run TestStack/нет_заголовка гоняет один кейс из матрицы, что экономит минуты на большом приложении.

Три правила дисциплины. Первое: свежая пара req/rec на кейс внутри цикла — рекордер копит тело и статус, и второй кейс на первом рекордере увидит чужой ответ; это не оптимизация, а корректность. Второе: сообщения ассертов содержат кейс — assert.Equal(t, tc.want, rec.Code, tc.name): упавший тест должен называть сценарий, а не строку в таблице. Третье: t.Parallel() внутри subtests включайте, только когда кейсы не делят состояние — и таблица, и приложение под тестом должны переживать параллельность; у Echo сам e потокобезопасен после конфигурации, но ваш мок-хранилище и replay-карта из семнадцатого урока — нет, у них свои мьютексы.

### Канон: table-driven матрица для /api/v1 под JWT

```go
// main_test.go — матрица отказов и успехов echojwt из пятнадцатого урока
func TestAuthMatrix(t *testing.T) {
        e := newTestEcho()

        cases := []struct {
                name  string
                token string // готовый заголовок Authorization
                want  int
        }{
                {"нет заголовка", "", http.StatusBadRequest},
                {"мусор вместо токена", "Bearer garbage", http.StatusUnauthorized},
                {"валидный access admin", "Bearer " + makeAccess("admin"), http.StatusOK},
        }
        for _, tc := range cases {
                t.Run(tc.name, func(t *testing.T) {
                        req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
                        if tc.token != "" {
                                req.Header.Set("Authorization", tc.token)
                        }
                        rec := httptest.NewRecorder()
                        e.ServeHTTP(rec, req)
                        assert.Equal(t, tc.want, rec.Code, tc.name)
                })
        }
}

// makeAccess строит подписанный токен тем же секретом и клеймами,
// что issuePair из канона: role, typ access
```

Матрица воспроизводит контракт echo-jwt из пятнадцатого урока и держит его под страхом теста: ничего не извлекли — 400 «missing or malformed jwt» (та ветка lastTokenErr == nil из v4.3.1); токен отвергнут — 401 «invalid or expired jwt»; валидный access — 200. Токен строится той же библиотекой golang-jwt и тем же секретом, что и на выдаче: тест подделывает только время, не криптографию. Каждая строка таблицы — fresh req/rec внутри t.Run, как договаривались. Расширять матрицу — механически: строка за строкой ложатся 403 от requireRole (токен с role user на DELETE), просроченный exp (тот же makeAccess с exp в прошлом), подмена алгоритма с ожиданием «unexpected jwt signing method» — и ни один из этих кейсов не потребует сети, порта или сервера echo-jwt.

### Проверьте себя: один рекордер на два кейса

**Вопрос:** В table-driven цикле рекордер создан до цикла, и оба кейса пишут в него. Что увидит второй кейс?

- ✅ Свои 200 байт поверх чужих: Body — буфер, записи дописываются, статус останется от того WriteHeader, который случился первым
- Пустое тело: Recorder сбрасывает Body при каждом WriteHeader
- Панику: повторный вызов ServeHTTP на том же рекордере запрещён
- Всё верно: каждый WriteHeader перезаписывает Code, тело тоже затирается

ResponseRecorder не сбрасывается сам: Body — это new(bytes.Buffer), и Write дописывает в конец; WriteHeader срабатывает один раз — «if rw.wroteHeader { return }» — так что статус второго ответа не перезапишет первый, а snapHeader уже снепшотнут при первой записи. Второй кейс ассертит смесь: чужое тело и чужой или пустой статус. Именно поэтому канон цикла — httptest.NewRecorder() внутри t.Run: три строки, ноль сюрпризов. Result() это тоже учитывает: «Result must only be called after the handler has finished running» — он отдаёт снепшот на момент вызова, а не магическое «текущее состояние».

### Мок-зависимости: store как интерфейс, а не глобальная карта

Тест всего стека упирается в вопрос: чем заменить хранилище? Глобальная переменная var store = map[int]task{...}, которой канон жил до сих пор, в тестах — мина: она переживает кейсы, связывает их порядком и не даёт повторить сценарий «хранилище упало». Решение старое, как Go: сузить зависимость до интерфейса и инъектировать реализацию. type TaskStore interface { List(offset, limit int) ([]task, int); Create(title string) task; Delete(id int) error } — и хендлеры получают его не из пакета, а из фабрики: makeTasksHandler(s TaskStore) echo.HandlerFunc. main собирает сервер с настоящим хранилищем, тесты — с fake-хранилищем на карте под мьютексом. Это юнит-принцип «мок-зависимости вместо глобальных задач», сформулированный ещё в анонсе девятнадцатого урока.

Fake — не заглушка, а вторая реализация контракта. У неё есть смысл проверять сценарии, которых настоящий store в тесте не даст: Delete возвращает ошибку — хендлер обязан отдать 404, а не запаниковать; Create после полного лимита — 409 или 422 по вашему контракту. Удобный приём — фабрика хендлеров + фабрика приложения: newTestEcho(s TaskStore) собирает маршруты с хендлерами от этого store, и каждый тест получает своё приложение с собственным состоянием. Полная версия этого трюка — слои и инъекция зависимостей по-взрослому — тема двадцать первого урока; здесь достаточно интерфейса, чтобы тесты перестали знать о существовании настоящего хранилища.

### Канон: fake-хранилище и инъекция

```go
// store.go — контракт, который хендлеры видят вместо глобальной карты
type TaskStore interface {
        Create(title string) task
        Delete(id int) error
}

// main.go — фабрика хендлера: store приезжает снаружи
func makeDeleteTask(s TaskStore) echo.HandlerFunc {
        return func(c echo.Context) error {
                id, err := strconv.Atoi(c.Param("id"))
                if err != nil {
                        return echo.NewHTTPError(http.StatusBadRequest, "id: только число")
                }
                if err := s.Delete(id); err != nil {
                        return echo.NewHTTPError(http.StatusNotFound, "task_not_found")
                }
                return c.NoContent(http.StatusNoContent)
        }
}

// main_test.go — fake как вторая реализация контракта
type fakeStore struct {
        mu      sync.Mutex
        created map[int]task
        err     error // что вернуть из Delete: nil или scripted failure
}

func (f *fakeStore) Create(title string) task { /* + мьютекс */ }
func (f *fakeStore) Delete(id int) error      { return f.err }

func TestDeleteTask(t *testing.T) {
        s := &fakeStore{created: map[int]task{}} // fresh fake на каждый тест
        e := echo.New()
        c := e.NewContext(
                httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/42", nil),
                httptest.NewRecorder())
        c.SetParamNames("id")
        c.SetParamValues("42")

        assert.NoError(t, makeDeleteTask(s)(c))
        s.err = errors.New("db down")
        err := makeDeleteTask(s)(c)
        he := err.(*echo.HTTPError)
        assert.Equal(t, http.StatusNotFound, he.Code)
}
```

Три слоя решения. Интерфейс TaskStore сужает хендлеру ровно те методы, которые он зовёт, — List в каноне опущен для краткости, принцип тот же. Фабрика makeDeleteTask замыкает store: в main регистрируется makeDeleteTask(realStore), в тесте — makeDeleteTask(fake); хендлер не изменился ни на строку, а тест получил управляющую ручку err для сценария отказа — scripted failure, второй половины матрицы. Fake хранит карту под sync.Mutex: тесты в одном пакете бегут последовательно, но t.Parallel — вопрос времени, и готовый мьютекс дешевле расследования гонки. Наконец, fresh fake на каждый тест: s := &fakeStore{...} внутри TestDeleteTask, не пакетная переменная — следующий predict объясняет, что ломает общий fake.

### Прогноз: пакетный fakeStore на два теста

```go
var shared = &fakeStore{created: map[int]task{}} // пакетная переменная

func TestA(t *testing.T) { shared.Create("первая"); /* ассерты TestA */ }
func TestB(t *testing.T) { assert.Equal(t, 0, len(shared.created)) }

// что произойдёт?
```

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

- ✅ TestB увидит запись TestA: состояние общее, тесты сцеплены порядком исполнения
- TestB пройдёт: каждый тест получает копию карты из пакетной переменной
- Компилятор откажет: пакетные моки запрещены vet-ом
- Тесты паникуют: map не переживёт два теста

Пакетная переменная — одна на процесс тестирования, и TestA оставит в ней свою запись: len(shared.created) в TestB равен 1, ассерт падает. Хуже, что падение зависит от порядка: go test исполняет тесты в порядке объявления, и TestB зелёный, пока TestA идёт раньше; переименование, перенос или t.Parallel() меняют картину — классический flaky, который ловится на CI раз в неделю и не воспроизводится локально. Компилятор и vet здесь молчат: гонка не в синтаксисе, а в дизайне. Лекарство — fresh fake на тест (newTestEcho с собственным store), а если состояние обязано быть общим — явная синхронизация и сброс в t.Cleanup. Правило простое: тест не должен замечать соседей.

### Тесты middleware: подмена next и подсевание контекста

Middleware тестируется той же парой e.NewContext/рекордер, но с двумя приёмами поверх. Первый — подмена next: слой — это func(next echo.HandlerFunc) echo.HandlerFunc, поэтому в тесте next — ваш собственный флажок: called := false; h := mw(func(c echo.Context) error { called = true; return c.String(http.StatusOK, "ok") }). Вызов h(c) говорит всё о слое: вызван ли next, что слой успел сделать до и после, какой ответ дойдёт до клиента при отказе слоя. Для гвардов это главный ассерт: требуемый токен — called == true, чужой — 401 и called остался false, потому что до next дело не дошло.

Второй приём — подсевание контекста. Гвард requireRole из пятнадцатого урока читает c.Get("user") — туда кладёт echo-jwt. Тестировать гварда вместе с echo-jwt значит тащить в юнит-тест всю аутентификацию; вместо этого тест подсевает c.Set("user", token) руками — token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"role": "admin"}), — и гвард исполняется в изоляции. Контракт c.Set/c.Get из восьмого урока делает это легальным: слой не знает, кто положил значение, ему важен ключ. Сквозная логика слоя — Skipper из седьмого урока, порядок слоёв из шестого — проверяется отдельно: Skipper зовётся с контекстом, и таблица путей в тесте скажет, на каких маршрутах слой спит.

### Канон: гвард requireRole без сети и без echo-jwt

```go
// main_test.go — юнит-тест гварда из пятнадцатого урока
func TestRequireRole(t *testing.T) {
        cases := []struct {
                name    string
                role    string // клейм role в подсевном токене
                wantErr int    // 0 — ошибки нет, next вызван
        }{
                {"admin проходит", "admin", 0},
                {"user получает 403", "user", http.StatusForbidden},
                {"без роли — 403", "", http.StatusForbidden},
        }
        for _, tc := range cases {
                t.Run(tc.name, func(t *testing.T) {
                        e := echo.New()
                        c := e.NewContext(
                                httptest.NewRequest(http.MethodDelete, "/x", nil),
                                httptest.NewRecorder())

                        token := jwt.NewWithClaims(jwt.SigningMethodHS256,
                                jwt.MapClaims{"role": tc.role})
                        c.Set("user", token) // то, что на проде кладёт echo-jwt

                        called := false
                        h := requireRole("admin")(
                                func(c echo.Context) error { called = true; return nil })

                        err := h(c)
                        if tc.wantErr == 0 {
                                assert.NoError(t, err)
                                assert.True(t, called)
                        } else {
                                he := err.(*echo.HTTPError)
                                assert.Equal(t, tc.wantErr, he.Code)
                                assert.False(t, called) // next не дошёл
                        }
                })
        }
}
```

Оба приёма в одном тесте. Подсевание: c.Set("user", token) заменяет echo-jwt — гвард читает *jwt.Token, достаёт MapClaims и не знает, что аутентификации не было; ассершн token.Claims.(jwt.MapClaims) из канона срабатывает, потому что тест подсевает ровно тот тип. Подмена next: called — единственный способ увидеть, дошло ли исполнение до хендлера; 403 без called — гвард отработал как замок, 200 без called был бы признаком гварда-бумажки. Матрица покрывает три судьбы роли: совпала, не совпала, отсутствует — пустая строка в ассершне claims["role"].(string) даст ноль и false через запятую, ровно как в проде. Когда этот юнит зелёный, echojwt-матрица из предыдущего блока проверяет проводку, а не логику — слои тестов делят ответственность, а не дублируют её.

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

Восстановите каноническую тройку httptest-echo: пропуски по порядку.

```go
func TestCreateTask(t *testing.T) {
        e := echo.New()
        body := strings.NewReader("{\"title\":\"купить кофе\"}")
        req := httptest.___(http.MethodPost, "/api/v1/tasks", body)
        rec := httptest.___()
        c := e.___(req, rec)
        if assert.NoError(t, createTask(c)) {
                assert.Equal(t, http.___, rec.Code)
                assert.Contains(t, rec.___, "купить кофе")
        }
}
```

Подсказка: Запрос строит httptest, контекст — Echo, ответ читают из рекордера; создание — 201.

Правильные ответы в пропусках: `NewRequest`, `NewRecorder`, `NewContext`, `StatusCreated`, `Body.String()`

### Соберите путь тестового запроса через e.ServeHTTP

Расставьте шаги от сборки запроса до возврата контекста в пул.

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

1. `httptest.NewRequest собирает *http.Request: Host example.com, RemoteAddr 192.0.2.1:1234, Proto HTTP/1.1`
2. `httptest.NewRecorder возвращает ResponseRecorder: Code 200, пустой bytes.Buffer, Flushed false`
3. `e.ServeHTTP достаёт контекст из пула: c := e.pool.Get().(*context)`
4. `c.Reset(r, w) перецепляет пару: хендлер по умолчанию NotFoundHandler, store обнулён`
5. `findRouter(r.Host).Find(r.Method, GetPath(r), c) ищет узел и раскладывает параметры`
6. `applyMiddleware оборачивает найденный хендлер всеми глобальными слоями`
7. `h(c) исполняется: слои, хендлер, сериализация в рекордер`
8. `исходящая ошибка уходит в e.HTTPErrorHandler: конверт {error, code}`
9. `e.pool.Put(c) возвращает контекст в пул до следующего теста`

### Проверьте себя: SSE и WebSocket в тестах

**Вопрос:** Канон имеет SSE-эндпоинт /api/v1/events из девятнадцатого урока и ws-маршрут /api/v1/ws из восемнадцатого. Что с их тестами?

- ✅ SSE тестируется через Recorder: его Flush ставит Flushed; для ws нужен httptest.NewServer — Recorder не умеет Hijack
- Оба тестируются через Recorder: Flushed хватает и для WebSocket
- Оба требуют httptest.NewServer: Recorder не умеет ни Flush, ни Hijack
- Ни один не тестируется: стриминг всегда требует сети

Рекордер реализует http.Flusher: «Flush implements [http.Flusher]. To test whether Flush was called, see rw.Flushed» — SSE-хендлер из c.Stream и ручного Flush оставит Flushed == true, и тест проверит и статус, и тело до флаша, и сам факт флаша. Hijack — другая история: ResponseRecorder не реализует http.Hijacker, а Response.Hijack в echo идёт через http.NewResponseController(r.Writer), который разворачивает обёртки и на Recorder возвращает errNotSupported. Апгрейд WebSocket обязан забрать соединение — значит нужен настоящий коннект: httptest.NewServer(e) поднимет Echo на реальном слушателе 127.0.0.1:0, а gorilla Dialer подключится по URL сервера; сценарий shutdown-а из восемнадцатого урока закрывается закрытием сервера.

### Границы Recorder: что тест не увидит

Честные границы — половина тестовой дисциплины. ResponseRecorder не симулирует транспорт: нет таймаутов и обрывов соединения, нет реального chunked-кодирования — Writer просто пишет в буфер, — нет HTTP/2 и сжатия на уровне сервера. Ветку «клиент умер посреди стрима» этим инструментом не построишь: для неё нужен httptest.NewServer с настоящим клиентом, который закрывает соединение, — и тесты Echo так и делают там, где сырому Recorder тесно. Вторая граница — Result(): «Result must only be called after the handler has finished running» — он отдаёт снепшот на момент вызова; читать его из горутины параллельно хендлеру — гонка, которую vet подсветит, а тест всё равно иногда пройдёт.

Третья граница — числа. Код статуса Recorder проверяет только на три цифры: «checkWriteHeaderCode: if code < 100 || code > 999 { panic(fmt.Sprintf("invalid WriteHeader code %v", code)) }» — «For now we only enforce that it's three digits». Content-Type при первом Write подбирается http.DetectContentType, если вы его не выставили — снэппинг живого сервера, удобно и для тестов. И HeaderMap помечен как Deprecated: «To access the headers returned by a handler, use the Response.Header map as returned by the Result method» — читайте заголовки после хендлера через rec.Result().Header, а не по живой карте, которую хендлер ещё правит. Знание границ превращает «тест не проходит» в «тест инструменту не по зубам» — и это два разных исправления.

> **Что тест не заменит.** e.ServeHTTP проверяет логику и проводку вашего HTTP-слоя, но не транспорт: таймауты, лимиты соединений, TLS и поведение reverse proxy останутся за кадром Recorder-а. Держите три яруса: юнит-тесты хендлеров и middleware на e.NewContext — тысячи и миллисекунды; стек-тесты через e.ServeHTTP — сотни и десятки миллисекунд; горстка httptest.NewServer-тестов для ws и обрывов соединения — единицы и секунды. Если весь этот вес давит на один ярус, тесты замедляются и перестают запускаться — а тест, который не запускают, хуже отсутствующего.

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

- **httptest.NewRequest: пять фактов** — Target — request-target RFC 7230, Host example.com без хоста; Proto всегда HTTP/1.1; пустой method = GET; RemoteAddr 192.0.2.1:1234 (TEST-NET, RFC 5737); паникует на невалидных аргументах («panics on error for ease of use in testing»). https-target даёт фиктивный TLS: VersionTLS12, HandshakeComplete true.
- **httptest.NewRecorder: поля** — Code — 200 из NewRecorder (ноль — только у zero-value), Body *bytes.Buffer (Write дописывает), Flushed — ставит Flush, HeaderMap Deprecated — читать через rec.Result().Header. Первый Write подбирает Content-Type через http.DetectContentType. WriteHeader срабатывает один раз; код вне 100..999 — паника «invalid WriteHeader code %v».
- **e.NewContext против e.ServeHTTP** — NewContext — юнит: контекст с req/rec, handler по умолчанию NotFoundHandler, store make(Map); хендлер зовётся руками, ErrorHandler не участвует — ошибка возвращается тесту. ServeHTTP — стек: pool.Get → Reset → findRouter(r.Host).Find(r.Method, GetPath(r)) → applyMiddleware → h(c) → HTTPErrorHandler на ошибке → pool.Put(c).
- **Table-driven: три правила** — Свежая пара req/rec на кейс внутри t.Run (Body дописывается, WriteHeader одноразовый); имена кейсов в ассертах и точечный запуск go test -run TestX/имя; t.Parallel() только при отсутствии общего состояния — Go исполняет тесты в порядке объявления, и общий fake делает порядок критичным (flaky).
- **Мок-зависимость: интерфейс + фабрика** — type TaskStore interface {Create; Delete} — хендлеры от фабрики makeDeleteTask(s TaskStore) echo.HandlerFunc; main инъектирует реальный store, тесты — fake на карте под мьютексом со сценарными ошибками (s.err = errors.New("db down") → 404). Fresh fake на каждый тест; общий пакетный — сцепляет тесты порядком.
- **Границы: Flushed да, Hijack нет** — Recorder реализует http.Flusher → Flushed == true после Flush (SSE тестируется напрямую). Hijack: Response.Hijack идёт через http.NewResponseController(r.Writer) → Recorder без Hijack/Unwrap → errNotSupported. WebSocket — httptest.NewServer(e) на 127.0.0.1:0 + gorilla Dialer. Нет в Recorder: таймауты, обрывы, настоящий chunked, HTTP/2.

### Что дальше: слои и шаблон проекта

У канона появился страховочный трос. Юнит-уровень: e.NewContext с парой из httptest — хендлеры и гварды проверяются без роутера, подсевание c.Set заменяет echo-jwt, а подмена next показывает, дошло ли исполнение. Стек-уровень: e.ServeHTTP гоняет запрос через пул, роутер, applyMiddleware и ErrorHandler — table-driven матрица держит контракт JWT-отказов 400/401/403, а конверт ошибок из десятого урока теперь закреплён тестом. Мок-зависимости: TaskStore как интерфейс и фабрика хендлеров разорвали связь с глобальной картой — fresh fake на тест, сценарные отказы ручкой err. Границы честные: Flushed для SSE проверяется рекордером, Hijack для ws — только настоящим сервером. Каждый факт сверен с исходниками: echo.go и router_test.go v4.15.0, recorder.go и httptest.go стандартной библиотеки.

Про песочницу честно, как во всём курсе: у курса Echo её нет — код исполняется локально, поэтому runner-ов здесь не будет; соберите main_test.go из канона и запустите go test ./... -v: юниты летят за миллисекунды, матрица auth — за десятки, а единственный тест, потребовавший сети, — ws — попросит себе httptest.NewServer.

Следующий, двадцать первый урок курса echo — Структура проекта: слои, инъекция зависимостей, шаблон проекта. Фабрика makeTasksHandler уже намекнула: store и хендлеры разъедутся по слоям, newTestEcho обрастёт конструктором, а тесты лягут рядом с кодом. Навигация по курсу — на [странице курса Echo](/courses/echo/); цепочка слоёв и c.Set/c.Get, на которых стоят подсевание и подмена next, — в [уроке про middleware](/courses/echo/lessons/06-middleware/); конверт {error, code}, закреплённый стек-тестом, — в [уроке про HTTPError](/courses/echo/lessons/10-httperror/); матрица echojwt 400/401/403 — в [уроке про JWT](/courses/echo/lessons/15-jwt/); Flushed и границы SSE — в [уроке про Server-Sent Events](/courses/echo/lessons/19-sse/).

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

- Два уровня теста: юнит — e.NewContext(req, rec) и хендлер руками (роутер и ErrorHandler не участвуют, ошибка возвращается тесту); стек — e.ServeHTTP(rec, req): pool.Get → Reset → findRouter.Find → applyMiddleware → h(c) → HTTPErrorHandler на ошибке → pool.Put. Сигнатура ServeHTTP — это http.Handler, Echo встаёт в httptest.NewServer без адаптеров; сам echo тестируется тем же рекордером (echo_test.go).
- httptest.NewRequest: target — request-target RFC 7230, Host example.com без хоста, Proto всегда HTTP/1.1, пустой method = GET, RemoteAddr 192.0.2.1:1234 (TEST-NET RFC 5737), ContentLength корректен для *bytes.Buffer/*bytes.Reader/*strings.Reader, паника на невалидных аргументах, https-target даёт фиктивный TLS ConnectionState. Конструктора запросов в Echo нет — запрос собирает стандартная библиотека.
- httptest.NewRecorder: Code инициализирован 200 (ноль — у zero-value), Body дописывается (Write в конец буфера), Flushed ставит Flush, HeaderMap Deprecated — читать через rec.Result().Header; Result() только после завершения хендлера; первый Write подбирает Content-Type через http.DetectContentType; WriteHeader одноразовый (wroteHeader), код вне 100..999 — паника «invalid WriteHeader code %v».
- Table-driven: срез структур + t.Run; свежая пара req/rec на кейс (буфер дописывает, WriteHeader одноразовый), имена кейсов в ассертах, go test -run TestX/имя для точечного запуска; t.Parallel() только без общего состояния — go test исполняет тесты в порядке объявления, общий пакетный fake делает тесты сцепленными (flaky).
- Мок-зависимости: интерфейс TaskStore + фабрика makeDeleteTask(s TaskStore) echo.HandlerFunc — main инъектирует реальное хранилище, тесты — fake на карте под мьютексом; сценарные отказы — ручка err → 404; fresh fake на каждый тест, общий — сцепляет порядок. Подсевание контекста: c.Set("user", jwt.NewWithClaims(...MapClaims{"role": ...})) заменяет echo-jwt в юнит-тесте гварда; подмена next с флажком called показывает, дошло ли исполнение.
- Стриминг в тестах: Recorder реализует http.Flusher — Flushed == true после Flush, SSE из c.Stream проверяется напрямую; Hijack через http.NewResponseController на Recorder даёт errNotSupported — ws требует httptest.NewServer(e) на 127.0.0.1:0 и gorilla Dialer. За кадром Recorder: таймауты, обрывы, настоящий chunked, HTTP/2.
- Коды статусов Recorder проверяет только на три цифры: checkWriteHeaderCode паникует вне 100..999 («For now we only enforce that it's three digits»); jwt-матрица канона: нет заголовка → 400 «missing or malformed jwt», мусор → 401 «invalid or expired jwt», валидный access → 200, чужая роль на DELETE → 403 от requireRole — контракт echo-jwt v4.3.1 из пятнадцатого урока под тестом.

## Домашнее задание

Соберите main_test.go поверх канона: newTestEcho с echojwt на /api/v1 и make-фабриками хендлеров с инъекцией TaskStore. Первое: напишите юнит-тест getTask через e.NewContext с SetParamNames/SetParamValues и добавьте второй кейс с нечисловым id — ассертните тип *echo.HTTPError и код 400. Второе: соберите table-driven матрицу TestAuthMatrix из канона и расширьте тремя кейсами: access с role user на DELETE (403 «нужна роль admin»), просроченный exp (401, в Internal ErrorHandler — «token is expired»), токен, подписанный чужим секретом (401); токены строит makeAccess с параметрами. Третье: поднимите тест requireRole из канона и добавьте четвёртый кейс — клейм role не строка (число): проверьте, что ассершн через запятую даст 403, а не панику, и объясните, почему тест обязан это покрывать. Четвёртое: замените глобальный store на TaskStore + fake во всех хендлерах канона; напишите тест сценария «store.Delete вернул ошибку» — хендлер обязан отдать 404 task_not_found, а не 500. Пятое: напишите тест SSE-хендлера из девятнадцатого урока: запрос через e.ServeHTTP, ассерт Flushed == true и Content-Type text/event-stream; объясните по исходнику response.go, почему Flush на Recorder не паникует, хотя panic-ветка там есть. Шестое: поднимите httptest.NewServer(e) и подключитесь gorilla Dialer-ом к wsURL из server.URL — отправьте кадр, получите эхо, закройте соединение и убедитесь, что Shutdown сервера не повисает.

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

### Какой уровень теста выбрать: e.NewContext на хендлер или e.ServeHTTP на приложение?

Оба, но с разным весом. Юнит на e.NewContext — для логики хендлера и middleware: быстрый, точечный, проверяет возврат ошибки значением и работу с контекстом; здесь же тестируются гварды с подсеванием контекста. Стек на e.ServeHTTP — для проводки: маршруты, порядок слоёв, статус-коды, конверты ошибок, заголовки. Практическая пропорция: юнитов в разы больше, стек-тестов — по одному на контракт ресурса (матрица из канона). Юнит не увидит забытый маршрут, стек не локализует баг в хендлере — они дополняют друг друга, а не дублируют.

### Хендлер возвращает ошибку, а тест через e.ServeHTTP получает 200 — где ошибка?

Скорее всего, ошибка поглощена в вашей цепочке: какой-то middleware вызвал next(c), но не вернул её результат — return nil вместо return err; цепочка исполнилась, ErrorHandler не вызван, запись в ответ не случилась, и Recorder показал дефолтный 200. Вторая типичная причина — ответ уже записан: хендлер успел сделать c.JSON(200, ...) до обнаружения проблемы, Committed отрезал вторую запись. Проверяйте оба места: слой между роутером и хендлером — на честный return err, хендлер — на отсутствие записи до валидации. Тест через e.ServeHTTP с ассертом на 404 ловит обе ситуации на ура.

### Как тестировать middleware, которому нужен результат echo-jwt в контексте?

Не тяните echo-jwt в юнит-тест: подсевайте контекст — c.Set("user", token) с token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{...}). Слой читает c.Get("user") и не знает, кто положил значение — это контракт c.Set/c.Get из восьмого урока. Ключ должен совпадать с ContextKey конфига echojwt (дефолт "user"), тип — *jwt.Token, иначе ассершн гварда вернёт false и тест будет проверять не логику, а опечатку. Провязку настоящего echo-jwt с вашим гвардом проверьте отдельным стек-тестом из table-driven матрицы — там токен подписывается по-настоящему, и это уже интеграционная ответственность.
