JavaRush /Курсы /Go SELF /Интеграционные HTTP‑тесты: ...

Интеграционные HTTP‑тесты: httptest.Server

Go SELF
64 уровень , 1 лекция
Открыта

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 строкой” мы почти всегда делаем так:

  1. проверяем resp.StatusCode
  2. проверяем Content-Type (если это JSON)
  3. декодируем 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 подход: меньше копипасты, больше смысла

Когда вы написали 35 интеграционных тестов, внезапно выясняется, что 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” — заголовок становится частью контракта, и его стоит проверять хотя бы в ключевых тестах.

1
Задача
Go SELF, 64 уровень, 1 лекция
Недоступна
Заметка POST
Заметка POST
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ