Опирается на правила:
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.ErrNoRows→NotFoundError, 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, ¬Found)
}
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-X1 | Testcontainers Postgres, тест реального SQL |
| Shared state между тестами без изоляции (TRUNCATE, глобальные данные) | R-SQLC-TEST-X2 | tx.Rollback в t.Cleanup на каждый тест |
| Только happy-path без проверки ошибок | R-SQLC-TEST-X3 | Отдельные тест-функции для ErrNoRows, 23505, отката TX |
| Per-test поднятие контейнера | R-SQLC-TEST-4 | TestMain поднимает один контейнер на пакет |
errors.Is(err, pgx.ErrNoRows) в тесте | — | require.ErrorAs(t, err, ¬Found) — проверяем доменный тип |
| Тест репозитория зависит от другого репозитория | — | Вставка через 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.