Опирается на правила: 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-4postgres.Run один раз, m.Run() запускает тесты, pg.Terminate завершает контейнер явно.
  • GOTEST-5testDSN — пакетная переменная; ни один тест не содержит строку "postgres://test:test@localhost:...".
  • GOTEST-6applyMigrations вызывается один раз до 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}
}

Что происходит:

  1. pgxpool.New открывает пул соединений к тому же Postgres, что и TestMain.
  2. t.Cleanup(pool.Close) — пул закрывается после теста, соединения возвращаются.
  3. clock и ids — детерминированные заглушки (правила GOTEST-8 и GOTEST-9 — тема статьи go/determinism.md).
  4. httptest.NewServer(r) — реальный HTTP-сервер на случайном порту; слушает на 127.0.0.1.
  5. 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-4TestMain — один контейнер на пакет
defer pg.Terminate(ctx) в TestMainGOTEST-4явный вызов после m.Run() до os.Exit
Хардкод "postgres://test:test@localhost:5432/testdb"GOTEST-5testDSN из pg.ConnectionString
DROP TABLE / CREATE TABLE между тестамиGOTEST-X3TRUNCATE ... CASCADE в DatabasePreparer.Clear
applyMigrations внутри каждого тестаGOTEST-6один раз в TestMain до m.Run()
httptest.NewServer без t.Cleanup(srv.Close)GOTEST-7t.Cleanup(srv.Close) обязательно
t.Parallel() с общим TRUNCATE CASCADEGOTEST-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-блоки, require vs assert.
  • Пирамида тестов — когда интеграционный тест, когда unit, когда E2E с build-tag e2e.