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

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

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

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

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

тестирование 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 превращает канон из демо в сопровождаемый проект.

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

тестирование 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: пропуски по порядку.

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., "купить кофе") } }

Соберите путь тестового запроса через 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, а не по живой карте, которую хендлер ещё правит. Знание границ превращает «тест не проходит» в «тест инструменту не по зубам» — и это два разных исправления.

Карточки: 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.

1 / 6

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

У канона появился страховочный трос. Юнит-уровень: 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 сервера не повисает.

Личные заметкиПусто
Хранятся только в вашем браузере (Echo · Тестирование приложений: httptest, e.ServeHTTP и table-driven матрицы)

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

Какой уровень теста выбрать: 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 матрицы — там токен подписывается по-настоящему, и это уже интеграционная ответственность.

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

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

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

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

GORM: ORM-библиотека для Go

Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.

  • Подключать БД и настраивать пул соединений
  • Описывать модели и связи: 1:1, 1:N, M:N
  • Писать CRUD и сложные выборки chainable-стилем
  • Контролировать N+1 через Preload