Опирается на правила:
GOTEST-4,GOTEST-5,GOTEST-6,GOTEST-7,GOTEST-X3из Go Test Strategy → раздел 2. Инфраструктура теста (TestMain + контейнер).
Важно знать
- Один
TestMainна пакет — поднимаетpostgres.Run(...)единожды, не пересоздаёт контейнер между тестами.- DSN в пакетной переменной (
var testDSN string), не в константе и не хардкодом в каждом тесте.- Схема — один раз при старте:
applyMigrations(testDSN)вTestMain; между тестами толькоTRUNCATE ... CASCADE.deferне выполняется доos.Exit— контейнер завершают явнымpg.Terminate(ctx)послеm.Run().newTestServer(t)— вспомогательная функция: собираетsqlc.New(pool)+ chi-роутер +httptest.NewServer, регистрируетt.Cleanup(srv.Close).pgxpool.NewвnewTestServerоткрывает новый пул под каждый тест;t.Cleanup(pool.Close)возвращает соединения.- Kafka и Redis не поднимаем — их отсутствие структурное, не упущение. Детали — в go/no-kafka-redis-async.md.
Почему TestMain, а не setup в каждом тесте
В Go нет аналога @BeforeAll из JUnit. TestMain(m *testing.M) — единственная точка, которая отрабатывает до и после всех тестов пакета. Именно здесь живут операции, которые дорого повторять на каждый тест: поднять контейнер, применить миграции, дождаться готовности порта.
Если разместить postgres.Run(...) внутри TestCreateOrder_Success, каждый тест будет стартовать новый Docker-контейнер — несколько секунд против нескольких миллисекунд. Сотня тестов превратится в CI-пробку.
GOTEST-6: схема разворачивается один раз — это принципиально. DROP TABLE / CREATE TABLE между тестами медленнее TRUNCATE на два порядка и, что важнее, маскирует ошибки в самих миграциях: если миграция идемпотентна только при пустой схеме, вы никогда это не узнаете.
TestMain — каноническая форма
// order/integration_test.go
package order_test
import (
"context"
"log"
"os"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
var testDSN string
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("5432/tcp")),
)
if err != nil {
log.Fatalf("start postgres: %v", err)
}
testDSN, _ = pg.ConnectionString(ctx, "sslmode=disable")
applyMigrations(testDSN)
code := m.Run()
_ = pg.Terminate(ctx)
os.Exit(code)
}
Разбор по правилам:
GOTEST-4—postgres.Runодин раз,m.Run()запускает тесты,pg.Terminateзавершает контейнер явно.GOTEST-5—testDSN— пакетная переменная; ни один тест не содержит строку"postgres://test:test@localhost:...".GOTEST-6—applyMigrationsвызывается один раз доm.Run(), не внутри тестов.defer pg.Terminate(ctx)— нельзя:os.Exitвнутриm.Run()пропускает всеdeferвызывающей функции. Контейнер останется жить в Docker после завершения прогона.
applyMigrations
Функцию реализуют через goose или golang-migrate — выбор не принципиален для правила:
func applyMigrations(dsn string) {
db, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer db.Close()
if err := goose.Up(db, "../../db/migrations"); err != nil {
log.Fatalf("migrate: %v", err)
}
}
Путь до миграций — относительный от пакета с тестом. Если пакет переедет, CI сразу сообщит об ошибке — это лучше, чем хранить путь в переменной окружения и узнавать о проблеме в проде.
newTestServer — сборка сервера под тест
GOTEST-7: каждый тест получает свой httptest.Server. Это даёт полную изоляцию: разные тесты могут использовать разные fixedClock, разные seqIDGenerator, разные stub-зависимости.
func newTestServer(t *testing.T) (*httptest.Server, *OrderDatabasePreparer) {
t.Helper()
pool, err := pgxpool.New(context.Background(), testDSN)
if err != nil {
t.Fatalf("pgxpool: %v", err)
}
t.Cleanup(pool.Close)
q := db.New(pool)
repo := order.NewRepository(q)
clock := &fixedClock{at: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}
ids := &seqIDGenerator{}
svc := order.NewService(repo, clock, ids)
r := chi.NewRouter()
orderhttp.Mount(r, svc)
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
return srv, &OrderDatabasePreparer{pool: pool}
}
Что происходит:
pgxpool.Newоткрывает пул соединений к тому же Postgres, что иTestMain.t.Cleanup(pool.Close)— пул закрывается после теста, соединения возвращаются.clockиids— детерминированные заглушки (правилаGOTEST-8иGOTEST-9— тема статьи go/determinism.md).httptest.NewServer(r)— реальный HTTP-сервер на случайном порту; слушает на127.0.0.1.t.Cleanup(srv.Close)— сервер останавливается после теста.
OrderDatabasePreparer получает тот же pool — тест управляет состоянием БД через preparer (детали — go/database-preparer.md).
Полный тест после сборки инфраструктуры
func TestCreateOrder_Success(t *testing.T) {
srv, prep := newTestServer(t)
// Arrange
prep.Clear(t).CreateCustomer(t, "customer-sber-1")
// Act
resp, err := http.Post(
srv.URL+"/orders",
"application/json",
strings.NewReader(`{"customerId":"customer-sber-1","amount":5000}`),
)
require.NoError(t, err)
defer resp.Body.Close()
// Assert
require.Equal(t, http.StatusCreated, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
assert.Equal(t, "customer-sber-1", body["customerId"])
}
Тест не знает ничего о Postgres-контейнере, DSN, миграциях. Вся инфраструктура скрыта в TestMain и newTestServer.
Параллельность и изоляция
GOTEST-18: t.Parallel() с общей БД и TRUNCATE CASCADE образует гонку данных — два теста одновременно чистят одни и те же таблицы. Есть два подхода:
Транзакционная изоляция (предпочтительно для CRUD без DDL):
func newTestServer(t *testing.T) (*httptest.Server, *OrderDatabasePreparer) {
t.Helper()
conn, err := pgx.Connect(context.Background(), testDSN)
if err != nil {
t.Fatalf("connect: %v", err)
}
tx, err := conn.Begin(context.Background())
if err != nil {
t.Fatalf("begin tx: %v", err)
}
t.Cleanup(func() {
_ = tx.Rollback(context.Background())
conn.Close(context.Background())
})
q := db.New(tx)
// ...
}
Каждый тест работает в собственной транзакции, откат в t.Cleanup — изоляция без TRUNCATE. При таком подходе t.Parallel() безопасен.
Последовательный прогон (когда транзакционная изоляция неприменима — DDL или TRUNCATE внутри теста):
func TestCreateOrder_Success(t *testing.T) {
srv, prep := newTestServer(t)
prep.Clear(t).CreateCustomer(t, "customer-1")
// ...
}
Один контейнер на все тесты пакета
Распространённая ошибка — поднимать контейнер в TestMain, но затем добавлять ещё один postgres.Run в отдельном файле пакета. Go запускает один TestMain на пакет, поэтому второй postgres.Run создаёт второй контейнер без причины.
Правило: один TestMain на пакет, один testDSN. Если у сервиса несколько пакетов с интеграционными тестами (order_test, product_test, customer_test), каждый держит свой TestMain — но это нормально, Docker выдержит несколько контейнеров параллельно.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
postgres.Run(...) внутри каждого тест-функции | GOTEST-4 | TestMain — один контейнер на пакет |
defer pg.Terminate(ctx) в TestMain | GOTEST-4 | явный вызов после m.Run() до os.Exit |
Хардкод "postgres://test:test@localhost:5432/testdb" | GOTEST-5 | testDSN из pg.ConnectionString |
DROP TABLE / CREATE TABLE между тестами | GOTEST-X3 | TRUNCATE ... CASCADE в DatabasePreparer.Clear |
applyMigrations внутри каждого теста | GOTEST-6 | один раз в TestMain до m.Run() |
httptest.NewServer без t.Cleanup(srv.Close) | GOTEST-7 | t.Cleanup(srv.Close) обязательно |
t.Parallel() с общим TRUNCATE CASCADE | GOTEST-18 | транзакционная изоляция или последовательный прогон |
Куда дальше
- go/determinism.md —
ClockиIDGenerator: как передатьfixedClockиseqIDGeneratorвnewTestServerи получить детерминированные тесты. - go/database-preparer.md —
OrderDatabasePreparer: fluent-методыClear(t),CreateCustomer(t, ...),CreateOrder(t, ...)и правильный порядокTRUNCATE CASCADE. - go/one-test.md — структура одного теста:
Test<Action>_<Condition>_<Expected>, AAA-блоки,requirevsassert. - Пирамида тестов — когда интеграционный тест, когда unit, когда E2E с build-tag
e2e.