Модуль: Тестирование · Уровень: Middle+/Senior
TL;DR#
Интеграционные тесты проверяют код против реальных зависимостей (БД, брокеры, кэши), а не моков. testcontainers-go поднимает эти зависимости в Docker-контейнерах на лету: тест получает реальный Postgres/Redis/Kafka с динамическим портом, использует его и уничтожает контейнер по завершении — без ручного docker-compose и без общей «тестовой» БД. TestMain(m *testing.M) даёт единую точку setup/teardown на весь пакет (один контейнер на пакет — баланс скорости и изоляции). Медленные/требующие Docker тесты отделяют от unit-тестов через build tags (//go:build integration) или testing.Short(), чтобы go test ./... оставался быстрым, а интеграционные шли отдельным шагом CI. Изоляция между тестами — через отдельные схемы/БД/префиксы, транзакции с откатом или truncate.
Теория#
Пирамида и место интеграционных тестов#
- Unit — быстрые, без I/O, моки/fakes. Большинство.
- Integration — реальная зависимость, медленнее, требуют Docker. Проверяют то, что моки не могут: реальный SQL-диалект, миграции, сериализацию, транзакционную семантику, поведение драйвера.
- E2E — вся система. Мало, дорого.
Моки репозитория не поймают опечатку в SQL, неверный тип колонки, нарушение констрейнта, особенности изоляции транзакций — это ловит интеграционный тест против настоящей БД.
testcontainers-go: базовый паттерн#
func setupPostgres(ctx context.Context, t *testing.T) (*pgxpool.Pool, func()) {
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "test",
"POSTGRES_DB": "app",
},
WaitingFor: wait.ForListeningPort("5432/tcp").
WithStartupTimeout(30 * time.Second),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
require.NoError(t, err)
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, "5432")
dsn := fmt.Sprintf("postgres://postgres:test@%s:%s/app?sslmode=disable", host, port.Port())
pool, err := pgxpool.New(ctx, dsn)
require.NoError(t, err)
cleanup := func() {
pool.Close()
_ = container.Terminate(ctx)
}
return pool, cleanup
}Ключевое:
- Динамический порт. Контейнер публикует случайный хостовый порт (
MappedPort) — параллельные прогоны не конфликтуют, нет хардкодаlocalhost:5432. - WaitingFor (wait strategy). Контейнер «запущен» ≠ «готов принимать соединения». Postgres логирует «ready» дважды (init + restart). Используйте
wait.ForSQL,wait.ForLog(...).WithOccurrence(2),wait.ForHTTP— а неtime.Sleep. Это главный источник флаки. - Модули. Для популярных сервисов есть готовые:
modules/postgres,modules/redis,modules/kafka— с корректными wait-стратегиями и хелперами (postgres.RunContainer(...),pgContainer.ConnectionString(...)). - Ryuk (reaper). testcontainers запускает контейнер-«сборщик мусора», который убивает забытые контейнеры даже при падении/kill теста. Можно отключить (
TESTCONTAINERS_RYUK_DISABLED=true), но тогда сами отвечаете за уборку.
TestMain: setup/teardown на пакет#
Поднимать контейнер на каждый тест дорого. TestMain поднимает один раз на пакет:
var testPool *pgxpool.Pool
func TestMain(m *testing.M) {
ctx := context.Background()
container, pool, err := startPostgres(ctx)
if err != nil {
log.Fatalf("setup: %v", err)
}
testPool = pool
code := m.Run() // запускает все тесты пакета
pool.Close()
_ = container.Terminate(ctx)
os.Exit(code) // ВАЖНО: код возврата из m.Run()
}Нюансы TestMain:
- Если объявлен, тесты запускаются только через
m.Run()— забыть его вызвать = тесты не выполнятся. os.Exit(m.Run())обходитdefer— teardown пишите ДОos.Exitявно, либо оборачивайте в функцию с defer и передавайте код вos.Exit.- Один на пакет. Для разной инфраструктуры — разбивайте на пакеты.
func TestMain(m *testing.M) {
os.Exit(run(m)) // run использует defer корректно
}
func run(m *testing.M) (code int) {
container, pool := mustStart()
defer func() { pool.Close(); _ = container.Terminate(context.Background()) }()
testPool = pool
return m.Run()
}Изоляция между тестами при общем контейнере#
Один контейнер на пакет → тесты делят БД. Варианты изоляции:
- Транзакция с откатом: каждый тест в своей транзакции,
defer tx.Rollback()— состояние не утекает. Минус: нельзя тестировать код, который сам управляет транзакциями/коммитит. - Отдельная схема/БД на тест:
CREATE SCHEMA test_<random>+search_path, или отдельная database. Полная изоляция, дороже. - Truncate/cleanup между тестами через
t.Cleanup: очищать таблицы. Просто, но требует осторожности с параллельностью. - Уникальные данные: префиксы/UUID в ключах, чтобы тесты не пересекались (для параллельных).
Для t.Parallel() с общей БД нужна реальная изоляция (схема/БД на тест), иначе гонки данных.
Build tags: разделение unit и integration#
//go:build integration
package repo_testЗапуск:
go test ./... # только unit (файлы без тега)
go test -tags=integration ./... # unit + integrationАльтернатива — testing.Short():
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
// ...
}go test -short ./... пропустит. Разница: build tag исключает файл из компиляции (быстрее, чище разделение, не тянет docker-зависимости в обычную сборку), -short компилирует, но пропускает в рантайме. Для тяжёлой инфраструктуры предпочтителен build tag.
CI#
- Отдельный job/stage для интеграционных: нужен Docker(-in-Docker) или доступ к docker-сокету.
- Unit-стадия быстрая, блокирует PR; интеграционная может быть тяжелее.
- testcontainers работает в CI, где есть Docker (GitHub Actions, GitLab с dind). Альтернатива в GitHub Actions —
services:контейнеры, но testcontainers даёт ту же конфигурацию из кода и переносимость на локальную машину.
Подводные камни / gotchas#
- Sleep вместо wait strategy — главный источник флаки. Контейнер стартовал ≠ сервис готов. Используйте
wait.ForSQL/ForLog(WithOccurrence)/ForHTTP. - Postgres «ready» дважды. Первый «ready» — во время init-скриптов, затем рестарт.
wait.ForLog("ready to accept connections").WithOccurrence(2)илиwait.ForSQL. os.Exitв TestMain съедает defer. Teardown доos.Exitили через обёртку с defer + возврат кода.- Забыли
m.Run()в TestMain — тесты не запустятся, а CI «зелёный». - Утечка контейнеров при kill теста. Ryuk подчищает; при
RYUK_DISABLED— ваша ответственность. - Параллельные тесты на общей БД без изоляции → гонки данных, флаки. Схема/БД на тест или отказ от parallel.
- Хардкод порта/хоста. Всегда
MappedPort/Host— иначе конфликт при параллельных прогонах и в CI. - Тяжесть. Контейнер на каждый тест убивает скорость. Один на пакет (TestMain) + изоляция данными/транзакциями.
- Зависимость от Docker. На машине без Docker интеграционные падают — отсюда build tags, чтобы
go test ./...не требовал Docker. - Pull образов в CI без кэша — медленно и хрупко (rate limits). Кэшируйте/пиньте теги (
postgres:16-alpine, неlatest). - Версия образа = версии прода. Тестируйте против той же мажорной версии БД, что в проде, иначе диалект/поведение разойдутся.
Вопросы на собеседовании#
В: Зачем интеграционные тесты, если есть моки репозитория? О: Моки не проверяют реальный SQL, миграции, типы колонок, констрейнты, транзакционную изоляцию, поведение драйвера и сериализацию. Интеграционный тест против настоящей БД ловит опечатки в запросах и расхождения с реальным движком, которые мок принципиально не увидит.
В: Что даёт testcontainers-go по сравнению с docker-compose для тестов? О: Зависимости описаны в коде теста, поднимаются/гасятся программно с динамическими портами (нет конфликтов и хардкода), корректные wait-стратегии, автоуборка через Ryuk, одинаково работает локально и в CI. Не нужно держать общую тестовую БД и синхронизировать compose-файл.
В: Почему нельзя ждать готовности контейнера через time.Sleep?
О: «Контейнер запущен» не значит «сервис принимает соединения» — время старта варьируется, Sleep либо флаки (мало), либо медленно (с запасом). Нужны wait strategies: wait.ForSQL, wait.ForListeningPort, wait.ForLog(...).WithOccurrence(n), wait.ForHTTP, которые опрашивают реальную готовность.
В: Зачем TestMain и какие с ним подводные камни?
О: Единая точка setup/teardown на пакет — поднять контейнер один раз, а не на каждый тест. Подводные камни: если TestMain объявлен, тесты идут только через m.Run(); os.Exit не вызывает defer, поэтому teardown делают до него или через обёртку с defer; один TestMain на пакет.
В: Как изолировать тесты при одном контейнере на пакет?
О: Транзакция с откатом на тест (быстро, но не для кода, который сам коммитит), отдельная схема/БД на тест (полная изоляция, дороже), truncate/cleanup через t.Cleanup, уникальные ключи/префиксы. Для t.Parallel() на общей БД нужна реальная изоляция (схема/БД), иначе гонки данных.
В: Чем build tag отличается от testing.Short() для разделения тестов?
О: Build tag (//go:build integration) исключает файл из компиляции без тега — обычная сборка не тянет docker-зависимости и быстрее. -short компилирует всё, но пропускает помеченные тесты в рантайме. Для тяжёлой инфраструктуры предпочтителен build tag.
В: Что такое Ryuk в testcontainers?
О: Контейнер-reaper, который testcontainers запускает рядом; он отслеживает и удаляет созданные тестом контейнеры/сети/тома даже при аварийном завершении (kill, паника), предотвращая утечку ресурсов. Отключается TESTCONTAINERS_RYUK_DISABLED, тогда уборка на вас.
В: Как держать интеграционные тесты быстрыми и стабильными в CI? О: Один контейнер на пакет (TestMain), изоляция данными вместо пересоздания, wait strategies вместо sleep, пиннинг и кэш образов, отдельный CI-stage с Docker, build tags чтобы PR-unit-стадия не зависела от Docker, версия образа = версии прода.
На что копают на senior+#
- Граница unit/integration: что мокать, а что проверять реально; почему мок репозитория недостаточен.
- Изоляция и параллелизм: транзакции vs схемы vs truncate, корректность при
t.Parallel, детерминизм. - Надёжность: wait strategies, искоренение sleep и флаки, Ryuk, утечки ресурсов.
- TestMain-механика: os.Exit vs defer, один на пакет, разбиение пакетов под разную инфраструктуру.
- CI-инженерия: docker-in-docker, кэш/пиннинг образов, разделение стадий, скорость пайплайна, паритет версий с продом.
- Стратегия тестирования: пирамида, стоимость/ценность интеграционных, contract tests (один набор против fake и реальной БД).