Модульные тесты проверяют логику в изоляции, но не говорят, правильно ли сервис работает с базой, брокером или соседним сервисом. Для этого нужны интеграционные тесты — с настоящими зависимостями. В 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всегда. - Миграции проверяют трижды: с нуля, на снимке прода с данными и временем, старым бинарником против новой схемы.
Что почитать дальше
- Тестирование в Go —
testing, табличные тесты,httptestи интерфейсы вместо моков. - Моки и внешние системы в тестах на Go — когда заглушка уместна,
httptest.Serverвместо партнёра, время и случайность. - Пирамида тестирования — как соотносятся модульные, интеграционные и сквозные тесты.
- Persistence: sqlc и pgx —
DBTX, транзакции и миграции в сервисе на Go.