Содержание урока · 17 разделов
- Мост из прошлого урока: три обещания тестам
- app.Test: тестирование fiber без сети и без порта
- Сигнатура app.Test по буквам: запрос, конфиг, ответ
- TestConfig: Timeout и FailOnTimeout — дедлайн для теста
- httptest.NewRequest и httptest.NewRecorder: стандартный слой
- Юнит-тесты хендлеров: каждый хендлер — функция с контрактом
- Тесты middleware: requireAPIKey и таймер как черный ящик
- Тесты ErrorHandler: лестница ошибок под контролем
- Ошибки тестовой обвязки: ErrTestGotEmptyResponse и ErrHandlerExited
- Справочник: ошибки, которые возвращает app.Test
- t.Setenv и configFromEnv: тест фабрики конфигурации
- graceful shutdown под тестом: медленный хендлер и дедлайн
- Чек-лист: тестирование fiber без гвоздей
- Прогон: go test, границы урока и что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Мост из прошлого урока: три обещания тестам#
Финальный блок двадцатого урока обещал тестам ровно три вещи, и сегодня мы исполним все три. Первая: фабрика configFromEnv просится под тесты — t.Setenv подставляет переменную окружения, app.Test прогоняет запрос через приложение без сети, и вы проверяете, что мусорное значение действительно оставило дефолт Fiber, а не тихо сломало конфиг. Вторая: у app.Test есть TestConfig с полем Timeout и управлением FailOnTimeout — таймауты, которые мы выбирали для сервера в масштабе процесса, возвращаются в масштабе отдельного теста: миллисекунды вместо секунд uptime, и провал вместо зависания. Третья: graceful shutdown тоже тестируется — медленный хендлер, дедлайн и наблюдение, что ответ уехал целиком, — это обычный тест без systemd и docker stop.
Почему это отдельный урок, а не приложение к конфигурации: тестирование fiber меняет саму точку зрения на код, который мы писали двадцать уроков. Хендлер перестаёт быть функцией, которую проверяют curl-ом на живом сервере, и становится функцией, чьё поведение зафиксировано тестом; middleware из седьмого урока — не строкой в app.Use, а контрактом «401 без ключа, 200 с ключом»; ErrorHandler из четырнадцатого — не «настройкой формата», а обещанием статуса, Content-Type и тела, которое нарушается только осознанно. Дальше по уроку: механика app.Test без сети, сигнатура по буквам, TestConfig и дедлайны, httptest.NewRequest и httptest.NewRecorder на фоне стандартной библиотеки, юнит-тесты хендлеров, table-driven тесты стража requireAPIKey, тесты ErrorHandler, словарь ошибок тестовой обвязки, t.Setenv для фабрики конфига и graceful-инвариант под тестом.
app.Test: тестирование fiber без сети и без порта#
Метод app.Test объявлен в app.go ядра fiber v3.5.0, и его док-комментарий скромен до обмана: Test is used for internal debugging by passing a *http.Request. Config is optional and defaults to a 1s error on timeout, 0 timeout will disable it completely. «Для внутренней отладки» — единственная официальная роль; на практике же это главный способ тестирования fiber-приложений, и лучший тому свидетель — сами исходники: в файле app_test.go вызов app.Test встречается сто семьдесят раз. Ни один из этих тестов не поднимает настоящий слушатель: весь корпус проверок фреймворка — от маршрутизации до ErrorHandler и graceful-таймаутов — гоняется через один метод, которому не нужны ни сеть, ни порт, ни время на рукопожатие.
Как это работает, шаг за шагом. Сначала app.Test сериализует запрос целиком через httputil.DumpRequest(req, true): метод, путь, заголовки, тело — всё уходит в сырые байты, как будто их написал настоящий клиент. Затем создаётся testConn — тип из helpers.go, реализующий интерфейс net.Conn поверх пары bytes.Buffer: то, что вы пишете, читает сервер; то, что он пишет, читаете вы; между ними нет ни TCP, ни сокета — только два буфера в памяти. Подготовленный коннект подаётся в горутине на app.server.ServeConn(conn) — тот самый fasthttp-сервер, который обслуживал бы живое TCP-соединение, кормится коннектом из памяти. Ответ собирается обратно функцией httpReadResponse в настоящую *http.Response, а цикл чтения аккуратно отбрасывает промежуточные ответы 1xx — например, Early Hints — и возвращает первый со статусом 200 и выше.
Отсюда три следствия, которые определяют весь урок. Первое: тестам не нужен порт — канон :8080 остаётся делом Listen из двадцатого урока, а не дела тестового. Второе: тестируется полный стек — middleware в порядке app.Use, роутер, хендлер, ErrorHandler; app.Test не умеет «дёрнуть один хендлер в отрыве», и это достоинство, как мы увидим в блоке про юнит-тесты. Третье: время в тесте реальное — хендлер с time.Sleep спит по-настоящему, поэтому у TestConfig существует дедлайн, о котором говорит следующий после канона блок.
И последнее — о скромности док-комментария: он называет app.Test инструментом внутреннего дебага, а в app_test.go сто семьдесят вызовов — fiber тестирует им сам себя. Переведите и на свой код: это не обходной путь «до настоящих тестов», а штатный, верифицированный всем корпусом тестов фреймворка способ тестирования fiber-приложений.
Канон: первый юнит-тест хендлера /api/tasks
GOpackage main
import (
"encoding/json"
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v3"
)
// Task — форма данных из девятого урока
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
}
// buildApp собирает приложение: один код для main и для всех тестов
func buildApp() *fiber.App {
app := fiber.New()
// страж из седьмого урока: без верного ключа — 401
app.Get("/api/tasks", requireAPIKey("s3cret"), func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"items": []Task{{ID: 1, Title: "починить шлюз"}}})
})
return app
}
// TestListTasks — первый unit-тест: полный стек, ноль сети
func TestListTasks(t *testing.T) {
app := buildApp()
// запрос строим конструктором из net/http/httptest
req := httptest.NewRequest("GET", "/api/tasks", nil)
req.Header.Set("X-API-Key", "s3cret")
// прогон через приложение: без Listen, без порта, без сокета
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("статус: получено %d, ожидалось 200", resp.StatusCode)
}
// тело — настоящий *http.Response, читаем как обычно
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("чтение тела: %v", err)
}
var got struct {
Items []Task `json:"items"`
}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("разбор JSON: %v", err)
}
if len(got.Items) != 1 || got.Items[0].Title != "починить шлюз" {
t.Fatalf("items: %+v", got.Items)
}
}Скелет, на котором держится весь урок. buildApp — фабрика приложения: main вызывает её один раз при старте, каждый тест — свежий экземпляр, поэтому тесты не делят ни состояние, ни Locals, и их можно гонять в любом порядке и параллельно. requireAPIKey("s3cret") — страж из седьмого урока; тест обязан слать заголовок X-API-Key, и эта строка — первый контракт, который тест фиксирует. httptest.NewRequest("GET", "/api/tasks", nil) строит запрос стандартной библиотеки; app.Test возвращает пару — *http.Response и error. Обратите внимание на порядок проверок: сначала err через t.Fatalf — пустой ответ с ошибкой нельзя проверять на статус, потому что resp равен nil; затем StatusCode; затем io.ReadAll(resp.Body) и json.Unmarshal в структуру с тем же json-тегом items, что и в хендлере. Тест стал исполняемой версией curl-сценария из прошлых уроков — только зелёная строка вместо взгляда на терминал.
Сигнатура app.Test по буквам: запрос, конфиг, ответ#
Сигнатура целиком: func (app App) Test(req http.Request, config ...TestConfig) (http.Response, error). Разберём по позициям, потому что каждая несёт смысл. Аргумент — http.Request из net/http: не fiber.Ctx, не строка, не самодельный DTO; это тот же тип, что строит httptest.NewRequest, и тот же, что получает хендлер обычного net/http-сервера. Любое знакомство с httptest из гайдов по стандартной библиотеке переносится сюда без поправок — и наоборот, тесты этого урока научат вас httptest-у, который пригодится и вне Fiber.
Конфиг — вариадик: config ...TestConfig. Внутри метода берётся только первый элемент, поэтому передавать два конфига бессмысленно; вариадик существует ради красоты вызова — app.Test(req) без третьей строки. Дефолт формируется в теле: cfg := TestConfig{Timeout: time.Second, FailOnTimeout: true}, и только если конфиг передан — он замещает дефолт целиком: полей «слить с дефолтом по одному» нет, если указали Timeout и промолчали про FailOnTimeout, флаг будет false, а не true. Это частая ловушка, и мы вернёмся к ней в блоке про TestConfig.
Возвращаемое значение — (*http.Response, error), и это самое важное дизайн-решение метода. Вы получаете ответ стандартной библиотеки: StatusCode, Header, Body, Cookies. Не fiber.Ctx — контекст Fiber не переживает запрос, он переиспользуется пулом и наружу не отдаётся. Не число и не строка. Все проверки теста — обычная работа с net/http: resp.StatusCode, resp.Header.Get("Content-Type"), io.ReadAll(resp.Body), json.Unmarshal для конверта items.
Перед подачей в сервер Test доводит запрос до ума тремя штрихами, и каждый — защита от забывчивости. Если тело не пустое и заголовок Content-Length не задан, значение дописывается само из req.ContentLength — иначе fasthttp не понял бы, сколько байт читать. Если Host пуст, он берётся из req.URL.Host, а при пустом URL — «localhost»: дамп запроса обязан содержать Host, иначе сервер вернёт ошибку. Наконец, RequestURI обнуляется, чтобы DumpRequest написал origin-form строку запроса «GET /api/tasks HTTP/1.1» вместо абсолютной формы с URI. Для вас это значит: httptest.NewRequest("GET", "/api/tasks", nil) достаточно — остальное тестовая обвязка сделает сама, и в объяснениях ошибок теста не будет ни одного «missing Host».
TestConfig: Timeout и FailOnTimeout — дедлайн для теста#
TestConfig — структура ровно с двумя полями, и оба стоит выучить дословно, потому что док-комментарии в app.go формулируют их контракт без воды. Первое поле — Timeout time.Duration: defines the maximum duration a test can run before timing out, Default: time.Second. Это максимум, который тест готов ждать ответа; по умолчанию — одна секунда. Второе поле — FailOnTimeout bool: specifies whether the test should return a timeout error if the HTTP response exceeds the Timeout duration, Default: true. Это решение о том, что делать, когда дедлайн истёк: по умолчанию — вернуть ошибку и провалить тест.
Механика внутри app.go прозрачная. Горутина с ServeConn пишет итог в буферизованный канал; тестовый поток стоит в select между этим каналом и time.After(cfg.Timeout). Пришёл ответ — он возвращается; истёк дедлайн — развилка FailOnTimeout. При true соединение закрывается, и возвращается ошибка, совместимая с os.ErrDeadlineExceeded, — тест красный, и это правильный исход: хендлер, который не успевает за дедлайн, нарушает контракт производительности, и пусть об этом говорит тест, а не пользователь в пятницу вечером. При false — поведение тоньше, и оно зафиксировано в исходнике дословно: When FailOnTimeout is false, wait up to 1 additional second for the handler to complete and write a response — тест даёт хендлеру ещё одну секунду дописать ответ и возвращает то, что успел; тест остаётся зелёным, если ответ доехал в этом дополнительном окне. Ноль в Timeout — третий режим: ожидание без ограничения, select без ветки time.After; тот самый 0 timeout will disable it completely из док-комментария.
Почему дедлайн — необходимость, а не украшение: go test убивает пакет по общему таймауту, и один зависший тест подвешивает всю сессию CI без внятного отчёта. TestConfig — предохранитель точечный: красный тест с ошибкой дедлайна точно говорит, какой хендлер и какой сценарий зависли. Отсюда правило выбора значений: дефолтной секунды хватает юнит-тестам — хендлеры без сети и базы отвечают за миллисекунды; Timeout поднимайте осознанно под реально медленные сценарии (сон, таймер из седьмого урока), а FailOnTimeout: false берите только когда тестируете сам факт «хендлер в итоге отвечает», а не его скорость.
Проверьте себя: дефолты TestConfig
Вызов app.Test(req) без второго аргумента; хендлер внутри молчит дольше секунды. Что произойдёт?
Что вернёт app.Test, когда хендлер ленивее дедлайна?
Прочитайте код и выберите, что он выведет:
app := buildApp()
app.Get("/api/slow", func(c fiber.Ctx) error {
time.Sleep(200 * time.Millisecond) // хендлер думает 200 мс
return c.SendString("готово")
})
// дедлайн теста короче хендлера — образец Test_Test_Timeout из app_test.go
resp, err := app.Test(
httptest.NewRequest("GET", "/api/slow", nil),
fiber.TestConfig{Timeout: 20 * time.Millisecond, FailOnTimeout: true},
)
fmt.Println(resp, err)httptest.NewRequest и httptest.NewRecorder: стандартный слой#
Пакет net/http/httptest — стандартный ящик с инструментами для HTTP-тестов, и в нём два героя. Первый — httptest.NewRequest(method, target, body): строит полноценный *http.Request. Метод — строка ("GET", "POST"), цель — путь с запросом, тело — nil, http.NoBody или любой io.Reader: bytes.NewBufferString для JSON-тела, strings.NewReader для форм. Второй герой — httptest.NewRecorder: заглушка http.ResponseWriter, в которую хендлер пишет ответ; после вызова handler.ServeHTTP(rec, req) вы читаете rec.Code и rec.Body. Классический юнит-тест net/http выглядит так: NewRequest, затем ServeHTTP, затем проверки на recorder — и большинство гайдов по тестированию HTTP в Go описывает именно этот рисунок.
В Fiber этот рисунок ломается ровно в одном месте: у приложения нет метода ServeHTTP. Сервер Fiber — fasthttp, а не net/http: другой интерфейс, другой конвейер, и ResponseRecorder подсунуть некуда — несовместимость подписей, а не «пока не добавили». Но не спешите тащить сторонний тестовый фреймворк: app.Test закрывает ту же потребность, сохранив половину классической пары. httptest.NewRequest остаётся — он из стандартной библиотеки и работает как есть; вместо связки «recorder + ServeHTTP» становится app.Test, и это даже выгоднее. Recorder вернул бы вам result.Code и result.Body — числа и буфер, а app.Test возвращает настоящую *http.Response: с Header, Cookies и тем же io.ReadAll, каким вы читаете ответ живого клиента. Один и тот же тестовый код объединяет оба мира: запрос — от httptest, ответ — от net/http, а между ними Fiber прогоняет полный стек без сети.
Для полноты честности: NewRecorder в уроке остался не для красоты. Если у вас есть чистая функция-обработчик без Fiber, работающая с http.ResponseWriter, — тестируйте её рескардером, это правильный инструмент. Но как только код завязан на fiber.Ctx, путь один: buildApp и app.Test.
Юнит-тесты хендлеров: каждый хендлер — функция с контрактом#
Требование «unit-тесты хендлеров» распадается на два вопроса: что считать юнитом и что проверять. Хендлер Fiber — функция func(c fiber.Ctx) error, и у соблазна вызвать её напрямую с фейковым Ctx ноги растут именно отсюда. Путь этот тупиковый: интерфейс Ctx широченный, самодельная заглушка либо реализует полсотни методов впустую, либо ломается при первом обновлении fiber, когда реальный код хендлера тронет метод, которого в заглушке нет. Правильный юнит для HTTP-хендлера — полный стек: buildApp, app.Test, ответ. Дешевизна та же — миллисекунды и ноль сети, — а покрытие честное: тестируются и роутер (правильный ли путь и метод), и middleware (страж впустил или нет), и сериализация (JSON-теги живее всех живых).
Первый структурный шаг — фабрика buildApp() из канона выше. Почему фабрика, а не глобальная переменная app: main вызывает её один раз, каждый тест получает свежий экземпляр; никаких общих Locals между тестами, никаких t.Cleanup с остановкой слушателя — потому что и слушать нечего. Свежее приложение на тест — это ещё и независимость сценариев: тест стража не зависит от того, что тест сериализации повесил на тот же путь другой middleware. Параллельность — t.Parallel из урока о тестировании в Go курса go-advanced — безопасна именно потому, что у каждого теста свой app и свой testConn.
Что проверять: статус, заголовки, тело — в порядке убывания стабильности. Статус — это контракт маршрута: 200 для /api/tasks с ключом, 401 без. Заголовки: Content-Type application/json после c.JSON, X-Request-ID, если requestid в стеке. Тело: io.ReadAll и json.Unmarshal в ту же структуру Task с тегами json:"id" и json:"title" — тест становится исполняемым контрактом формата items, и переименование поля в хендлере красит тест раньше, чем фронтенд в проде. Для POST то же самое с телом: httptest.NewRequest(fiber.MethodPost, "/api/tasks", bytes.NewBufferString("{\"title\":\"новая задача\"}")) и req.Header.Set("Content-Type", "application/json") — Fiber не угадывает формат тела, Content-Length допишется сам, а Content-Type — ваша забота.
Канон: table-driven тесты requireAPIKey с t.Run
GO// TestTasksAPIKey — страж requireAPIKey из седьмого урока как контракт
func TestTasksAPIKey(t *testing.T) {
app := buildApp() // внутри: /api/tasks под requireAPIKey("s3cret")
// три сценария одного middleware в одном срезе структур
tests := []struct {
name string
key string
status int
}{
{"без заголовка", "", fiber.StatusUnauthorized},
{"чужой ключ", "wrong", fiber.StatusUnauthorized},
{"верный ключ", "s3cret", 200},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/tasks", nil)
if tt.key != "" {
req.Header.Set("X-API-Key", tt.key)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != tt.status {
t.Fatalf("%s: статус %d, ожидался %d",
tt.name, resp.StatusCode, tt.status)
}
})
}
}Table-driven тесты — главная идиома Go, и стражи middleware подходят под неё идеально: у слоя конечный набор сценариев с одинаковым протоколом проверки. Срез структур держит имя сценария, вход (ключ) и ожидание (статус); t.Run делает каждый сценарий самостоятельным под-тестом с именем в отчёте — go test -v покажет TestTasksAPIKey/без_заголовка и TestTasksAPIKey/чужой_ключ красными, если кто-то «упростил» requireAPIKey. Заметьте два места, где тест защищает контракт: пустой ключ не ставит заголовок вовсе — это сценарий «заголовка нет», а не «заголовок пустой», и fiber.StatusUnauthorized вместо сырого 401 в ожидании — константы читаются и рефакторятся лучше чисел. Добавить четвёртый сценарий — ключ в другом регистре x-aPi-KeY — значит зафиксировать ещё один факт из седьмого урока: имена заголовков сравниваются без учёта регистра.
Проверьте себя: конфликт портов в CI
Два пакета тестов гоняются параллельно в CI; оба зовут app.Test на приложениях с каноном :8080. Конфликт портов неизбежен?
Тесты middleware: requireAPIKey и таймер как черный ящик#
Седьмой урок научил писать middleware — фабрику requireAPIKey(key) fiber.Handler, таймер вокруг c.Next(), передачу пользователя через c.Locals. Теперь научимся доказывать тестами, что слой делает ровно то, что обещал. Главный принцип: middleware тестируется как черный ящик через полный стек. Нельзя «вызвать слой напрямую» — для вызова нужен живой fiber.Ctx, а честно построить его вне приложения нельзя: контекст создаётся сервером на каждый запрос и требует внутренностей app. Самодельные заглушки Ctx — вчерашняя заглушка против сегодняшнего fiber. Черный ящик проще и сильнее: buildApp регистрирует слой ровно так, как main, app.Test подаёт запрос, и тест проверяет наблюдаемые последствия.
Для стража наблюдаемые последствия мы уже зафиксировали: 401 с JSON-телом {"error":"неверный API-ключ"} без верного ключа, 200 дальше по цепочке с верным. Три сценария в table-driven — и requireAPIKey покрыт лучше, чем curl-ом за неделю. Но у слоя седьмого урока есть и вторая половина контракта: c.Locals("user", "api-client"). Как проверить её? Приём называется хендлер-канарейка: в тестовом приложении под стражем вешается временный хендлер, который читает Locals и отдаёт значение в ответе; тест сверяет тело. Канарейка — единственный честный способ увидеть внутренности цепочки снаружи.
Таймер из седьмого урока проверяется по тем же правилам, но с оговоркой про флаки: строгие пороги «latency < 10ms» в тестах — генератор нестабильных провалов на перегруженном CI. Проверяйте инварианты, а не скорость: что значение записано, что оно положительное, что порядок слоёв не потерял ошибки (таймер в error-стиле обязан вернуть err из c.Next() как есть). И ещё одна вещь, которую тесты middleware дают бесплатно: порядок app.Use в buildApp идентичен порядку в main — если тесты собирают цепочку одной фабрикой с сервером, расхождение «в тесте страж был, в проде забыли» невозможно по построению.
Тесты ErrorHandler: лестница ошибок под контролем#
Четырнадцатый урок собрал ErrorHandler как единый формат ошибок: поле fiber.Config{ErrorHandler: ...}, тип-алиас func(Ctx, error) error, лестница распознавания — errors.As на *fiber.Error для framework-овых отказов, errors.Is на доменные sentinel-ы, единый JSON-конверт {error, code} для API. Всё это — контракт с клиентом, а контракт, который не проверяется тестом, — это комментарий. app.Test превращает ErrorHandler из настройки в проверяемое поведение: собрать приложение с вашим ErrorHandler, повесить хендлер, возвращающий нужную ошибку, подать запрос — и сверить три вещи: статус, Content-Type и тело.
Каждая ветка лестницы — отдельный сценарий, и table-driven отсюда напрашивается. Сценарий первый: хендлер вернул fiber.NewError(fiber.StatusNotFound, "задача не найдена") — ожидание: статус 404, application/json, тело содержит текст и код 404. Сценарий второй: обычная errors.New("база недоступна") — ожидание: 500 (обычная ошибка не несёт статус), JSON с этим текстом: ваш ErrorHandler, в отличие от DefaultErrorHandler, отвечает JSON-ом, и тест фиксирует именно это отличие от дефолта четырнадцатого урока. Сценарий третий: обёртка fmt.Errorf("получить задачу: %w", fiber.ErrNotFound) — ожидание: 404, потому что errors.As/Is проворачивают цепочку Unwrap и находят *fiber.Error внутри. Сценарий четвёртый, для продвинутых: доменная ошибка ValidationError с полем Field — ожидание: 400 и имя поля в теле.
Отдельно стоит сказать про Content-Type. Дефолтный DefaultErrorHandler четырнадцатого урока отвечал text/plain; charset=utf-8 — и тест, проверяющий application/json, поймает регрессию, в которой кто-то вернул дефолт или написал ответ SendString-ом мимо формата. А страховка роутера — голый SendStatus(500), когда сам ErrorHandler упал, — тестируется сценарием с намеренно сломанным обработчиком: приложение с ErrorHandler, который возвращает ошибку, обязано отдать пустой 500. Это редкий, но самый ценный тест: он проверяет деградацию, а не идеальный путь.
Тест единого JSON-формата ошибок
GO// jsonErrorHandler — единый формат {error, code} из четырнадцатого урока
func jsonErrorHandler(c fiber.Ctx, err error) error {
// статус по умолчанию — 500, как у DefaultErrorHandler
code := fiber.StatusInternalServerError
var fe *fiber.Error
if errors.As(err, &fe) {
code = fe.Code // *fiber.Error несёт свой код через Unwrap
}
return c.Status(code).JSON(fiber.Map{
"error": err.Error(),
"code": code,
})
}
// TestErrorHandler — ErrorHandler как исполняемый контракт API
func TestErrorHandler(t *testing.T) {
app := fiber.New(fiber.Config{ErrorHandler: jsonErrorHandler})
app.Get("/api/tasks/:id", func(c fiber.Ctx) error {
// «нет записи» — через NewError, как в четырнадцатом уроке
return fiber.NewError(fiber.StatusNotFound, "задача не найдена")
})
req := httptest.NewRequest("GET", "/api/tasks/42", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != fiber.StatusNotFound {
t.Fatalf("статус: %d, ожидался 404", resp.StatusCode)
}
// контент-неготиация: API получает JSON, а не text/plain дефолта
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type: %s, ожидался application/json", ct)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "задача не найдена") ||
!strings.Contains(string(body), "404") {
t.Fatalf("тело: %s", body)
}
}ErrorHandler и его тест — два зеркала одного контракта. Слева: errors.As проворачивает цепочку и достаёт *fiber.Error с кодом 404; Status(code).JSON собирает конверт {error, code}. Справа: тест подаёт запрос через app.Test и сверяет три уровня обещания — статус fiber.StatusNotFound, заголовок Content-Type application/json (именно этим ветка отличается от DefaultErrorHandler с его text/plain; charset=utf-8), тело с текстом ошибки и числом кода. Маршрут /api/tasks/42 с параметром :id напоминает, что ErrorHandler стоит за роутером: NewError, возвращённый хендлером, всплывает по цепочке и попадает в обработчик — тот самый путь из router.go, который мы читали в четырнадцатом уроке. Этот тест ломается, если кто-то сменит формат конверта, заберёт JSON-ветку или вернёт дефолт — и это ровно те поломки, которые иначе замечает клиент, а не CI.
Ошибки тестовой обвязки: ErrTestGotEmptyResponse и ErrHandlerExited#
app.Test возвращает ошибку не только по дедлайну — у тестовой обвязки собственный словарь, и все слова стоит выучить до первого провала в CI. Первая ошибка — ErrTestGotEmptyResponse с текстом test: got empty response. Она объявлена в app.go рядом с TestConfig как var ErrTestGotEmptyResponse = errors.New(...) и возникает, когда httpReadResponse упирается в io.ErrUnexpectedEOF: сервер не написал ни байта ответа, буфер коннекта пуст. Типичный источник — c.Drop(): хендлер осознанно рвёт соединение без статуса и тела. Другие источники — хендлер, который вернулся, ничего не записав, или обрыв по дедлайну ровно между запросом и первым байтом ответа. Проверяется через errors.Is: require.ErrorIs(err, fiber.ErrTestGotEmptyResponse). У этого факта есть дисциплинирующая сторона: тест, в котором хендлер Drop-ает, обязан ожидать пустой ответ явно — тихий Drop в тесте, ожидающем 200, станет красным тестом, а не молчаливой зелёной фикцией.
Вторая ошибка — ErrHandlerExited с текстом runtime.Goexit() called in handler or server panic; комментарий в error.go поясняет: returned by App.Test if a handler panics or calls runtime.Goexit(). Механика: горутина, обслуживающая коннект, обёрнута в defer, который проверяет, вернулась ли ServeConn в канал; если горутина ушла через панику или Goexit — в канал уходит ErrHandlerExited. Практическое следствие двойное. Хорошее: паника в хендлере без recover-middleware не валит go test с трассировкой стека всего прогона — она превращается в ошибку конкретного теста, который можно читать и чинить. Неудобное: привычного panic-стека в выводе вы не увидите, поэтому recover-middleware из шестого урока — первый слой в цепочке — остаётся обязательным: с ним паника превращается в 500 через DefaultPanicHandler, и ErrorHandler получает её уже как ошибку, которую тест может проверить по формату.
Мелочь напоследок: fmt.Errorf-обёртка failed to dump request: %w возвращается, если запрос невозможно сериализовать — например, тело из reader, который читается с ошибкой. Внутренняя errTestConnClosed с текстом testConn is closed остаётся деталью механики: Write в закрытый testConn возвращает её, и app.Test трактует это как штатный финал. Таблица ниже — справочник для быстрого чтения отчёта CI.
Справочник: ошибки, которые возвращает app.Test#
| Ошибка | Когда возникает | Что делать в тесте |
|---|---|---|
| os.ErrDeadlineExceeded | Дедлайн Timeout истёк при FailOnTimeout: true | Поднять Timeout под сценарий или взять FailOnTimeout: false для медленных хендлеров |
| ErrTestGotEmptyResponse («test: got empty response») | Хендлер не написал ни байта: c.Drop(), возврат без записи, обрыв — io.ErrUnexpectedEOF | Ожидать через errors.Is; проверить, что хендлер в этом сценарии действительно не отвечает |
| ErrHandlerExited («runtime.Goexit() called in handler or server panic») | Паника в хендлере без recover-middleware или runtime.Goexit() в хендлере | Поставить recover первым в цепочке; Goexit в хендлерах не использовать |
| failed to dump request: %w | Запрос невозможно сериализовать через httputil.DumpRequest | Проверить конструирование httptest.NewRequest: метод, цель, тело и его reader |
Проверьте себя: паника под app.Test
Хендлер паникует, recover-middleware в цепочке нет. Что сделает go test, в котором такой хендлер проверяется через app.Test?
Что вернёт app.Test для хендлера с c.Drop()?
Прочитайте код и выберите, что он выведет:
app := fiber.New()
app.Get("/api/gone", func(c fiber.Ctx) error {
// хендлер отвечает разрывом: ни статуса, ни тела
return c.Drop()
})
_, err := app.Test(
httptest.NewRequest("GET", "/api/gone", nil),
fiber.TestConfig{Timeout: 0, FailOnTimeout: false},
)
fmt.Println(errors.Is(err, fiber.ErrTestGotEmptyResponse))t.Setenv и configFromEnv: тест фабрики конфигурации#
Фабрика configFromEnv из двадцатого урока была подарком с отложенным сроком: «фабрику легко покрыть тестами, подавая env через t.Setenv». Срок наступил. Пакет testing даёт t.Setenv(name, value): метод ставит переменную окружения и гарантированно возвращает всё, как было, после конца теста — включая случаи паники и t.Fatal; внутренние механизмы вроде os.Setenv сами по себе такой гарантии не дают, и это первая причина использовать именно t.Setenv. Вторая причина — честность параллельности: t.Setenv помечает тест как несовместимый с t.Parallel, и это правильно — окружение процесса глобально, два параллельных теста, спорящие за одну переменную, — гонка по определению.
Что проверять у фабрики: два фильтра, ради которых она писалась. Корректное значение «5s» в FIBER_READ_TIMEOUT должно превратиться в 5 time.Second — это ветка time.ParseDuration с err == nil. Мусорное значение «очень много» в FIBER_BODY_LIMIT обязано оставить дефолт 4 1024 * 1024 — это ветка, где strconv.Atoi вернул ошибку и двойной фильтр err == nil && n > 0 отработал. Отдельный сценарий — пустая переменная: фабрика обязана вести себя так же, как при её отсутствии. И финальный штрих интеграции: приложение целиком — fiber.New(configFromEnv()), один запрос через app.Test — доказывает, что мусорное окружение не роняет старт и /api/tasks отвечает с дефолтами; это ровно тот тест, который тизер прошлого урока обещал дословно.
Цена вопроса: конфигурация, которую нельзя проверить тестом, — надежда. Фабрика из двадцати строк с двумя фильтрами после такого теста становится документированным контрактом: любое изменение правила парсинга сначала красит тест, потом едет в релиз.
Канон: TestConfigFromEnv на t.Setenv
GO// TestConfigFromEnv — фабрика конфига из двадцатого урока под тестом
func TestConfigFromEnv(t *testing.T) {
// t.Setenv ставит переменную и вернёт окружение после теста
t.Setenv("FIBER_READ_TIMEOUT", "5s")
cfg := configFromEnv()
if cfg.ReadTimeout != 5*time.Second {
t.Fatalf("ReadTimeout: %v, ожидалось 5s", cfg.ReadTimeout)
}
// мусор не должен менять дефолт: двойной фильтр err == nil && n > 0
t.Setenv("FIBER_BODY_LIMIT", "очень много")
cfg = configFromEnv()
if cfg.BodyLimit != 4*1024*1024 {
t.Fatalf("BodyLimit: %d, ожидался дефолт 4 MiB", cfg.BodyLimit)
}
}Два сценария — две ветки фабрики. Первый: «5s» парсится time.ParseDuration в 5 time.Second, и конфиг получает честный дедлайн чтения. Второй: «очень много» не проходит strconv.Atoi, двойной фильтр err == nil && n > 0 отбрасывает значение, и BodyLimit остаётся дефолтом 4 1024 * 1024 — тем самым, что двадцатый урок показывал в карте полей. t.Setenv дважды в одном тесте легален: каждая установка перекрывает предыдущую и обе откатятся после теста. Расширение дома: тест на пустые переменные (поведение как при отсутствии), тест на «-5» (отрицательные отсекает n > 0) и интеграционный — fiber.New(configFromEnv()) плюс app.Test на /api/tasks, чтобы убедиться, что приложение с мусорным окружением стартует и отвечает. Так конфигурация из темы «настройки» переезжает в тему «контракт с тестом».
graceful shutdown под тестом: медленный хендлер и дедлайн#
Тизер прошлого урока обещал: graceful shutdown тоже тестируется. Честная формулировка границы: app.Test — про путь запроса, а не про остановку процесса. Слушатель и обработчик сигналов из двадцатого урока живут в Listen и в горутине остановки — их полный цикл тестируется интеграционно, локальным kill -TERM с curl в фоне, как в двадцатом уроке. Но инвариант graceful — «медленный запрос успевает дописать ответ, пока его не оборвали» — проверяется в миниатюре прямо здесь: хендлер с time.Sleep, TestConfig с Timeout больше сна, и зелёный тест подтверждает, что ответ уезжает целиком. Сожмите дедлайн ниже сна — получите провал по os.ErrDeadlineExceeded. Дедлайн, который в проде задаёт ShutdownWithTimeout, в тесте задаёт TestConfig — та же арифметика «запас должен быть больше самого медленного легитимного запроса», только в миллисекундах и на каждый прогон.
В исходниках fiber есть готовые образцы. Test_Test_Timeout в app_test.go — хендлер со Sleep 200 миллисекунд, TestConfig{Timeout: 20 * time.Millisecond, FailOnTimeout: true} и require.Error: дедлайн короче хендлера — тест обязан упасть. Это наш predict из середины урока, процитированный из первых рук. А тест с говорящим именем Test_App_Test_Goroutine_Leak_Compare открывает вторую страницу механики: горутина, потерянная по дедлайну. Когда FailOnTimeout true обрывает ожидание, коннект закрывается, но горутина хендлера ещё спит — она допишет ответ в закрытый буфер и завершится сама; тест специально сравнивает runtime.NumGoroutine() до и после и документирует, что при сработавшем дедлайне goroutine-утечка возможна и самоизлечима. Практический вывод: дедлайн теста — инструмент быстрого провала, а не способ «убить» хендлер; для медленных сценариев поднимайте Timeout или берите FailOnTimeout: false — тогда app.Test даст хендлеру дополнительную секунду дописать ответ и вернёт то, что успело уехать.
И последняя связка с прошлым уроком: env-конфигурация и graceful встречаются в одном тесте, когда вы проверяете, что Timeout из окружения действительно дошёл до конфига, — fiber.New(configFromEnv()), хендлер со Sleep, app.Test с ожиданием ответа в пределах дедлайна. Конфигурация без теста — надежда; тест без конфигурации — возможность; вместе — контракт.
Соберите юнит-тест хендлера
Заполните пропуски так, чтобы тест строил запрос стандартной библиотекой, прогонял его через приложение без сети и читал тело настоящего ответа.
Соберите жизненный цикл юнит-теста хендлера
Расставьте шаги от фабрики приложения до зелёной строки go test — от того, что делается первым, к тому, что делается последним.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Чек-лист: тестирование fiber без гвоздей#
- buildApp(): приложение собирает фабрика — main и тесты используют один код, у каждого теста свой экземпляр
- Запрос — httptest.NewRequest(method, target, body); заголовки и тело — до app.Test, Content-Type задаёте сами, Content-Length допишется сам
- app.Test(req) возвращает (*http.Response, error): сначала t.Fatalf на err, потом StatusCode, потом io.ReadAll(resp.Body)
- TestConfig: Timeout по умолчанию time.Second, FailOnTimeout — true; Timeout: 0 отключает дедлайн полностью
- Помните про цельный замещение: переданный TestConfig не сливается с дефолтом — задали Timeout без FailOnTimeout, получите false
- Медленный хендлер — либо больше Timeout, либо FailOnTimeout: false с дополнительной секундой; иначе os.ErrDeadlineExceeded и потерянная горутина
- c.Drop() в хендлере — только с явным require.ErrorIs(err, fiber.ErrTestGotEmptyResponse)
- recover-middleware первым в цепочке: без него паника вернёт ErrHandlerExited вместо проверяемого 500
- Middleware и ErrorHandler — черный ящик через полный стек: 401/200 стража, статус и Content-Type единого JSON-формата
- t.Setenv для env-конфигурации: переменная восстановится после теста; t.Parallel с t.Setenv не смешивать
Прогон: go test, границы урока и что дальше#
Соберите файл урока целиком: buildApp, страж requireAPIKey из седьмого, jsonErrorHandler из четырнадцатого, configFromEnv из двадцатого — всё в одном пакете, тесты рядом в *_test.go. Команды прогона три. go test ./... — весь пакет со всеми тестами. go test -run TestListTasks -v — один тест по шаблону имени с подробным отчётом: t.Run-подтесты table-driven напечатаются отдельными строками со своими именами. go test -cover — покрытие, чтобы увидеть, какие ветки хендлеров прогон так и не потревожил. Приятная арифметика: весь урок исполняется за доли секунды, потому что ни один тест не открыл сокет.
Честное слово про инструментарий: песочница сайта Go-тесты не исполняет — runner-ов в уроках Fiber нет с первого урока, go test требует локального тулчейна, а интерактивы здесь quiz, predict, fill, order и флешкарты. Материал держится на исходниках ветки main ядра fiber v3.5.0: app.go — Test, TestConfig, ErrTestGotEmptyResponse; helpers.go — testConn над парой bytes.Buffer и errTestConnClosed; error.go — ErrHandlerExited; app_test.go — сто семьдесят вызовов, из которых мы процитировали Test_Test_Timeout, Test_App_Test_drop_empty_response и Test_App_Test_Goroutine_Leak_Compare. Граница темы: всё, что тестируется здесь, — транспортный контракт Fiber; бизнес-логика домена тестируется обычными тестами Go без fiber вообще, и чем меньше fiber в домене, тем быстрее тесты. Полный план курса — на странице курса Fiber.
Следующий, двадцать второй урок — структура большого проекта: слои, репозитории, DI-подход, вынос хендлеров из main. Тестовая база этого урока — его главный союзник, и вот почему. Сегодня buildApp вмещает один маршрут и одну фабрику; в двадцать втором маршрутов станет десятки, и фабрика разложится на конструкторы слоёв: buildApp станет собирать приложение из нужного среза — страж без логгера, роутер задач без статики, репозиторий над хранением в памяти вместо настоящей базы. Юнит-тесты этого урока при этом не изменятся ни строчкой: httptest.NewRequest, app.Test, io.ReadAll — тот же скелет работает поверх любой структуры проекта. Именно это и есть цель тестирования fiber: сделать рефакторинг скучным — а скучный рефакторинг это комплимент архитектуре.
Карточки: тестирование fiber
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- app.Test(req, config ...TestConfig) (*http.Response, error) — главный инструмент тестирования fiber: полный стек без сети и порта, запрос через httptest.NewRequest, ответ — стандартный *http.Response net/http.
- TestConfig: Timeout по умолчанию time.Second, FailOnTimeout — true; Timeout: 0 отключает дедлайн; истечение при FailOnTimeout возвращает ошибку, совместимую с os.ErrDeadlineExceeded; FailOnTimeout: false даёт хендлеру ещё одну секунду.
- Механика без сети: httputil.DumpRequest → testConn над парой bytes.Buffer → ServeConn → httpReadResponse с отбрасыванием 1xx; канон :8080 остаётся делом Listen, тестам порт не нужен.
- Юнит-тест хендлера: buildApp-фабрика, httptest.NewRequest, app.Test, t.Fatalf на err, resp.StatusCode, io.ReadAll, json.Unmarshal в структуру с json-тегами; сценарии стража requireAPIKey — table-driven с t.Run.
- Middleware и ErrorHandler тестируются как черный ящик через полный стек: 401/200 requireAPIKey, хендлер-канарейка для c.Locals, статус и Content-Type единого JSON-формата ошибок из четырнадцатого урока.
- Ошибки обвязки — часть контракта: ErrTestGotEmptyResponse после c.Drop(), ErrHandlerExited при панике без recover; t.Setenv проверяет configFromEnv из прошлого урока, а дедлайн TestConfig — это graceful-инвариант ShutdownWithTimeout в миниатюре.
Домашнее задание
Соберите пакет тестов вокруг канона урока и пройдите три шага. Первый: напишите TestListTasks на buildApp с requireAPIKey("s3cret") и добавьте четвёртый сценарий в table-driven — заголовок X-API-Key в другом регистре (x-aPi-KeY) — и объясните в комментарии, почему тест зелёный. Второй: покройте jsonErrorHandler четырьмя table-driven сценариями — fiber.NewError с кодом и текстом, обычная errors.New (ожидайте 500), обёртка fmt.Errorf("%w", fiber.ErrNotFound) (ожидайте 404) — сверьте статус, Content-Type и тело. Третий: тестируйте configFromEnv через t.Setenv: пять переменных, включая мусорные и пустые, — и один тест на медленный хендлер с fiber.TestConfig{Timeout: 50 * time.Millisecond, FailOnTimeout: false}, объяснив в комментарии, почему err равен nil. Прогоните всё через go test -v и go test -cover и посмотрите, какие ветки остались непокрытыми.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем app.Test отличается от httptest.NewRecorder из гайдов по net/http?
Рескардер подходит хендлерам с интерфейсом http.Handler: recorder притворяется http.ResponseWriter, и вы читаете rec.Code и rec.Body после handler.ServeHTTP. У Fiber нет http.Handler — сервер fasthttp, поэтому подсунуть ResponseRecorder некуда. app.Test решает задачу глубже: сериализует запрос httputil.DumpRequest и подаёт байты настоящему серверу через testConn, возвращая честную *http.Response. httptest.NewRequest — общий знаменатель обоих путей.
Какой Timeout ставить в TestConfig?
Дефолтной секунды хватает почти всем юнит-тестам: хендлер без сети и базы отвечает за миллисекунды. Дедлайн нужен, чтобы зависший тест не подвесил весь go test: Timeout выше — только если хендлер реально медленный (сон, таймер из седьмого урока); либо FailOnTimeout: false, когда тестируете факт «в итоге отвечает», а не скорость. Помните: переданный TestConfig замещает дефолт целиком, FailOnTimeout в нём не равен true по умолчанию.
Можно ли тестировать middleware отдельно от хендлеров?
Слою нужен живой fiber.Ctx, а его нельзя честно построить вне приложения: контекст создаётся сервером на каждый запрос. Правильный уровень — черный ящик: buildApp, регистрация слоя как в main, app.Test и проверки статуса, тела и того, что хендлер-канарейка увидел в c.Locals. Так тест заодно проверяет порядок цепочки, а самодельные заглушки Ctx не ломаются при каждом обновлении fiber.
Тестируется ли graceful shutdown через app.Test?
app.Test — про путь запроса, а не про остановку процесса: слушатель и сигналы живут в Listen. Но инвариант graceful проверяется в миниатюре: медленный хендлер, TestConfig с Timeout больше сна — и тест подтверждает, что ответ уезжает до дедлайна; это арифметика ShutdownWithTimeout из двадцатого урока в миллисекундах. Полный цикл SIGTERM → ShutdownWithContext тестируется интеграционно: kill -TERM и curl в фоне.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT