8. HTTP integration тесты
Вертикальный срез: Тестирование системы целиком
Юнит-тесты проверяют отдельные шестеренки механизма в изоляции. Тесты репозиториев — например, разобранные нами в 3. Транзакции и rollback подход — гарантируют, что шестеренки корректно передают вращение на вал базы данных. Однако конечный пользователь или сторонний сервис никогда не вызывает ваши Go-функции напрямую через структуры и интерфейсы. Клиент взаимодействует с системой через сетевой фасад — HTTP REST API.
HTTP интеграционные тесты (в литературе их часто называют сквозными компонентными тестами или тестированием подсистем) верифицируют вертикальный срез (Vertical Slice) приложения.
В таком тесте мы моделируем реальное поведение внешнего мира: отправляем HTTP-запрос по сети, наблюдаем, как он проходит через всю цепочку обработки — сетевой стек ядра Linux $\rightarrow$ мультиплексор маршрутизатора $\rightarrow$ промежуточные слои (Middleware: трассировка, аутентификация, сжатие) $\rightarrow$ контроллер $\rightarrow$ валидация DTO $\rightarrow$ доменная бизнес-логика $\rightarrow$ репозиторий $\rightarrow$ реальная база данных, — а затем возвращается обратно в виде сформированного HTTP-ответа и обновленного состояния на диске.
Сетевой запрос (HTTP POST)
│
▼
[Сетевой стек ОС (TCP)] ──> [Middleware цепочка] ──> [Router / Handler]
│
▼
[Service / UseCase]
│
▼
[Клиентский HTTP Ответ] <── [JSON Сериализация] <── [БД: PostgreSQL/WAL]
Это тесты с наивысшим коэффициентом доверия (High Confidence). Вы можете полностью переписать внутреннюю структуру пакетов, оптимизировать запросы или заменить библиотеки валидации — если вертикальный HTTP-тест проходит успешно, система работает в строгом соответствии с бизнес-требованиями.
In-Memory (Recorder) vs TCP (Server)
В стандартной библиотеке Go заложен эталонный инструмент для сетевого тестирования — пакет net/http/httptest (детальное исследование его внутренностей ожидает нас в 1. net/http/httptest пакет). При проектировании интеграционных проверок инженер неизменно сталкивается с выбором между двумя фундаментальными подходами: httptest.NewRecorder и httptest.NewServer.
Наглядно сопоставим эти два пути:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Mode1["Режим 1: In-Memory (httptest.NewRecorder)"]
Test1["Тест go test"]:::data -->|"Прямой вызов ServeHTTP"| Rec["ResponseRecorder (bytes.Buffer)"]
Rec <--> Hand1["HTTP Handler / Router"]
end
subgraph Mode2["Режим 2: Настоящая сеть (httptest.NewServer)"]
Test2["Тест go test"]:::entry -->|"http.Client (sys_connect)"| Sock["Сетевой стек ОС (Loopback TCP)"]
Sock -->|"Порт 127.0.0.1:0 (например, 43912)"| Serv["httptest.Server (фоновая горутина)"]
Serv <--> Hand2["HTTP Handler / Router"]
end
Для полноценных интеграционных проверок архитектурно правильнее отдавать предпочтение httptest.NewServer().
Хотя накладные расходы на сокеты составляют дополнительные доли миллисекунды, реальный сервер проверяет жизненно важные аспекты: корректность заголовков управления соединением Connection: keep-alive или close, работу потоковой передачи данных (Chunked Transfer Encoding), поведение потоков сжатия gzip, обработку обрыва соединения клиентом (http.CloseNotifier / отмена контекста запроса) и лимиты на размер заголовков.
Благодаря динамическому выделению случайных портов ядром, httptest.NewServer() безупречно масштабируется и поддерживает запуск параллельных тестов с флагом t.Parallel().
Архитектура теста: Паттерн "Test Application"
В зрелых проектах интеграционные тесты не должны тонуть в рутине ручного создания соединений с базой данных, сборки графа зависимостей и конфигурирования серверов. Для этого проектируется специальный фасад окружения — паттерн Test Application (или TestEnv / TestApp).
Структура TestApp объединяет все компоненты запущенного стенда и инкапсулирует аккуратную очистку ресурсов через t.Cleanup:
package integration_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"yourproject/internal/api"
"yourproject/internal/repository"
"yourproject/internal/service"
)
// TestApp агрегирует запущенные подсистемы для сквозного тестирования
type TestApp struct {
Server *httptest.Server
DB *pgxpool.Pool
Client *http.Client
}
// setupTestApp поднимает боевой граф зависимостей с тестовыми хранилищами
func setupTestApp(t *testing.T) *TestApp {
t.Helper()
// 1. Извлекаем пул тестовой базы данных (например, поднятой через Testcontainers)
dbPool := getTestDBPool(t)
// 2. Инициализируем реальные слои доменной логики
userRepo := repository.NewUserRepo(dbPool)
userSvc := service.NewUserService(userRepo)
// 3. Собираем реальный HTTP-роутер приложения со всеми middleware
router := api.NewRouter(userSvc)
// 4. Поднимаем реальный тестовый HTTP-сервер на эфемерном порту
server := httptest.NewServer(router)
// 5. Регистрируем гарантированное закрытие сетевого сокета
t.Cleanup(func() {
server.Close()
})
return &TestApp{
Server: server,
DB: dbPool,
// server.Client() возвращает готовый *http.Client, преднастроенный под сетевой адрес сервера
Client: server.Client(),
}
}
Полный цикл: От JSON до базы и обратно
Напишем эталонный интеграционный тест, проверяющий создание учетной записи пользователя. Тест выполнит полный оборот:
- Сериализует JSON и отправит его методом POST на динамический URL нашего
httptest.Server. - Проверит заголовки, HTTP статус-код и структуру возвращенного JSON-тела.
- Напрямую обратится в PostgreSQL, минуя HTTP-слой, чтобы убедиться: транзакция зафиксирована, и записи физически присутствуют в таблице.
package integration_test
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestAPI_CreateUser_Success(t *testing.T) {
t.Parallel()
// Поднимаем изолированное тестовое приложение
app := setupTestApp(t)
// 1. Arrange: Формируем полезную нагрузку
reqBody := `{"name": "Gopher", "email": "gopher@golang.org"}`
// Формируем целевой URL (app.Server.URL хранит адрес вроде "http://127.0.0.1:43912")
req, err := http.NewRequest(http.MethodPost, app.Server.URL+"/api/v1/users", strings.NewReader(reqBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
// 2. Act: Выполняем сетевой запрос через реальный сокет
resp, err := app.Client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// 3. Assert HTTP Layer: Проверяем транспортный контракт
require.Equal(t, http.StatusCreated, resp.StatusCode)
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
var respPayload struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
err = json.NewDecoder(resp.Body).Decode(&respPayload)
require.NoError(t, err)
require.Greater(t, respPayload.ID, int64(0), "Идентификатор созданной сущности должен быть положительным")
require.Equal(t, "Gopher", respPayload.Name)
require.Equal(t, "gopher@golang.org", respPayload.Email)
// 4. Assert Database Layer: Проверяем состояние на диске
// Идем напрямую в базу данных, проверяя отсутствие транзакционных аномалий
var dbName, dbEmail string
err = app.DB.QueryRow(context.Background(),
"SELECT name, email FROM users WHERE id = $1", respPayload.ID,
).Scan(&dbName, &dbEmail)
require.NoError(t, err, "Созданная запись обязана существовать в таблице users")
require.Equal(t, "Gopher", dbName)
require.Equal(t, "gopher@golang.org", dbEmail)
}
Резюме
- HTTP интеграционные тесты проверяют полный вертикальный срез сервиса: от сырых сетевых байтов на сокете до коммита транзакции в постоянное хранилище.
- Для тестирования сквозных сценариев выбирайте
httptest.NewServer()вместоhttptest.NewRecorder(). Реальный системный вызовbindна адрес127.0.0.1:0с выделением эфемерного порта проверяет работу сетевого стека ядра, заголовков и пулов соединений. - Инкапсулируйте окружение в паттерн Test Application (
TestApp), делегируя очистку ресурсов механизмуt.Cleanup. - Всегда проверяйте побочные эффекты (Side Effects): успешного HTTP-статуса
200 OKнедостаточно — необходимо верифицировать физическое состояние базы данных.
Но как быть, если наш сервис вынужден делать исходящие HTTP-запросы во внешние сторонние системы (платежные шлюзы, CRM, внешние микросервисы)? Как эмулировать их поведение локально, быстро и без обращения в интернет? Об этом пойдет речь в следующей главе: 9. Тестирование внешних API.