Содержание урока · 11 разделов
- тестирование echo: два уровня и один рекордер
- httptest.NewRequest: запрос собирает стандартная библиотека
- e.ServeHTTP: весь стек без сети
- Table-driven: матрица вместо кучи тестов
- Мок-зависимости: store как интерфейс, а не глобальная карта
- Тесты middleware: подмена next и подсевание контекста
- Границы Recorder: что тест не увидит
- Что дальше: слои и шаблон проекта
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
тестирование 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?
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
Прочитайте код и выберите, что он выведет:
// 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?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 цикле рекордер создан до цикла, и оба кейса пишут в него. Что увидит второй кейс?
Мок-зависимости: 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 на два теста
Прочитайте код и выберите, что он выведет:
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)) }
// что произойдёт?Тесты 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: пропуски по порядку.
Соберите путь тестового запроса через e.ServeHTTP
Расставьте шаги от сборки запроса до возврата контекста в пул.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Проверьте себя: SSE и WebSocket в тестах
Канон имеет SSE-эндпоинт /api/v1/events из девятнадцатого урока и ws-маршрут /api/v1/ws из восемнадцатого. Что с их тестами?
Границы 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
Что дальше: слои и шаблон проекта#
У канона появился страховочный трос. Юнит-уровень: 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; цепочка слоёв и c.Set/c.Get, на которых стоят подсевание и подмена next, — в уроке про middleware; конверт {error, code}, закреплённый стек-тестом, — в уроке про HTTPError; матрица echojwt 400/401/403 — в уроке про JWT; Flushed и границы SSE — в уроке про Server-Sent Events.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Два уровня теста: юнит — 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 матрицы — там токен подписывается по-настоящему, и это уже интеграционная ответственность.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload