Стабы из предыдущей части проверили порядок шагов и перевод ошибок, но ни один из них не открыл соединение с базой. Опечатка в имени колонки, `UPDATE` мимо транзакции и разъехавшееся поведение стаба и репозитория остаются в слепой зоне. Разберём тесты, которым нужен настоящий PostgreSQL, и цену, которую они за это берут.

Это шестая статья серии «Архитектура Go» и вторая половина разговора про тесты, начатого в пятой части. Код тот же: условный Go-модуль modular_shop, сценарий checkout.UseCase, модуль catalog за адаптером и платёжный сервис за HTTP-клиентом. Стабы callLog, productPortStub, paymentClientStub и receiptEnqueuerStub объявлены в stubs_test.go из пятой части и здесь используются как есть.

Содержание

  1. Что доказывают тесты с базой
  2. Изоляция тестов в PostgreSQL
  3. Интеграционный тест репозитория
  4. Интеграционный тест транзакции
  5. Сквозной тест сценария
  6. Что остаётся вне тестов
  7. Как не стоит заменять базу
  8. Практическая проверка тестов

Что доказывают тесты с базой

Карта уровней из пятой части заканчивалась двумя строчками, для которых подмена не работает:

  • PostgreSQL-репозиторий пишет SQL и переводит строку таблицы в order.Order. Заменять базу подделкой здесь нечем: проверять нужно результат запроса.
  • Transaction Manager вместе с use case отвечает за COMMIT и ROLLBACK, а постановщик фоновой задачи на отправку чека обязан писать в переданную ему транзакцию. Это receiptEnqueuer из четвёртой части: сам чек он не отправляет, а сохраняет в PostgreSQL задачу, которую позже заберёт воркер.

К ним добавляется сквозной тест сценария. Новых бизнес-веток он не добавляет, зато проходит через реальную сборку компонентов и проверяет то, чего не видит ни один изолированный тест: что счастливый путь работает на настоящих реализациях, а не только на стабах.

Плата у этих тестов общая: им нужна база, они на порядки медленнее unit-тестов, и запускать их приходится там, где PostgreSQL есть. Поэтому начинать стоит не с первого теста, а с того, как они уживаются друг с другом.

Изоляция тестов в PostgreSQL

Интеграционные тесты живут в одной базе и мешают друг другу быстрее, чем начинают что-либо доказывать. Подключение, изоляция и очистка выносятся в общий помощник:

// internal/pkg/testdb/postgres.go
package testdb

import (
	"os"
	"strings"
	"testing"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/stdlib"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"

	"modular_shop/internal/migrations"
)

func New(t *testing.T) *gorm.DB {
	t.Helper()

	if testing.Short() {
		t.Skip("integration test requires postgres")
	}

	dsn := dsnFromEnv(t)
	schema := "test_" + strings.ReplaceAll(uuid.NewString(), "-", "")

	admin := open(t, dsn, "")
	if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
		t.Fatalf("create schema %s: %v", schema, err)
	}

	t.Cleanup(func() {
		if err := admin.Exec(
			"DROP SCHEMA " + schema + " CASCADE",
		).Error; err != nil {
			t.Errorf("drop schema %s: %v", schema, err)
		}
	})

	db := open(t, dsn, schema)
	migrate(t, db)

	return db
}

func open(t *testing.T, dsn, schema string) *gorm.DB {
	t.Helper()

	config, err := pgx.ParseConfig(dsn)
	if err != nil {
		t.Fatalf("parse dsn: %v", err)
	}
	if schema != "" {
		config.RuntimeParams["search_path"] = schema
	}

	pool := stdlib.OpenDB(*config)
	pool.SetMaxOpenConns(4)

	t.Cleanup(func() {
		if err := pool.Close(); err != nil {
			t.Errorf("close pool: %v", err)
		}
	})

	db, err := gorm.Open(postgres.New(postgres.Config{Conn: pool}))
	if err != nil {
		t.Fatalf("open database: %v", err)
	}

	return db
}

dsnFromEnv и migrate остаются неэкспортированными и занимают вместе полтора десятка строк:

// internal/pkg/testdb/postgres.go
const dsnEnv = "TEST_POSTGRES_DSN"

func dsnFromEnv(t *testing.T) string {
	t.Helper()

	dsn := os.Getenv(dsnEnv)
	if dsn == "" {
		t.Fatalf("%s is not set, use -short to skip", dsnEnv)
	}

	return dsn
}

func migrate(t *testing.T, db *gorm.DB) {
	t.Helper()

	pool, err := db.DB()
	if err != nil {
		t.Fatalf("get sql.DB: %v", err)
	}
	if err := migrations.Up(pool); err != nil {
		t.Fatalf("apply migrations: %v", err)
	}
}

migrations.Up это та же точка входа, которую приложение вызывает на старте, поэтому тесты работают на схеме, собранной ровно так же, как рабочая. Чем она реализована внутри, goose, golang-migrate или собственным перебором файлов, помощнику неважно: всё, что ему нужно, это отдать инструменту *sql.DB от пула тестовой схемы. search_path у этого пула уже указывает на неё, поэтому объекты создаются там, а не в public. Именно поэтому migrate принимает подключение, а не DSN: строку пришлось бы собирать заново вместе со схемой, а это та самая конкатенация, от которой избавил pgx.ParseConfig.

Пустая переменная окружения это падение, а не пропуск. Пропуск задаётся флагом -short, то есть осознанным решением, а интеграционный тест, который тихо не выполнился на CI из-за забытой переменной, худший из возможных исходов.

Базу для локального прогона достаточно поднять контейнером:

docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:17
export TEST_POSTGRES_DSN='postgres://postgres:secret@localhost:5432/postgres?sslmode=disable'
go test ./...
go test -short ./...

В CI ту же переменную выставляет сервис-контейнер, а если хочется, чтобы жизненным циклом базы управлял сам тест, её поднимает testcontainers-go в TestMain и кладёт свой DSN в то же окружение.

Схема на тест здесь не перестраховка. go test ./... запускает разные пакеты параллельно, даже если внутри пакетов нет ни одного t.Parallel(), и все они получают одну и ту же строку подключения из окружения. Общий TRUNCATE в такой схеме однажды сработает во время чужого теста, и упадёт он в чужом пакете, где искать причину никто не будет. Отдельная схема убирает саму возможность.

search_path задан через RuntimeParams, то есть параметром самого соединения. Так его получает каждое соединение пула, в том числе открытое позже. SET search_path отдельным запросом здесь не годится: GORM держит пул, и следующий запрос может уйти в соединение, где настройка не применялась. Конкатенация строки подключения тоже не годится, потому что DSN бывает и в форме postgres://user@host/db, и в форме host=... dbname=..., а pgx.ParseConfig разбирает обе.

Оба пула закрываются, и порядок здесь не случайный. t.Cleanup выполняет функции в обратном порядке регистрации, поэтому сначала закрывается пул тестовой схемы, затем удаляется схема, и только потом закрывается административный пул, через который выполняется само удаление. Без закрытия каждый тест оставлял бы по два пула до конца процесса, и при схеме на тест сотня интеграционных тестов упёрлась бы в max_connections раньше, чем нашла бы первую ошибку. Пулу задан небольшой предел, потому что каждый тест выполняет лишь несколько запросов. Сами тесты и пакеты при этом могут работать параллельно.

Плата за изоляцию это миграции на каждый тест. Если их много и они медленные, схема создаётся один раз на пакет в TestMain, а тесты внутри пакета делят её и запускаются последовательно. Промежуточный вариант с общей схемой и уникальными идентификаторами в каждом тесте выглядит дешевле, но разваливается на первом же запросе со SELECT без фильтра по идентификатору.

Ошибку очистки проверяет t.Errorf, а не молчаливое присваивание в _. Незамеченный сбой DROP SCHEMA оставляет мусор в базе, и через сотню прогонов список схем становится длиннее, чем список таблиц.

Рядом с подключением живут и помощники, которые читают результат в обход проверяемого кода:

// internal/pkg/testdb/rows.go
package testdb

type OrderRow struct {
	Status     string
	PaymentID  string
	TotalCents int64
}

func FetchOrder(t *testing.T, db *gorm.DB, id uuid.UUID) OrderRow {
	t.Helper()

	var row OrderRow
	result := db.Raw(
		"SELECT status, COALESCE(payment_id, '') AS payment_id, total_cents "+
			"FROM orders WHERE id = ?",
		id,
	).Scan(&row)
	if result.Error != nil {
		t.Fatalf("fetch order %s: %v", id, result.Error)
	}
	if result.RowsAffected != 1 {
		t.Fatalf(
			"fetch order %s: got %d rows, want 1",
			id,
			result.RowsAffected,
		)
	}

	return row
}

func CountReceipts(t *testing.T, db *gorm.DB, orderID uuid.UUID) int {
	t.Helper()

	var count int64
	err := db.Raw(
		"SELECT count(*) FROM receipt_jobs WHERE order_id = ?",
		orderID,
	).Scan(&count).Error
	if err != nil {
		t.Fatalf("count receipts of order %s: %v", orderID, err)
	}

	return int(count)
}

Запрос написан руками, а не собран через модели GORM. Помощник должен смотреть на таблицу глазами, независимыми от кода, который в неё пишет, иначе одна и та же ошибка в модели скроет сама себя и в записи, и в проверке. Имена таблиц здесь взяты из схемы: orderRow и jobRow объявляют TableName явно именно потому, что по умолчанию GORM превратил бы их в order_rows и job_rows.

RowsAffected проверяется отдельно, потому что Scan без единой найденной строки не считается ошибкой: он оставит структуру нулевой и вернёт nil. Помощник, который в этом случае молча отдаёт OrderRow{}, превращает «заказа нет» в «статус пустой», и тест падает не там, где сломано.

Интеграционный тест репозитория

Репозиторий подменять нечем. Стаб *gorm.DB доказал бы, что вызваны определённые методы GORM, а вопрос состоит в том, что окажется в таблице и что вернётся обратно.

Сам тест проходит по обеим веткам MarkPaid:

// internal/domain/order/repositories/postgres/repository_test.go
package postgres_test

func TestRepository_CreateAndMarkPaid(t *testing.T) {
	db := testdb.New(t)
	repository := postgres.New(db)
	ctx := context.Background()
	accountID := uuid.New()

	created, err := repository.Create(ctx, order.CreateOrderParams{
		AccountID:  accountID,
		TotalCents: 12500,
	})
	if err != nil {
		t.Fatalf("Create() error = %v", err)
	}
	if created.ID == uuid.Nil {
		t.Fatal("Create() returned an order without id")
	}
	if created.AccountID != accountID {
		t.Errorf(
			"Create() account = %s, want %s",
			created.AccountID,
			accountID,
		)
	}
	if created.TotalCents != 12500 {
		t.Errorf("Create() total = %d, want 12500", created.TotalCents)
	}
	if created.Status != order.StatusPending {
		t.Errorf(
			"Create() status = %q, want %q",
			created.Status,
			order.StatusPending,
		)
	}
	if created.CreatedAt.IsZero() {
		t.Error("Create() returned zero CreatedAt")
	}

	if err := repository.MarkPaid(ctx, created.ID, "pay_42"); err != nil {
		t.Fatalf("MarkPaid() error = %v", err)
	}

	stored := testdb.FetchOrder(t, db, created.ID)
	if stored.Status != "paid" {
		t.Errorf("stored status = %q, want %q", stored.Status, "paid")
	}
	if stored.PaymentID != "pay_42" {
		t.Errorf("stored payment = %q, want %q", stored.PaymentID, "pay_42")
	}
	if stored.TotalCents != 12500 {
		t.Errorf("stored total = %d, want 12500", stored.TotalCents)
	}

	err = repository.MarkPaid(ctx, uuid.New(), "pay_43")
	if !errors.Is(err, order.ErrOrderNotFound) {
		t.Fatalf(
			"MarkPaid() error = %v, want %v",
			err,
			order.ErrOrderNotFound,
		)
	}
}

Отсутствия ошибки от MarkPaid для проверки не хватает: UPDATE без ошибки одинаково выглядит и когда записан нужный статус, и когда обновлены не те колонки, и когда payment_id остался пустым. Читать результат через Create того же репозитория тоже нельзя, иначе проверка пройдёт через тот же маппинг, корректность которого она предполагает.

Последняя проверка в этом тесте про MarkPaid для несуществующего заказа. Ошибки от GORM здесь нет: UPDATE выполняется успешно и меняет ноль строк. Создаёт её сам репозиторий по RowsAffected == 0, и никакой стаб этого не подтвердит. Проверка CreatedAt относится к тому же классу, хотя источник значения другой: поле заполняет GORM перед INSERT по соглашению об имени, а в коде репозитория ему не присваивается ничего. Стаб вернул бы структуру с нулевым временем и про это соглашение не сказал бы ничего.

Тест лежит в postgres_test и видит только экспортированное API. orderRow и mapOrder ему не нужны: результат маппинга возвращает Create, а хранимую строку тест читает в обход репозитория. Внутренний тестовый пакет пришлось бы заводить, только если бы у маппинга появилась логика, которую нельзя вызвать снаружи.

Интеграционный тест транзакции

markOrderPaid объединяет две записи: статус заказа и постановку задачи на чек. Обещание кода состоит в том, что либо применяются обе, либо ни одной.

transaction.FakeManager из unit-теста этого не проверит по причине, разобранной в пятой части: он не открывает транзакцию, и откатывать ему нечего. Нужна настоящая база.

Перед первым тестом стоит проверить одну строку в репозитории. Транзакция живёт в контексте, поэтому каждый метод обязан выбирать подключение оттуда, а не из поля структуры:

// internal/domain/order/repositories/postgres/repository.go
func (r *Repository) dbFromContext(ctx context.Context) *gorm.DB {
	return transaction.ExtractDB(ctx, r.db).WithContext(ctx)
}

MarkPaid и Create начинают запрос с r.dbFromContext(ctx). Если в них остался прямой r.db.WithContext(ctx), показанный в третьей части до появления менеджера, UPDATE уйдёт мимо транзакции: ROLLBACK откатит пустую транзакцию, а заказ останется paid. Механизм разобран в разделе «Как подключаются репозитории» второй части, а тест ниже как раз и есть то место, где забытый dbFromContext перестаёт быть тихой ошибкой.

Первый тест проверяет откат первой записи, когда падает вторая:

// internal/domain/order/checkout/transaction_test.go
package checkout

func TestUseCase_MarkOrderPaidRollsBack(t *testing.T) {
	db := testdb.New(t)
	ctx := context.Background()
	repository := orderpostgres.New(db)

	created, err := repository.Create(ctx, order.CreateOrderParams{
		AccountID:  uuid.New(),
		TotalCents: 12500,
	})
	if err != nil {
		t.Fatalf("Create() error = %v", err)
	}

	log := &callLog{}
	useCase := New(
		repository,
		&productPortStub{},
		&paymentClientStub{log: log},
		transaction.NewGormManager(db),
		&receiptEnqueuerStub{log: log, err: errors.New("queue is down")},
	)

	if err := useCase.markOrderPaid(ctx, created.ID, "pay_42"); err == nil {
		t.Fatal("markOrderPaid() error = nil, want queue failure")
	}

	stored := testdb.FetchOrder(t, db, created.ID)
	if stored.Status != "pending" {
		t.Errorf("stored status = %q, want %q", stored.Status, "pending")
	}
}

Здесь и понадобился внутренний тестовый пакет package checkout: тест вызывает неэкспортированный markOrderPaid, потому что проверять нужно именно транзакционный этап, а не весь Execute. Каталог и платёжный клиент в этом сценарии не участвуют, но конструктор требует все пять зависимостей, поэтому две из них передаются пустыми стабами. Это цена позиционного New и ещё один довод за Deps с именованными полями: незаполненное поле осталось бы нулевым само.

Правило checkout из настроек Depguard в четвёртой части запрещает этому пакету импорт gorm.io/gorm, и тест его не нарушает: testdb.New отдаёт готовое подключение, а тип db выводится. Зато orderpostgres и receiptpostgres появляются в тестовом файле пакета, где в рабочем коде их нет, и это плата за доступ к неэкспортированному методу: внешним такой тест не сделать. Если платить не хочется, транзакцию проверяют через Execute на настоящей базе, то есть тем же способом, что и сквозной тест.

FetchOrder снова читает колонку прямым запросом, а не через репозиторий: ошибка в маппинге иначе скрыла бы неоткатившуюся запись.

Стаб очереди возвращает обычную ошибку, а не sentinel. Тесту достаточно любого сбоя второй записи, конкретная ошибка на поведение транзакции не влияет.

Границы у этого теста узкие. Он доказывает одно: если callback вернул ошибку, предыдущая запись откатилась. Он ничего не говорит про настоящий EnqueueReceipt, потому что настоящего в нём нет. Реализация, которая пишет задачу через собственное подключение мимо txCtx, оставит этот тест зелёным и всё равно нарушит обещание «либо обе, либо ни одной»: заказ откатится, а чек уедет.

Настоящий постановщик устроен так же, как репозиторий заказа, и подключение выбирает из контекста:

// internal/domain/order/receipts/postgres/enqueuer.go
package postgres

import (
	"context"
	"fmt"
	"time"

	"github.com/google/uuid"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"

	"modular_shop/internal/domain/order"
	"modular_shop/internal/pkg/transaction"
)

type jobRow struct {
	ID        uuid.UUID `gorm:"type:uuid;primaryKey"`
	OrderID   uuid.UUID `gorm:"type:uuid;not null;uniqueIndex"`
	CreatedAt time.Time
}

func (jobRow) TableName() string {
	return "receipt_jobs"
}

type Enqueuer struct {
	db *gorm.DB
}

func New(db *gorm.DB) *Enqueuer {
	return &Enqueuer{db: db}
}

func (e *Enqueuer) EnqueueReceipt(
	ctx context.Context,
	orderID uuid.UUID,
) error {
	row := jobRow{ID: uuid.New(), OrderID: orderID}

	err := transaction.ExtractDB(ctx, e.db).
		WithContext(ctx).
		Clauses(clause.OnConflict{
			Columns:   []clause.Column{{Name: "order_id"}},
			DoNothing: true,
		}).
		Create(&row).Error
	if err != nil {
		return fmt.Errorf(
			"enqueue receipt for order %s: %w: %v",
			orderID,
			order.ErrReceiptEnqueueFailed,
			err,
		)
	}

	return nil
}

receipt_jobs создаётся миграцией. По order_id она добавляет уникальное ограничение, а ON CONFLICT DO NOTHING превращает повторную постановку того же чека в идемпотентный успех. Без ограничения в базе два одновременных повтора всё равно могли бы создать две строки. Нужен ли таблице внешний ключ на orders, зависит от жизненного цикла данных и правил удаления; к идемпотентности это отношения не имеет.

ErrReceiptEnqueueFailed объявлен рядом с остальными sentinel-ошибками order по правилам первой части. Если задачи ставит River из отдельной статьи, меняется только тело метода: вместо Create вызывается вставка задачи в транзакции из того же контекста, а тест считает строки в river_job.

Тест у постановщика свой, симметричный проверке репозитория:

// internal/domain/order/receipts/postgres/enqueuer_test.go
package postgres_test

func TestEnqueuer_JoinsTransaction(t *testing.T) {
	db := testdb.New(t)
	ctx := context.Background()
	enqueuer := postgres.New(db)
	manager := transaction.NewGormManager(db)
	orderID := uuid.New()

	wantErr := errors.New("later step failed")
	err := manager.InTransaction(ctx, func(txCtx context.Context) error {
		if err := enqueuer.EnqueueReceipt(txCtx, orderID); err != nil {
			return err
		}

		tx := transaction.ExtractDB(txCtx, db)
		if got := testdb.CountReceipts(t, tx, orderID); got != 1 {
			t.Errorf("receipts inside transaction = %d, want 1", got)
		}

		return wantErr
	})
	if !errors.Is(err, wantErr) {
		t.Fatalf("InTransaction() error = %v, want %v", err, wantErr)
	}

	if got := testdb.CountReceipts(t, db, orderID); got != 0 {
		t.Errorf("stored receipts = %d, want 0", got)
	}
}

func TestEnqueuer_IsIdempotent(t *testing.T) {
	db := testdb.New(t)
	enqueuer := postgres.New(db)
	orderID := uuid.New()

	for attempt := 1; attempt <= 2; attempt++ {
		if err := enqueuer.EnqueueReceipt(
			context.Background(),
			orderID,
		); err != nil {
			t.Fatalf("EnqueueReceipt() attempt %d: %v", attempt, err)
		}
	}

	if got := testdb.CountReceipts(t, db, orderID); got != 1 {
		t.Errorf("stored receipts = %d, want 1", got)
	}
}

Внутри транзакции строка сначала проверяется на месте, и без этой проверки тест был бы зелёным для реализации, которая не пишет вообще ничего: ноль строк после отката и ноль строк из-за бездействия выглядят одинаково. Считать приходится через transaction.ExtractDB(txCtx, db), потому что снаружи, из основного пула, незафиксированной строки не видно. Это то же свойство изоляции, которое тест и проверяет, только с другой стороны.

Ошибка возвращается уже после успешной постановки задачи, поэтому откатывается именно запись очереди. Такой же тест стоит написать для любой реализации, которая пишет в базу и получает txCtx: это наиболее точная проверка забытого ExtractDB.

Отдельный TestEnqueuer_IsIdempotent проверяет другое обещание: два вызова для одного заказа завершаются без ошибки, а в базе остаётся одна задача. Уникальное ограничение нужно не только для этого последовательного теста, но и для двух одновременных повторов.

Остаётся вторая половина обещания, и она проверяется на настоящей паре:

func TestUseCase_MarkOrderPaidWritesBothRecords(t *testing.T) {
	db := testdb.New(t)
	ctx := context.Background()
	repository := orderpostgres.New(db)

	created, err := repository.Create(ctx, order.CreateOrderParams{
		AccountID:  uuid.New(),
		TotalCents: 12500,
	})
	if err != nil {
		t.Fatalf("Create() error = %v", err)
	}

	useCase := New(
		repository,
		&productPortStub{},
		&paymentClientStub{log: &callLog{}},
		transaction.NewGormManager(db),
		receiptpostgres.New(db),
	)

	if err := useCase.markOrderPaid(ctx, created.ID, "pay_42"); err != nil {
		t.Fatalf("markOrderPaid() error = %v", err)
	}

	stored := testdb.FetchOrder(t, db, created.ID)
	if stored.Status != "paid" {
		t.Errorf("stored status = %q, want %q", stored.Status, "paid")
	}
	if got := testdb.CountReceipts(t, db, created.ID); got != 1 {
		t.Errorf("stored receipts = %d, want 1", got)
	}
}

Обещание «либо обе записи, либо ни одной» разложено на три теста, и по отдельности ни один его не закрывает. Менеджер откатывает уже сделанную запись. Постановщик задачи пишет в переданную ему транзакцию, а не рядом с ней. После COMMIT в базе оказываются обе записи, а не одна. Полный набор сценариев для самого менеджера, включая вложенные вызовы и отменённый контекст, собран во второй части.

Сквозной тест сценария

Тесты со стабами из пятой части держатся на одном допущении: стаб ведёт себя так же, как настоящая реализация. Компилятор проверяет только форму. Стаб и *postgres.Repository удовлетворяют одному orderRepository, поэтому разъехавшихся сигнатур в Go не бывает, в отличие от моков в динамических языках. Поведение не проверяет никто.

Отсюда и берётся зелёный тест при сломанном коде. orderRepositoryStub.Create возвращает заказ с заполненным ID. Если настоящий Create перестанет проставлять идентификатор, Execute соберёт ChargeCommand с пустым IdempotencyKey, а unit-тест останется зелёным, потому что у стаба идентификатор на месте.

Сквозной тест проверяет передачу значения, но не его смысл. Если catalog начнёт хранить цену за упаковку вместо цены за штуку или переедет на другую валюту, число всё равно доедет до заказа и тест останется зелёным. Такой контракт нужно делать явным через названия типов и полей, валюту, количество или явное преобразование в адаптере. Только после этого тест сможет зафиксировать нужное правило.

Есть и то, чего стабы не видят в принципе. Если Execute перестанет брать сумму из снапшота и подставит 12500 константой, unit-тест не шелохнётся: 12500 в нём и есть ожидаемое значение. Разницу между «значение доехало» и «значение совпало» даёт только тест, в котором цена приходит из настоящего каталога.

Каждый стаб держится на другом тесте. paymentAPIStub оправдан тем, что настоящий клиент проверен своим тестом на httptest, а тот проверен ответом, записанным у провайдера. У orderRepositoryStub такой цепочки нет: интеграционный тест проверяет репозиторий, но не сходство стаба с ним.

Дыру закрывает один тест на счастливый путь, собранный из настоящих зависимостей. Подменяется в нём только то, что нельзя вызвать физически, и подменяется на самом внешнем краю: вместо провайдера поднимается httptest.Server, а paymentclient.Client, оба адаптера, репозиторий заказа, менеджер транзакций, постановщик чека и catalog.ProductUseCase берутся настоящие. Подстановка стаба вместо paymentAPI выглядела бы проще, но тогда из проверки выпал бы весь код клиента, ради связки с которым тест и пишется.

Лежать такой тест должен в internal/app:

// internal/app/checkout_scenario_test.go
package app

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/google/uuid"

	"modular_shop/internal/clients/paymentclient"
	"modular_shop/internal/domain/catalog"
	catalogpostgres "modular_shop/internal/domain/catalog/repositories/postgres"
	orderadapters "modular_shop/internal/domain/order/adapters"
	"modular_shop/internal/domain/order/checkout"
	receiptpostgres "modular_shop/internal/domain/order/receipts/postgres"
	orderpostgres "modular_shop/internal/domain/order/repositories/postgres"
	"modular_shop/internal/pkg/testdb"
	"modular_shop/internal/pkg/transaction"
)

func TestCheckoutScenario(t *testing.T) {
	db := testdb.New(t)
	ctx := context.Background()

	var (
		gotAmount float64
		gotKey    string
	)

	provider := httptest.NewServer(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			var body map[string]any
			if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
				t.Errorf("decode charge request: %v", err)
			}

			gotAmount, _ = body["amount_cents"].(float64)
			gotKey = r.Header.Get("Idempotency-Key")

			w.Header().Set("Content-Type", "application/json")
			_, _ = w.Write([]byte(
				`{"transaction_id":"pay_42","status":"captured"}`,
			))
		},
	))
	defer provider.Close()

	products := catalog.NewProductUseCase(catalogpostgres.New(db))
	product, err := products.Create(ctx, catalog.CreateProductParams{
		Name:       "Espresso machine",
		PriceCents: 12537,
	})
	if err != nil {
		t.Fatalf("create product: %v", err)
	}

	useCase := checkout.New(
		orderpostgres.New(db),
		orderadapters.NewCatalogAdapter(products),
		orderadapters.NewPaymentAdapter(paymentclient.New(provider.URL)),
		transaction.NewGormManager(db),
		receiptpostgres.New(db),
	)

	got, err := useCase.Execute(ctx, checkout.Command{
		ProductID: product.ID,
		AccountID: uuid.New(),
	})
	if err != nil {
		t.Fatalf("Execute() error = %v", err)
	}

	if got.ID == uuid.Nil {
		t.Error("Execute() returned an order without id")
	}
	if got.TotalCents != 12537 {
		t.Errorf("Execute() total = %d, want 12537", got.TotalCents)
	}
	if gotAmount != 12537 {
		t.Errorf("provider got amount = %v, want 12537", gotAmount)
	}
	if gotKey != got.ID.String() {
		t.Errorf("provider got key = %q, want %q", gotKey, got.ID)
	}

	stored := testdb.FetchOrder(t, db, got.ID)
	if stored.Status != "paid" {
		t.Errorf("stored status = %q, want %q", stored.Status, "paid")
	}
	if stored.PaymentID != "pay_42" {
		t.Errorf("stored payment = %q, want %q", stored.PaymentID, "pay_42")
	}
	if stored.TotalCents != 12537 {
		t.Errorf("stored total = %d, want 12537", stored.TotalCents)
	}
	if receipts := testdb.CountReceipts(t, db, got.ID); receipts != 1 {
		t.Errorf("stored receipts = %d, want 1", receipts)
	}
}

Пакет выбран не ради удобства. Тесту нужен paymentclient.New, а правило business-logic из четвёртой части запрещает пакетам internal/domain импортировать internal/clients везде, кроме */adapters. golangci-lint по умолчанию проверяет и тестовые файлы, и отключать это через tests: false не стоит: иначе граница действует только в рабочем коде. Composition root остаётся единственным пакетом, которому положено знать все стороны.

Саму сборку тест при этом повторяет руками, а не берёт ту, что работает в продакшене, и разойтись они могут молча. Если в internal/app есть функция, собирающая checkout.UseCase из подключения и адреса провайдера, звать нужно её, а тест пусть отличается только источником этих двух значений. Пока такой функции нет, тест проверяет совместимость компонентов, но не то, что приложение собрано из них так же.

Цена у товара выбрана некруглой и намеренно не совпадает с числами из unit-тестов. Реализация, которая перестала брать сумму из снапшота и подставила знакомое 12500 константой, проходит все тесты со стабами, потому что там 12500 и есть ожидаемое значение. Здесь тест упадёт в трёх местах: на итоговом заказе, на значении total_cents, прочитанном из PostgreSQL, и на сумме, которую увидел провайдер.

Обработчик httptest.Server разбирает тело запроса, а не отвечает вслепую. Без этого утверждение «цена доехала до провайдера» осталось бы недоказанным: заказ на нужную сумму и POST с нулём в теле выглядели бы одинаково успешно. Заодно проверяется ключ идемпотентности, и это единственное место, где видно, что до заголовка HTTP доезжает идентификатор именно созданного заказа.

Товар создаётся вызовом catalog.ProductUseCase, а не вставкой в таблицу catalog. Прямой INSERT в чужие таблицы это то же нарушение границы, что и прямой импорт, только перенесённое в тестовый набор, и линтер его не увидит.

Доказывает этот тест следующее: Create возвращает заказ с идентификатором, цена доезжает из каталога до заказа и до запроса провайдеру, ответ провайдера разбирается настоящим клиентом и переводится настоящим адаптером, MarkPaid меняет строку, а обе записи транзакции фиксируются. Это не проверка соответствия стабов реальности вообще, а одна точка, в которой такое соответствие подтверждено конкретными значениями на счастливом пути. Каждый следующий стаб, который начнёт врать за пределами этой точки, тест пропустит.

Веток отказа в нём быть не должно. Вызвать отклонённый платёж на настоящих зависимостях можно, поменяв ответ httptest.Server, но проверку «MarkPaid не был вызван» на них уже не сформулировать: наблюдать за вызовами через настоящую базу нечем. Это остаётся за стабами, и деление получается такое: стабы покрывают все ветки, включая то, чего происходить не должно, сквозной тест покрывает счастливый путь.

Таких тестов должно быть мало. Они медленные, при падении виноватым может оказаться любое звено сборки, и они возвращают в тестовый набор связанность, которую границы убрали из рабочего кода: тесты order начинают падать при изменении схемы catalog. Пока их единицы, это приемлемая плата. Когда их десятки, каскад правок вернулся, только теперь в тестах.

Ту же дыру закрывает и другой способ. In-memory реализация репозитория плюс общий набор тестов, который прогоняется и против неё, и против PostgreSQL: тогда fake ведёт себя как база в тех случаях, которые в набор попали, и его можно подставлять в тесты сценариев. Конкурентная запись, блокировки и ограничения схемы в такой набор обычно не попадают, поэтому равенством базе это не становится. Стоит это дороже пары сквозных тестов и окупается, когда стабов становится много.

Что остаётся вне тестов

Контракт платёжного провайдера называла пятая часть, и сквозной тест его не закрывает: провайдера в нём играет httptest.Server.

Не проверяется атомарность между базой и платёжным сервисом. Остановка процесса между успешным Charge и markOrderPaid остаётся возможной, и никакой тест её не отменяет: это свойство архитектуры, а не кода. Зато проверяемо всё, что делается для восстановления, и в этой статье таких тестов нет: повторный Charge уходит с тем же ключом, сверка находит заказ, застрявший в pending, и доводит его до paid, повторный прогон не создаёт второй чек. Устроен такой тест так: создают заказ в pending, вызывают Charge, намеренно пропускают markOrderPaid, а затем запускают механизм сверки.

Как не стоит заменять базу

Каждый из следующих подходов обещает интеграционный тест без интеграции.

sqlmock вместо базы. Дело не в том, что он ничего не ловит: с точным ожидаемым запросом он заметит и лишнюю колонку в UPDATE, и неверный WHERE, а ответ с RowsAffected: 0 пройдёт по ветке с несуществующим заказом. Но нуль затронутых строк задал сам автор теста: sqlmock не доказал, что PostgreSQL и GORM действительно вернут такой результат для этого запроса.

Запрос при этом никуда не отправляется. Схемы нет, значит опечатка в имени колонки, несовпадение типов и нарушенное ограничение остаются за пределами проверки, а тест начинает фиксировать SQL-строку, которую генерирует текущая версия ORM. Получается тест, который ломается на обновлении GORM и молчит на ошибке в схеме.

Непроверенный fake-репозиторий в памяти. Иногда fake оправдан: сценарий с десятком шагов читается лучше, чем цепочка стабов. Но map вместо таблицы не воспроизводит уникальные индексы, каскады и поведение при конкурентной записи, поэтому доверять ему стоит ровно в тех случаях, которые проверены общим набором тестов, прогнанным и против него, и против настоящего репозитория.

Практическая проверка тестов

Перед слиянием изменений надо проверить следующее:

  1. Репозиторий проверяется на настоящей базе, включая ветку с нулём затронутых строк, а записанные значения читаются прямым запросом.
  2. У каждой реализации, которая получает txCtx, есть тест на то, что она пишет в переданную транзакцию.
  3. Откат транзакции и фиксация обеих записей проверяются на настоящей базе.
  4. Повторная постановка одной задачи не создаёт дубль, а уникальное ограничение защищает от одновременных повторов.
  5. Каждый интеграционный тест работает в своей схеме, потому что go test ./... запускает пакеты параллельно.
  6. Счастливый путь сценария хотя бы один раз проходит через настоящие репозиторий, адаптеры и HTTP-клиент, причём на значениях, которых нет в тестах со стабами.
  7. Запрещённые импорты проверяются линтером, а не тестом.

К этому делению здесь добавился репозиторий, и смысл его в том, где заканчивается правка. Изменение схемы PostgreSQL должно упираться в тест репозитория и не доходить до теста checkout.UseCase. Если доходит, модель хранения пересекла границу.

Вернуться к предыдущей части.

Материалы