Модуль: Тестирование · Уровень: 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()
}

Изоляция между тестами при общем контейнере#

Один контейнер на пакет → тесты делят БД. Варианты изоляции:

  1. Транзакция с откатом: каждый тест в своей транзакции, defer tx.Rollback() — состояние не утекает. Минус: нельзя тестировать код, который сам управляет транзакциями/коммитит.
  2. Отдельная схема/БД на тест: CREATE SCHEMA test_<random> + search_path, или отдельная database. Полная изоляция, дороже.
  3. Truncate/cleanup между тестами через t.Cleanup: очищать таблицы. Просто, но требует осторожности с параллельностью.
  4. Уникальные данные: префиксы/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 и реальной БД).