Опирается на правила: R-SQLC-TEST-1, R-SQLC-TEST-2, R-SQLC-TEST-3, R-SQLC-TEST-4, R-SQLC-TEST-X1, R-SQLC-TEST-X2, R-SQLC-TEST-X3, R-SQLC-REPO-4 из sqlc Style Guide → раздел 10. Тестирование.

Важно знать

  • Тест репозитория — интеграционный, против реального Postgres. Mock pgx.Tx и pgxpool.Pool не тестируют реальный SQL и запрещены (R-SQLC-TEST-X1).
  • Контейнер создаётся один раз в TestMain на весь пакет; не поднимать per-test — это дорого (R-SQLC-TEST-4).
  • Изоляция данных между тестами — транзакция с defer tx.Rollback(ctx) в t.Cleanup; никаких TRUNCATE между тестами (R-SQLC-TEST-X2).
  • Миграции применяются через golang-migrate или goose в TestMain; репозиторий тестируется на актуальной схеме.
  • Каждый тест покрывает: happy-path, pgx.ErrNoRowsNotFoundError, constraint-нарушение → доменная ошибка, откат при ошибке (R-SQLC-TEST-3).
  • errors.As — единственный способ проверить тип доменной ошибки; errors.Is подходит только для сентинел-значений.
  • Тесты без изоляции ломаются при параллельном запуске и оставляют мусор в БД — t.Cleanup гарантирует откат даже при панике.

Persistence-слой на sqlc и pgx содержит реальный SQL: JOIN-ы, уникальные ограничения, поведение ErrNoRows, логику pgconn.PgError. Мокать пул или транзакцию — значит тестировать только обёртки, но не сам запрос. Поэтому тест репозитория идёт против живого Postgres, поднятого через testcontainers-go.

Инициализация контейнера в TestMain

R-SQLC-TEST-4: один контейнер на весь пакет. TestMain запускает Postgres, применяет миграции, создаёт pgxpool.Pool и передаёт его тестам через пакетную переменную.

// adapters/out/persistence/postgres_order_repository_test.go
package persistence_test

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

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/wait"
    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

var testPool *pgxpool.Pool

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

    container, 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 container: %v", err)
    }
    defer container.Terminate(ctx)

    dsn, err := container.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        log.Fatalf("connection string: %v", err)
    }

    runMigrations(dsn)

    testPool, err = pgxpool.New(ctx, dsn)
    if err != nil {
        log.Fatalf("create pool: %v", err)
    }
    defer testPool.Close()

    os.Exit(m.Run())
}

func runMigrations(dsn string) {
    m, err := migrate.New("file://../../../../db/migrations", dsn)
    if err != nil {
        log.Fatalf("migrate new: %v", err)
    }
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        log.Fatalf("migrate up: %v", err)
    }
}

postgres.Run из testcontainers-go/modules/postgres — удобная обёртка: сам выставляет POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD и ждёт готовности порта. migrate.ErrNoChange нормален: при повторном запуске миграции уже применены.

Изоляция через транзакцию с откатом

R-SQLC-TEST-2: каждый тест открывает транзакцию и регистрирует откат в t.Cleanup. Данные не накапливаются между тестами, TRUNCATE не нужен.

func TestPostgresOrderRepository_FindByID(t *testing.T) {
    ctx := context.Background()

    tx, err := testPool.Begin(ctx)
    require.NoError(t, err)
    t.Cleanup(func() { tx.Rollback(ctx) })

    repo := NewPostgresOrderRepository(testPool).WithTx(tx)

    order := &order.Order{
        ID:         uuid.MustParse("018f1b2c-3d4e-7f8a-9b0c-1d2e3f4a5b6c"),
        CustomerID: uuid.MustParse("018f1b2c-3d4e-7f8a-9b0c-000000000001"),
        Amount:     decimal.NewFromFloat(4500.00),
        Status:     order.StatusPending,
        CreatedAt:  time.Now().UTC(),
    }
    require.NoError(t, repo.Save(ctx, order))

    found, err := repo.FindByID(ctx, order.ID)

    require.NoError(t, err)
    assert.Equal(t, order.ID, found.ID)
    assert.Equal(t, order.Amount, found.Amount)
    assert.Equal(t, order.Status, found.Status)
}

WithTx(tx) передаёт транзакцию в репозиторий — и Save, и FindByID работают в одном соединении. Когда тест завершается (успешно или с ошибкой), t.Cleanup откатывает транзакцию. Никакой утечки данных в следующий тест.

Happy-path с nested-fetch

Для агрегатов с вложенными сущностями (Order + Items) happy-path проверяет, что маппер правильно собирает граф из flat-результата JOIN-а.

func TestPostgresOrderRepository_FindByID_WithItems(t *testing.T) {
    ctx := context.Background()

    tx, err := testPool.Begin(ctx)
    require.NoError(t, err)
    t.Cleanup(func() { tx.Rollback(ctx) })

    repo := NewPostgresOrderRepository(testPool).WithTx(tx)

    orderID := uuid.New()
    productID := uuid.New()

    q := db.New(tx)
    require.NoError(t, q.InsertOrder(ctx, db.InsertOrderParams{
        ID:         orderID,
        CustomerID: uuid.New(),
        Amount:     decimal.NewFromFloat(1200.00),
        Status:     "pending",
        CreatedAt:  time.Now().UTC(),
    }))
    require.NoError(t, q.InsertOrderItem(ctx, db.InsertOrderItemParams{
        ID:        uuid.New(),
        OrderID:   orderID,
        ProductID: productID,
        Quantity:  2,
        Price:     decimal.NewFromFloat(600.00),
    }))

    found, err := repo.FindByID(ctx, orderID)

    require.NoError(t, err)
    require.Len(t, found.Items, 1)
    assert.Equal(t, productID, found.Items[0].ProductID)
    assert.Equal(t, 2, found.Items[0].Quantity)
}

Вставка через db.Queries напрямую допустима в тесте — это позволяет подготовить state, не втягивая зависимости ProductRepository. Главное, что тот же tx используется репозиторием при чтении.

ErrNoRows → NotFoundError

R-SQLC-TEST-3 требует явной проверки: pgx.ErrNoRows должен транслироваться в доменную NotFoundError, не в nil, nil и не в raw pgx-ошибку.

func TestPostgresOrderRepository_FindByID_NotFound(t *testing.T) {
    ctx := context.Background()

    tx, err := testPool.Begin(ctx)
    require.NoError(t, err)
    t.Cleanup(func() { tx.Rollback(ctx) })

    repo := NewPostgresOrderRepository(testPool).WithTx(tx)

    _, err = repo.FindByID(ctx, uuid.New())

    var notFound *order.NotFoundError
    require.ErrorAs(t, err, &notFound)
}

require.ErrorAs проверяет конкретный тип ошибки по цепочке errors.As. Если репозиторий вернул nil, nil или прокинул сырой pgx.ErrNoRows — тест падает. Это и есть цель: гарантировать, что граница адаптера обрабатывает ErrNoRows правильно.

Constraint-нарушение → доменная ошибка

R-SQLC-TEST-3: нарушение 23505 (unique) должно возвращать доменную ошибку конфликта, а не технический pgconn.PgError.

func TestPostgresOrderRepository_Save_DuplicateID(t *testing.T) {
    ctx := context.Background()

    tx, err := testPool.Begin(ctx)
    require.NoError(t, err)
    t.Cleanup(func() { tx.Rollback(ctx) })

    repo := NewPostgresOrderRepository(testPool).WithTx(tx)

    o := &order.Order{
        ID:         uuid.New(),
        CustomerID: uuid.New(),
        Amount:     decimal.NewFromFloat(3000.00),
        Status:     order.StatusPending,
        CreatedAt:  time.Now().UTC(),
    }

    require.NoError(t, repo.Save(ctx, o))

    err = repo.Save(ctx, o)

    var alreadyExists *order.AlreadyExistsError
    require.ErrorAs(t, err, &alreadyExists)
    assert.Equal(t, o.ID, alreadyExists.ID)
}

После первого Save второй нарушает PRIMARY KEY. errors.As проверяет, что репозиторий перевёл pgconn.PgError{Code: "23505"} в *order.AlreadyExistsError.

Транзакционный откат при ошибке

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

func TestPlaceOrderHandler_RollsBackOnError(t *testing.T) {
    ctx := context.Background()

    handler := NewPlaceOrderHandler(testPool, NewPostgresOrderRepository(testPool))

    cmd := PlaceOrderCommand{
        CustomerID: uuid.New(),
        ProductID:  uuid.New(), // не существует в БД
        Quantity:   1,
    }

    err := handler.Handle(ctx, cmd)
    require.Error(t, err)

    tx, _ := testPool.Begin(ctx)
    t.Cleanup(func() { tx.Rollback(ctx) })

    repo := NewPostgresOrderRepository(testPool).WithTx(tx)
    orders, listErr := repo.ListByCustomer(ctx, cmd.CustomerID)
    require.NoError(t, listErr)
    assert.Empty(t, orders)
}

Этот тест работает на уровне Handler, не репозитория — именно там живёт граница транзакции. Репозиторий через WithTx подтянут в ту же транзакцию handler'а; при ошибке Handler делает tx.Rollback в defer.

Customer domain — дополнительный пример

Не все агрегаты такие же простые, как Order. Customer с несколькими адресами:

func TestPostgresCustomerRepository_FindByID_WithAddresses(t *testing.T) {
    ctx := context.Background()

    tx, _ := testPool.Begin(ctx)
    t.Cleanup(func() { tx.Rollback(ctx) })

    q := db.New(tx)
    customerID := uuid.New()

    require.NoError(t, q.InsertCustomer(ctx, db.InsertCustomerParams{
        ID:    customerID,
        Email: "ivanoff@sber.ru",
        Name:  "Иванов Иван",
    }))
    for i := range 2 {
        require.NoError(t, q.InsertCustomerAddress(ctx, db.InsertCustomerAddressParams{
            ID:         uuid.New(),
            CustomerID: customerID,
            City:       fmt.Sprintf("Москва-%d", i),
            Street:     "ул. Тверская",
        }))
    }

    repo := NewPostgresCustomerRepository(testPool).WithTx(tx)
    found, err := repo.FindByID(ctx, customerID)

    require.NoError(t, err)
    assert.Equal(t, "ivanoff@sber.ru", found.Email)
    require.Len(t, found.Addresses, 2)
}

Структура та же: db.New(tx) для подготовки данных, repo.WithTx(tx) для читающего метода, t.Cleanup для отката.

Что запрещено

АнтипаттернПравилоЧто взамен
Mock pgxpool.Pool или pgx.Tx в тесте репозиторияR-SQLC-TEST-X1Testcontainers Postgres, тест реального SQL
Shared state между тестами без изоляции (TRUNCATE, глобальные данные)R-SQLC-TEST-X2tx.Rollback в t.Cleanup на каждый тест
Только happy-path без проверки ошибокR-SQLC-TEST-X3Отдельные тест-функции для ErrNoRows, 23505, отката TX
Per-test поднятие контейнераR-SQLC-TEST-4TestMain поднимает один контейнер на пакет
errors.Is(err, pgx.ErrNoRows) в тестеrequire.ErrorAs(t, err, &notFound) — проверяем доменный тип
Тест репозитория зависит от другого репозиторияВставка через db.New(tx) напрямую в arrange-части

Куда дальше

  • Repository pattern в Go — sqlc + pgx/v5 — как устроен PostgresOrderRepository и метод WithTx, который тесты используют для изоляции.
  • Ошибки и pgx в sqlc — как pgx.ErrNoRows и pgconn.PgError превращаются в NotFoundError и AlreadyExistsError, которые проверяет errors.As в тестах.
  • Маппинг sqlc ↔ domain — функция toDomain и сборка агрегата из flat-результата; именно её корректность проверяет happy-path с Items.