← назад к разделу

Модульные тесты проверяют логику в изоляции, но не говорят, правильно ли сервис работает с базой, брокером или соседним сервисом. Для этого нужны интеграционные тесты — с настоящими зависимостями. В Go для них не нужен ни фреймворк, ни контейнер зависимостей: пакет testing, TestMain и библиотека testcontainers-go, которая поднимает PostgreSQL в Docker прямо из теста.

Обязательно

Проблема заглушек и sqlmock

Самый быстрый способ протестировать репозиторий — подменить базу. В Go для этого есть два соблазна, и оба обманывают одинаково.

Заглушка репозитория на map: тест обработчика проходит, но сам SQL ни разу не выполнился. Запрос с опечаткой в имени колонки, неверный JOIN, забытый WHERE deleted_at IS NULL — всё это тест не видит, потому что репозиторий в нём ненастоящий.

sqlmock выглядит честнее: он перехватывает database/sql и сверяет текст запроса с ожиданием. Но сверяет он с вашим текстом: тест записывает ExpectQuery("SELECT (.+) FROM orders") и возвращает строки, которые вы же и придумали. Планировщик, типы колонок, ограничения уникальности, поведение RETURNING при конфликте — ничего из этого в sqlmock нет. Тест зелёный ровно до прода.

Интеграционный тест поднимает тот же PostgreSQL, что работает в проде, и убирает целый класс ошибок: запрос действительно выполняется, колонки действительно раскладываются по полям структуры, UNIQUE действительно не даёт вставить дубль.

testcontainers-go: PostgreSQL один раз на пакет

testcontainers-go запускает Docker-контейнер из кода теста. Для PostgreSQL есть готовый модуль, и он надёжнее общего GenericContainer: ждёт не открытия порта, а строки «ready to accept connections» во второй раз — образ при первом старте поднимается дважды, и ожидание по порту иногда пускает тест раньше времени.

Контейнер стоит секунды, поэтому его поднимают один раз на пакет в TestMain, а не в каждом тесте:

package order_test

import (
    "context"
    "log"
    "os"
    "testing"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
)

var pool *pgxpool.Pool

func TestMain(m *testing.M) {
    ctx := context.Background()

    ctr, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("shop"),
        postgres.WithUsername("shop"),
        postgres.WithPassword("shop"),
        postgres.BasicWaitStrategies(),
    )
    if err != nil {
        log.Fatalf("postgres: %v", err)
    }

    dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        log.Fatalf("dsn: %v", err)
    }
    pool, err = pgxpool.New(ctx, dsn)
    if err != nil {
        log.Fatalf("pool: %v", err)
    }

    code := m.Run()

    pool.Close()
    if err := testcontainers.TerminateContainer(ctr); err != nil {
        log.Printf("terminate: %v", err)
    }
    os.Exit(code)
}

TestMain выполняется один раз на пакет: до всех тестов поднимает контейнер и пул, после всех — гасит. m.Run() возвращает код выхода, и его обязательно передают в os.Exit, иначе красный прогон выйдет зелёным.

Две оговорки. Первая: TestMain живёт в одном пакете, и у двадцати пакетов будет двадцать контейнеров — go test ./... гоняет пакеты параллельно, и это двадцать стартов. Выход — держать интеграционные тесты в немногих пакетах или вынести общий старт в вспомогательный пакет, который возвращает строку подключения из переменной среды, если контейнер уже поднят снаружи. Вторая: контейнер не удаляется, если процесс убили сигналом до TerminateContainer. За уборкой следит сторожевой контейнер Ryuk, который testcontainers поднимает сам: он гасит всё, что осталось после завершения процесса.

Откуда в пустом контейнере таблицы

Контейнер поднимается с пустой базой: в образе postgres:16-alpine нет ни одной вашей таблицы. Кто их создаёт?

Те же миграции, что и в проде. В Go миграции не запускаются «при старте контекста», как это делает Spring: их запускает ваш код, обычно в main или в отдельной команде. В тестах то же самое — накатить миграции в TestMain сразу после подъёма контейнера:

import (
    "database/sql"
    "embed"

    _ "github.com/jackc/pgx/v5/stdlib"
    "github.com/pressly/goose/v3"
)

//go:embed migrations/*.sql
var migrations embed.FS

func migrate(dsn string) error {
    db, err := sql.Open("pgx", dsn)
    if err != nil {
        return err
    }
    defer db.Close()
    goose.SetBaseFS(migrations)
    if err := goose.SetDialect("postgres"); err != nil {
        return err
    }
    return goose.Up(db, "migrations")
}

Миграции встроены в бинарник через embed, поэтому тест, сервис и команда миграции накатывают один и тот же набор файлов — расходиться нечему. Побочная польза та же, что везде: каждый прогон интеграционных тестов проверяет, что миграции применяются на чистой базе с нуля.

Самая дорогая ловушка в этом месте — создавать схему в тесте отдельным CREATE TABLE «для скорости» или держать для тестов свой SQL-файл. Через месяц тестовая схема расходится с миграциями, тесты зелёные, а прод падает на колонке, которой в миграции нет. Схема у тестов одна — та, что накатывают миграции.

Справочные данные (статусы, типы, тарифы), которые в проде вставляет миграция, появятся в тестовой базе сами и должны там остаться. Это важно, когда дойдёте до очистки данных между тестами.

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

Контейнер один на пакет — значит, база одна на все тесты. Первый тест создал заказ, второй посчитал заказы и получил не ту цифру; третий проходит один и падает в наборе. Способов изолировать три, и границы у них разные.

Откат транзакции. Репозиторий в Go обычно принимает не пул, а интерфейс — у sqlc он называется DBTX и реализуется и пулом, и транзакцией. Тест открывает транзакцию, отдаёт её репозиторию и откатывает в t.Cleanup:

func TestOrderRepo_Save(t *testing.T) {
    ctx := context.Background()
    tx, err := pool.Begin(ctx)
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { _ = tx.Rollback(ctx) })

    repo := order.NewRepo(tx)
    id, err := repo.Save(ctx, order.New("cust-1", 500))
    if err != nil {
        t.Fatal(err)
    }
    got, err := repo.ByID(ctx, id)
    if err != nil {
        t.Fatal(err)
    }
    if got.CustomerID != "cust-1" {
        t.Errorf("customer: %q", got.CustomerID)
    }
}

Быстро и ничего не надо убирать. Ограничений три. Не проверяется поведение при фиксации: отложенные ограничения и всё, что происходит в момент COMMIT, не случается вовсе. Не работает через настоящий HTTP: обработчик берёт соединение из пула, а не вашу транзакцию, и его данные остаются в базе. И такие тесты нельзя пускать параллельно, если репозиторий внутри сам открывает транзакции: вложенных транзакций в PostgreSQL нет, pgx обойдётся точкой сохранения, но поведение уже не то, что в проде.

Очистка таблиц. Честный способ для всего, что проходит через HTTP или проверяет фиксацию: тест пишет по-настоящему, а помощник возвращает базу в исходное состояние одним запросом:

func truncateAll(t *testing.T) {
    t.Helper()
    ctx := context.Background()
    rows, err := pool.Query(ctx, `
        SELECT quote_ident(tablename) FROM pg_tables
        WHERE schemaname = 'public'
          AND tablename NOT IN ('goose_db_version', 'order_status_ref')`)
    if err != nil {
        t.Fatal(err)
    }
    tables, err := pgx.CollectRows(rows, pgx.RowTo[string])
    if err != nil {
        t.Fatal(err)
    }
    _, err = pool.Exec(ctx, "TRUNCATE TABLE "+strings.Join(tables, ", ")+" RESTART IDENTITY CASCADE")
    if err != nil {
        t.Fatal(err)
    }
}

Все таблицы в одном TRUNCATE, тогда внешние ключи не мешают; RESTART IDENTITY сбрасывает счётчики; таблица версий миграций и справочники исключены, иначе следующий тест не найдёт того, что в проде есть всегда. Список таблиц берётся из базы, и новая таблица попадает в уборку сама.

Уникальные данные. Ничего не удалять, а делать каждый тест независимым по данным: свой uuid.New() на покупателя, свой номер заказа. Порядок не важен, соседи не мешают, и это единственный способ, который выживает при t.Parallel() на одной базе. Цена: любая проверка «а сколько всего записей» становится неверной, считать надо всегда с условием по своему ключу.

Как выбрать: запросы репозитория — откат транзакции; сквозные тесты через HTTP и всё, что проверяет фиксацию, — очистка; параллельный прогон — уникальные данные.

Сквозной тест: HTTP до базы и обратно

В Go для сквозного теста не нужен ни сервер, ни порт: роутер собирается с настоящим пулом, а запрос проходит через httptest:

func TestCreateOrder(t *testing.T) {
    truncateAll(t)
    srv := app.NewRouter(pool, slog.Default())

    body := strings.NewReader(`{"customerId":"cust-1","lines":[{"sku":"A1","qty":2}]}`)
    req := httptest.NewRequest(http.MethodPost, "/api/v1/orders", body)
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()

    srv.ServeHTTP(rec, req)

    if rec.Code != http.StatusCreated {
        t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
    }
    var n int
    if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM orders`).Scan(&n); err != nil {
        t.Fatal(err)
    }
    if n != 1 {
        t.Errorf("orders: %d", n)
    }
}

Такой тест проверяет ровно то, чего не видит ни заглушка, ни тест репозитория по отдельности: разбор тела, валидацию, обработчик, транзакцию в сценарии и то, что в базе действительно появилась строка. Проверять результат нужно в базе, а не только по коду ответа: 201 с пустой таблицей — классическая ошибка сценария, который открыл транзакцию и не зафиксировал.

Не только PostgreSQL: Kafka, Redis, свои образы

testcontainers-go — библиотека не про базу, а про «подними мне что угодно из образа и дай адрес». Три сценария, которые нужны почти всем.

Брокер. Готовый модуль modules/kafka поднимает брокер в режиме KRaft, без ZooKeeper, и отдаёт адреса через ctr.Brokers(ctx). Старт занимает секунды, поэтому его тоже держат на пакет. Проверка обязательно с ожиданием, а не следующей строкой: сообщение доходит до потребителя асинхронно, и if len(received) != 1 сразу после записи будет мигать. Опрашивают условие в цикле с паузой и пределом — require.Eventually из testify или свой помощник на time.Tick.

Что угодно из образа. Нет готового модуля — берут GenericContainer: заглушка внешнего партнёра, хранилище, совместимое с S3, почтовый ловец:

ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
    ContainerRequest: testcontainers.ContainerRequest{
        Image:        "minio/minio:RELEASE.2024-10-13T13-34-11Z",
        Cmd:          []string{"server", "/data"},
        ExposedPorts: []string{"9000/tcp"},
        WaitingFor:   wait.ForListeningPort("9000/tcp"),
    },
    Started: true,
})

Адрес и порт всегда спрашивают у контейнера (ctr.Host(ctx), ctr.MappedPort(ctx, "9000")), а не пишут напрямую: изнутри контейнера порт свой, а снаружи случайный.

Контейнер между прогонами. Чтобы не платить стартом на каждый go test локально, контейнер оставляют жить: в запросе задают Name и Reuse: true, и следующий прогон подхватит уже работающий контейнер вместо нового. В сборке так обычно не делают — там контейнер поднимается заново. Из этого следует неочевидное требование: переиспользуемый контейнер сохраняет данные между прогонами, поэтому тесты обязаны быть чистыми сами по себе. Тест, который проходит на свежей базе и падает на второй запуск, — это найденная зависимость от пустой базы.

Цена входа: тестам нужен Docker

Про testcontainers говорят «достаточно установленного Docker», и за этим стоит несколько решений, принимаемых один раз на команду.

Агент сборки. Тестам нужен доступ к демону Docker, а агент обычно сам работает в контейнере. Варианты: смонтировать сокет демона хоста, поднять отдельный демон рядом или указать DOCKER_HOST на удалённый — тогда контейнеры поднимаются на выделенной машине. Для облачных агентов у всех крупных систем сборки Docker есть из коробки.

Машина разработчика. Docker Desktop в больших компаниях платный. Заменители работают: Colima и Rancher Desktop на macOS, Podman с сокетом, совместимым с Docker. testcontainers ищет демон по стандартным путям; если не нашёл — путь задаётся переменной DOCKER_HOST. Тем, у кого Docker нет вовсе, интеграционные тесты выключают локально, но не в сборке:

//go:build integration

package order_test

go test ./... такие файлы пропустит, go test -tags integration ./... включит. Второй способ — testing.Short(): тест вызывает t.Skip() при go test -short, и это удобнее, когда интеграционные и обычные тесты живут в одном пакете.

Закрытый контур. Без выхода в интернет образы не скачаются. Решение — внутреннее зеркало реестра и переменная TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX, которая дописывает его ко всем именам образов; в зеркало кладут и образ Ryuk, иначе «всё скачалось, а тесты не стартуют».

Время и ресурсы. Каждый контейнер — память и секунды старта. Поэтому контейнеры поднимают один раз на пакет, а не на тест, и не поднимают то, что в этом пакете не нужно.

Когда полная связка, а когда срез

СитуацияЧто использовать
Запросы репозитория, миграции, ограничения базыконтейнер + откат транзакции
Сквозной путь HTTP → сценарий → база → ответhttptest на роутере с настоящим пулом + очистка
Разбор тела, валидация, коды ответа обработчикаhttptest + заглушка сценария, без базы
Бизнес-правила доменаобычный тест, без всего

Правило то же, что и везде: поднимать ровно столько, сколько нужно. В Go цена лишнего не «ещё один контекст», как в Spring, а ещё один контейнер на пакет и лишние секунды в каждом прогоне.

Дополнительно: при первом чтении можно пропустить

Глубже: мигающие тесты: общее состояние, паузы, порты и параллельный прогонрасширенное

Тест, который то зелёный, то красный, разрушает доверие к сборке быстрее, чем отсутствие тестов. Причин немного, и каждая находится.

Общее состояние. Переменная пакета, кэш, строка в базе от предыдущего теста. Находят запуском в случайном порядке: go test -shuffle=on ./... перемешивает тесты и подтесты и печатает зерно, чтобы повторить порядок. Тест, который проходит один (-run) и падает в наборе, зависит от соседей.

Паузы вместо ожиданий. time.Sleep(500 * time.Millisecond) перед проверкой асинхронного результата проходит на ноутбуке и падает на загруженном раннере. Ждут условия, а не времени: require.Eventually(t, cond, 5*time.Second, 50*time.Millisecond). С Go 1.25 для кода, зависящего от времени и горутин, есть testing/synctest: внутри synctest.Test время виртуальное, time.Sleep не ждёт по-настоящему, и такие тесты перестают мигать вовсе.

Время и случайность. Код, читающий time.Now() напрямую, падает в полночь и при смене пояса раннера. Время внедряют функцией now func() time.Time и в тесте подставляют фиксированную дату; случайность берут из rand.New(rand.NewPCG(seed, seed)) с зерном, которое печатают при падении.

Порты и ресурсы. httptest.NewServer сам берёт свободный порт — свой ListenAndServe(":8080") в тесте писать не надо. Контейнер один на пакет, а go test ./... гоняет пакеты параллельно: два пакета с одним Name и Reuse: true поделят базу.

Гонки. go test -race ./... обязателен в сборке: гонка в обработчике не падает в обычном прогоне и годами живёт в проде. t.Parallel() включают постепенно и только там, где данные уникальны.

Процесс. Мигающий тест не перезапускают до зелёного, а помечают t.Skip с номером задачи и чинят; сборка считает долю перезапусков, и её рост — тревога.

Глубже: тест миграций: накатить на непустую базурасширенное

Статья дважды говорит, что схему накатывают миграции, и стоит показать, как это проверить. Проверок три.

С нуля. Любой интеграционный тест на контейнере это проверяет заодно: goose.Up на пустой базе падает на первой же сломанной миграции.

На непустой базе. Пустая таблица не покажет, что ALTER TABLE держит блокировку минуту, что SET NOT NULL на колонку с существующими NULL невозможен, что перелив данных не идемпотентен. Отдельный шаг конвейера восстанавливает снимок схемы прода с обезличенным образцом данных (или заполняет таблицы генератором до реалистичного размера) и применяет только новые миграции с lock_timeout, замеряя время. Запускают на PR, где менялся каталог миграций.

Старый бинарник на новой схеме. Во время выката предыдущая версия сервиса работает с новой схемой. Тесты предыдущей версии (образ с прошлым тегом уже есть) прогоняют против базы с новыми миграциями: зелёный прогон означает, что миграция совместима и откат безопасен. Красный — ломающее изменение, которое раскладывают на шаги.

Что тесты не проверят: таймауты на таблицах в миллиард строк, которые больше любого стенда. Для них остаются CONCURRENTLY, пакетный перелив и репетиция на копии.

Коротко

  • Заглушка репозитория и sqlmock проверяют ваши ожидания о SQL, а не SQL: интеграционный тест поднимает тот же PostgreSQL, что в проде, через testcontainers-go.
  • Контейнер стартует один раз на пакет в TestMain, модуль postgres.Run с BasicWaitStrategies ждёт настоящей готовности, код m.Run() уходит в os.Exit.
  • Таблицы создают те же миграции, что в проде: goose.Up из embed сразу после старта контейнера; отдельная тестовая схема расходится с миграциями за месяц.
  • Изоляция: откат транзакции через DBTX для репозиториев, TRUNCATE всех таблиц одним запросом для HTTP и фиксации, уникальные данные для t.Parallel().
  • Сквозной тест — httptest на роутере с настоящим пулом; результат проверяют в базе, а не только по коду ответа.
  • Kafka и Redis — готовые модули, остальное — GenericContainer; порт всегда спрашивают у контейнера; Reuse с именем оставляет контейнер между прогонами.
  • Docker нужен агенту сборки и машине разработчика; без него тесты выключают тегом integration или -short локально, но не в сборке.
  • Мигание лечат по причинам: -shuffle=on находит общее состояние, Eventually и synctest вместо пауз, внедрённое время и зерно, -race всегда.
  • Миграции проверяют трижды: с нуля, на снимке прода с данными и временем, старым бинарником против новой схемы.

Что почитать дальше