1. Зачем нужны интеграционные HTTP‑тесты
Когда вы пишете unit‑тест обработчика, вы тестируете его “в вакууме”: отдельно взятую функцию, которая получила ResponseWriter и Request и что-то записала в ответ. Это очень быстро и очень полезно, но есть один нюанс: в реальной жизни обработчик почти никогда не живёт один. Он живёт в связке “mux → middleware → handler”, и клиент видит результат именно этой связки, а не чистой функции.
Интеграционный тест HTTP‑слоя — это когда мы поднимаем тестовый сервер и делаем запрос как настоящий клиент: через http.Client, через URL, со всеми заголовками и реальным чтением resp.Body. Такой тест ловит ошибки, которые unit‑тесты часто пропускают: неправильный роут, неожиданный 404/405, забытый middleware, отсутствие Content-Type, разные форматы ошибок “в разных углах” сервера.
Чтобы не было ощущения “ещё один вид тестов ради тестов”, зафиксируем мысль простой таблицей:
| Что проверяем | Unit‑тест handler’а (ResponseRecorder) | Интеграционный тест (httptest.Server) |
|---|---|---|
| Логику конкретного handler’а | Отлично | Можно, но дороже |
| Роутинг ServeMux (patterns, {id}) | Почти нет | Да, это его работа |
| Middleware (например, X-Request-ID) | Обычно нет | Да, естественно |
| Поведение “как видит клиент” | Почти | Да, на 100% |
| Скорость/простота | Максимальная | Чуть сложнее, но всё ещё быстро |
И да, интеграционные тесты в Go — это не “тяжёлая артиллерия” на 10 минут прогона. httptest.Server запускается в процессе теста, без Docker, без реального порта в конфиге, без шаманства. Это хороший, практичный инструмент, который помогает держать HTTP‑контракт в узде.
2. База: httptest.Server и тестируемый HTTP‑стек
Что такое httptest.Server
httptest.NewServer(handler) поднимает мини‑HTTP‑сервер прямо внутри вашего теста. Он слушает на случайном свободном порту (чтобы не конфликтовать с другими тестами и вашим локальным приложением), отдаёт вам базовый URL (ts.URL) и готовый клиент (ts.Client()), которым можно делать запросы.
Важно понять философию: это “почти как сеть”, но без внешнего мира. Всё происходит в одном процессе, максимально воспроизводимо. Это отличный баланс: мы тестируем HTTP‑поведение “как у клиента”, но не платим ценой сложного окружения.
Небольшая схема, чтобы картинка сложилась:
flowchart LR
T[Test] -->|создаёт| S[httptest.Server]
S -->|ServeHTTP| H[ваш http.Handler: mux + middleware + handlers]
T -->|HTTP запрос| C["ts.Client()"]
C -->|GET/POST| S
S -->|HTTP response| C
C -->|status/headers/json| T
И сразу дисциплина, без которой всё превращается в “тесты, которые иногда странно зависают”.
ts := httptest.NewServer(handler)
defer ts.Close() // закрыть сервер обязательно
Если забыть Close, сервер может продолжить жить до конца пакета тестов. Иногда это “просто утечка”, а иногда — странные эффекты, особенно если тестов много.
Собираем HTTP‑стек одним входом
Прежде чем писать интеграционные тесты, полезно иметь одну функцию, которая собирает ваш HTTP‑стек целиком: mux, middleware и регистрацию маршрутов. Тогда тесты используют ровно тот же “вход”, что и main.
Представим, что у нас учебное приложение “tasks”, и мы уже реализовали HTTP‑API вроде:
- POST /api/v1/tasks — создать задачу
- GET /api/v1/tasks/{id} — получить задачу
- ошибки всегда возвращаются как error envelope
Мы сделаем сборку сервера через функцию newHTTPHandler(...). Она будет возвращать http.Handler, который мы отдадим в httptest.NewServer.
func newHTTPHandler(taskHandler http.Handler) http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /api/v1/tasks/{id}", taskHandler)
mux.Handle("POST /api/v1/tasks", taskHandler)
return mux
}
Да, в реальном проекте у вас, скорее всего, будет TaskHandler с разными методами или отдельные handler’ы на разные endpoints. Здесь важно не архитектурное совершенство, а идея: тест поднимает сервер так же, как он будет поднят в приложении.
Если вы используете middleware (например, request id), очень удобно включить его сюда же — тогда интеграционный тест реально проверит, что middleware “жив”.
Мини‑правила запросов: ts.URL, ts.Client() и resp.Body.Close()
Теперь о том, как именно делать запросы.
ts.URL — это базовый адрес, например "http://127.0.0.1:54321". В тестах мы просто прибавляем путь строкой. Да, это “склейка строк”, но в тесте допустимо и читаемо.
ts.Client() — это уже настроенный http.Client. Для обычного NewServer он не супер‑магический, но привычка хорошая: “сервер дал клиент”.
И самый важный момент: resp.Body — это поток. Его надо закрывать, как дверь в подъезде: не потому что вы злой, а потому что так живут взрослые.
resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/1")
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
Если не закрывать body, тесты могут начать “подъедать” ресурсы и внезапно становиться нестабильными. Самый неприятный тип багов: “у меня на ноуте прошло, а в CI упало”.
3. Проверяем контракт API: статус, заголовки и JSON‑структура
Интеграционные тесты особенно полезны, когда вы проверяете контракт, а не “как именно сейчас получилось”. Поэтому вместо “сравнить body строкой” мы почти всегда делаем так:
- проверяем resp.StatusCode
- проверяем Content-Type (если это JSON)
- декодируем JSON и проверяем поля
С типами под error envelope мы уже определялись раньше; зафиксируем их в тестовом файле (или в пакете, если вы хотите переиспользовать).
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
type errorEnvelope struct {
Error apiError `json:"error"`
}
Для успеха пусть будет простой DTO задачи:
type taskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
И маленький helper, чтобы JSON‑ошибки в тесте читались нормально. Плюс t.Helper(), чтобы тест падал “в месте вызова”, а не внутри helper’а — это экономит нервы и чай.
func mustJSON(t *testing.T, r io.Reader, dst any) {
t.Helper()
if err := json.NewDecoder(r).Decode(dst); err != nil {
t.Fatalf("decode json: %v", err)
}
}
Кстати, сама идея “ошибки — это значения, их нужно программировать, а не только печатать” — очень в духе Go. В HTTP‑слое это проявляется так: мы работаем с ошибками структурно, а не строками. То же самое мы делаем и в тестах: декодируем структуру и проверяем поля.
4. Практика: сценарии интеграционных HTTP‑тестов
Роутинг и {id}
Начнём с самого “приземлённого”: убедимся, что ServeMux pattern реально матчится, и {id} извлекается корректно. Это типичный пример того, что unit‑тест handler’а не ловит (там вы можете случайно тестировать обработчик, который вообще не зарегистрирован на этот путь).
Сделаем минимальный mux и сервер:
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, r.PathValue("id"))
})
ts := httptest.NewServer(mux)
defer ts.Close()
Дальше делаем запрос:
resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/42")
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
И проверяем:
b, _ := io.ReadAll(resp.Body)
if string(b) != "42" {
t.Fatalf("body=%q, want %q", string(b), "42")
}
Этот тест кажется простым до смешного, но он проверяет важную вещь: маршрут действительно зарегистрирован правильно, и сервер действительно умеет вытаскивать {id}. В реальном проекте такие тесты спасают от “ой, я написал "/task/{id}" вместо "/tasks/{id}" и теперь всё 404”.
Успех: POST создаёт задачу и возвращает JSON
Теперь перейдём к нормальному API‑сценарию: создаём задачу. Мы проверим, что сервер:
- принял JSON,
- вернул правильный статус,
- вернул правильный Content-Type,
- вернул DTO задачи.
Допустим, у нас уже есть handler, который обслуживает POST /api/v1/tasks. В тесте нам важно отправить JSON‑тело.
body := strings.NewReader(`{"title":"buy milk"}`)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks", body)
req.Header.Set("Content-Type", "application/json")
Вызов:
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
Проверка статуса и заголовка:
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusCreated)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Fatalf("Content-Type=%q", ct)
}
И проверка JSON‑тела:
var got taskDTO
mustJSON(t, resp.Body, &got)
if got.Title != "buy milk" {
t.Fatalf("title=%q", got.Title)
}
Обратите внимание на важную мелочь: мы не сравниваем весь JSON как строку. Завтра вы добавите поле updated_at или поменяете порядок полей — строковое сравнение развалится, хотя контракт “задача создаётся” останется верным. Тест должен быть вашим союзником, а не капризным критиком.
Ошибка 400: неверный {id} и fields в error envelope
Теперь самое вкусное: проверка error envelope в интеграции. Мы хотим убедиться, что не только handler “внутри себя” умеет формировать envelope, но и весь стек в целом (mux, общий обработчик ошибок, middleware) не ломает формат.
Сценарий: клиент вызывает GET /api/v1/tasks/abc. По контракту, это validation‑ошибка, потому что {id} должен быть числом.
Запрос:
resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/abc")
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
Проверка статуса и декодирование envelope:
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusBadRequest)
}
var env errorEnvelope
mustJSON(t, resp.Body, &env)
Проверка полей:
if env.Error.Code != "validation" {
t.Fatalf("code=%q", env.Error.Code)
}
if env.Error.Fields["id"] == "" {
t.Fatalf("fields.id is empty")
}
Здесь мы проверяем именно то, что обещали клиенту: “validation‑ошибка имеет код "validation" и карту fields”. Текст поля может быть “must be integer” или “invalid id” — зависит от вашего стандарта. Но то, что fields существует и содержит "id", — это уже контракт.
Ошибка 404: not_found тоже обязан быть envelope’ом
Следующий сценарий похожий, но смысл другой: id валидный, но сущности нет. Это уже не validation, а not_found.
Допустим, GET /api/v1/tasks/9999 возвращает not_found. В тестовой конфигурации вы можете поднять сервер с пустым хранилищем или с парой задач — как вы делали в коде приложения.
resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/9999")
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
Проверка статуса + envelope:
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusNotFound)
}
var env errorEnvelope
mustJSON(t, resp.Body, &env)
if env.Error.Code != "not_found" {
t.Fatalf("code=%q", env.Error.Code)
}
Почему интеграционный тест здесь важнее unit‑теста? Потому что не редко бывает ситуация “в одном handler’е not_found завернули, в другом — забыли и вернули голый http.Error”. Unit‑тест отдельного handler’а этого не поймает, если вы не написали unit‑тесты на каждый endpoint. Интеграционные тесты помогают удержать единый стандарт хотя бы на критических маршрутах.
Ошибка 500: сообщение должно быть стабильным и безопасным
С 500‑ошибками есть особая договорённость: клиент получает стабильное сообщение (например, "internal error"), а реальные детали остаются внутри логов. Это не “блажь”, а вопрос безопасности и UX: пользователю не нужно видеть "dial tcp 10.0.0.12:5432: connection refused".
Проверять это полезно именно интеграционно: чтобы убедиться, что общий обработчик ошибок действительно “режет” внутренности и возвращает стабильный envelope.
Тестовый сценарий обычно такой: поднять сервер в конфигурации, где обработчик намеренно падает “внутренней ошибкой”. Как именно — зависит от вашей архитектуры приложения. Главное — что клиент должен увидеть:
- HTTP 500
- {"error":{"code":"internal","message":"internal error"}}
Проверка выглядит знакомо:
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusInternalServerError)
}
var env errorEnvelope
mustJSON(t, resp.Body, &env)
if env.Error.Message != "internal error" {
t.Fatalf("message=%q", env.Error.Message)
}
А вот “проверять, что message содержит текст внутренней ошибки” — это как раз анти‑цель: вы фиксируете утечку деталей как норму. Такой тест будет защищать неправильное поведение, а не правильное.
Table‑driven подход: меньше копипасты, больше смысла
Когда вы написали 3–5 интеграционных тестов, внезапно выясняется, что 60% кода — это одно и то же: создать запрос, сделать Do, закрыть body, проверить статус, декодировать JSON.
Самое простое решение — table‑driven тесты + небольшие helper’ы. Но мы будем аккуратны: интеграционные тесты не должны превращаться в “комбайн, который проверяет всё на свете одним циклом”. Хороший стиль — один тестовый кейс = один понятный сценарий.
Например, можно держать таблицу для ошибок:
cases := []struct {
name string
path string
status int
code string
}{
{"bad id", "/api/v1/tasks/abc", 400, "validation"},
{"missing", "/api/v1/tasks/9999", 404, "not_found"},
}
А запускать так:
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, _ := ts.Client().Get(ts.URL + tc.path)
defer resp.Body.Close()
var env errorEnvelope
mustJSON(t, resp.Body, &env)
})
}
Обратите внимание: я намеренно не пытаюсь запихнуть сюда “и успех, и ошибки, и разные методы”. Как только таблица становится слишком универсальной — читаемость падает. Ваш тест начинает напоминать бухгалтерскую форму, а не проверку поведения.
5. Типичные ошибки
Ошибка №1: забывают defer ts.Close().
В результате сервер продолжает жить дольше, чем должен. Иногда это почти незаметно, а иногда приводит к “плавающим” проблемам и утечкам ресурсов. Исправляется банально: создали сервер — сразу же рядом написали defer ts.Close().
Ошибка №2: забывают defer resp.Body.Close().
Это одна из самых частых причин, почему тесты начинают вести себя нестабильно при росте количества сценариев. Body — это поток, у него есть ресурсы, и в тестах их тоже надо освобождать. Если не хочется помнить — заведите helper mustClose(t, resp.Body) и пользуйтесь им дисциплинированно.
Ошибка №3: сравнивают JSON как строку.
Сегодня порядок полей один, завтра другой, послезавтра вы добавили omitempty, и “идеально работающий API” внезапно ломает тест. Строковое сравнение полезно редко, а для контрактов почти всегда вредно. Надёжнее декодировать JSON в struct/map и проверять нужные поля.
Ошибка №4: интеграционный тест пытается проверить “вообще всё”.
Когда в одном тесте одновременно проверяется роутинг, middleware, успешный ответ, и ещё три вида ошибок — вы получаете тест, который сложно читать и сложно чинить. Он падает, и непонятно почему. Интеграционные тесты должны быть маленькими: один сценарий — одна причина падения.
Ошибка №5: не проверяют Content-Type, хотя API обещает JSON.
Очень легко случайно вернуть JSON‑тело, но забыть заголовок, или вернуть text/plain через http.Error. Для клиента это может быть критично (особенно для автоматических клиентов и SDK). Если контракт говорит “это JSON” — заголовок становится частью контракта, и его стоит проверять хотя бы в ключевых тестах.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ