Опирается на правила: GOTEST-11GOTEST-14 из Go Test Strategy → раздел 4. DatabasePreparer — fluent setup БД.

Важно знать

  • На каждый Bounded Context — свой <Domain>DatabasePreparer со своим pgxpool.Pool.
  • Clear(t) вызывает TRUNCATE ... CASCADE в порядке FK-зависимостей; вызывается в начале каждого теста.
  • Create*-методы порядком вызовов отражают FK-дерево: сначала родительская запись, затем дочерняя.
  • Прямой pgx, а не sqlc-генерация: тест не зависит от доменного слоя.
  • Fluent-цепочка — каждый метод возвращает *OrderDatabasePreparer; читается сверху вниз.
  • Нет DDL между тестами — только TRUNCATE, схема разворачивается один раз в TestMain.
  • Find*-методы для Assert-фазы: прочитать строку из БД без бизнес-слоя.
  • t.Helper() обязателен в каждом методе препарера — stack trace указывает на строку теста, не на препарер.

Setup БД — самая шумная часть integration-теста: вокруг трёх строк бизнес-логики растут десятки INSERT-ов. DatabasePreparer прячет этот шум за читаемым fluent-интерфейсом и делает Arrange-фазу компактной.

Структура OrderDatabasePreparer

GOTEST-11: один препарер на Bounded Context, содержит *pgxpool.Pool.

type OrderDatabasePreparer struct {
	pool *pgxpool.Pool
}

Препарер получает пул напрямую — тот же pgxpool.Pool, который передаётся в newTestServer(t). Не создаём отдельное подключение на тест.

Clear(t) — TRUNCATE в порядке FK

GOTEST-12: Clear(t) вызывает TRUNCATE ... CASCADE для таблиц Bounded Context. Возвращает *OrderDatabasePreparer для fluent-продолжения.

func (p *OrderDatabasePreparer) Clear(t *testing.T) *OrderDatabasePreparer {
	t.Helper()
	_, err := p.pool.Exec(context.Background(),
		"TRUNCATE order_items, orders, customers CASCADE")
	require.NoError(t, err)
	return p
}

GOTEST-X5: нельзя перечислять таблицы в произвольном порядке. TRUNCATE без CASCADE при нарушении FK-порядка вернёт ошибку. С CASCADE PostgreSQL разрулит FK-зависимости сам, но порядок таблиц в строке всё равно важен для читаемости: зависимые (order_items) — перед родительскими (orders, customers).

Clear(t) вызывается в начале каждого теста, не в TestMain. Это гарантирует чистое состояние независимо от того, в каком порядке запускаются тесты.

Create*-методы — FK от корня к листу

GOTEST-13: порядок Create*-методов отражает FK-дерево. Сначала запись, на которую ссылаются (customers), затем та, которая ссылается (orders), затем зависимая (order_items).

func (p *OrderDatabasePreparer) CreateCustomer(t *testing.T, id string) *OrderDatabasePreparer {
	t.Helper()
	_, err := p.pool.Exec(context.Background(),
		"INSERT INTO customers(id, name) VALUES($1, $2)",
		id, "Test Customer")
	require.NoError(t, err)
	return p
}

func (p *OrderDatabasePreparer) CreateOrder(
	t *testing.T, id, customerID, status string,
) *OrderDatabasePreparer {
	t.Helper()
	_, err := p.pool.Exec(context.Background(),
		"INSERT INTO orders(id, customer_id, status, created_at) VALUES($1, $2, $3, $4)",
		id, customerID, status, time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
	require.NoError(t, err)
	return p
}

func (p *OrderDatabasePreparer) CreateOrderItem(
	t *testing.T, id, orderID, productID string, amount int,
) *OrderDatabasePreparer {
	t.Helper()
	_, err := p.pool.Exec(context.Background(),
		"INSERT INTO order_items(id, order_id, product_id, amount) VALUES($1, $2, $3, $4)",
		id, orderID, productID, amount)
	require.NoError(t, err)
	return p
}

GOTEST-14: запросы в методах — raw pgx-строки. Не используем sqlc-генерацию — тест должен быть независим от доменного слоя; если sqlc-запрос сломается по другой причине, тест не должен за это падать.

Find*-методы — для Assert-фазы

GOTEST-11: помимо Clear и Create*, препарер предоставляет Find*-методы для проверки состояния БД в Assert-фазе. Они тоже raw pgx — тест проверяет данные в обход бизнес-слоя.

type OrderRow struct {
	ID         string
	CustomerID string
	Status     string
	CreatedAt  time.Time
}

func (p *OrderDatabasePreparer) FindOrder(t *testing.T, id string) OrderRow {
	t.Helper()
	var row OrderRow
	err := p.pool.QueryRow(context.Background(),
		"SELECT id, customer_id, status, created_at FROM orders WHERE id = $1", id).
		Scan(&row.ID, &row.CustomerID, &row.Status, &row.CreatedAt)
	require.NoError(t, err)
	return row
}

type OutboxEvent struct {
	EventType string
	Payload   map[string]any
}

func (p *OrderDatabasePreparer) FindOutboxEvents(t *testing.T, eventType string) []OutboxEvent {
	t.Helper()
	rows, err := p.pool.Query(context.Background(),
		"SELECT event_type, payload FROM outbox WHERE event_type = $1", eventType)
	require.NoError(t, err)
	defer rows.Close()

	var events []OutboxEvent
	for rows.Next() {
		var ev OutboxEvent
		var raw []byte
		require.NoError(t, rows.Scan(&ev.EventType, &raw))
		require.NoError(t, json.Unmarshal(raw, &ev.Payload))
		events = append(events, ev)
	}
	require.NoError(t, rows.Err())
	return events
}

Fluent-цепочка в тесте

Все методы возвращают *OrderDatabasePreparer, что позволяет строить Arrange-фазу как читаемую последовательность:

func TestCancelOrder_WhenConfirmed_Returns200(t *testing.T) {
	srv, prep := newTestServer(t)

	// Arrange
	prep.Clear(t).
		CreateCustomer(t, "c-sberprime").
		CreateOrder(t, "o-1", "c-sberprime", "CONFIRMED")

	// Act
	req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/orders/o-1", nil)
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	defer resp.Body.Close()

	// Assert
	require.Equal(t, http.StatusOK, resp.StatusCode)

	row := prep.FindOrder(t, "o-1")
	assert.Equal(t, "CANCELLED", row.Status)
}

Цепочка читается сверху вниз: сначала очистка, затем родительская запись (Customer), затем дочерняя (Order). Это и есть FK-порядок, зафиксированный в коде.

Пример с Outbox

GOTEST-19: вместо проверки Kafka — читаем Outbox-таблицу через препарер.

func TestCreateOrder_PublishesToOutbox(t *testing.T) {
	srv, prep := newTestServer(t)

	// Arrange
	prep.Clear(t).CreateCustomer(t, "c-sberprime")

	// Act
	resp, err := http.Post(srv.URL+"/orders", "application/json",
		strings.NewReader(`{"customerId":"c-sberprime","amount":500}`))
	require.NoError(t, err)
	defer resp.Body.Close()
	require.Equal(t, http.StatusCreated, resp.StatusCode)

	// Assert
	events := prep.FindOutboxEvents(t, "ORDER_CREATED")
	require.Len(t, events, 1)
	assert.Equal(t, "c-sberprime", events[0].Payload["customerId"])
}

Подключение к newTestServer

Препарер создаётся внутри newTestServer(t) и разделяет pgxpool.Pool с репозиторием:

func newTestServer(t *testing.T) (*httptest.Server, *OrderDatabasePreparer) {
	t.Helper()
	pool, err := pgxpool.New(context.Background(), testDSN)
	require.NoError(t, 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}
}

Один пул — нет рассинхронизации между тем, что пишет тест, и тем, что читает репозиторий.

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

АнтипаттернПравилоЧто взамен
TRUNCATE таблиц в произвольном порядке без CASCADE — FK violationGOTEST-X5TRUNCATE ... CASCADE; зависимые таблицы первыми для читаемости
DROP TABLE / пересоздание схемы между тестамиGOTEST-6TRUNCATE; схема — один раз в TestMain
sqlc-запросы в методах препарераGOTEST-14raw pgx-строки; тест не зависит от доменного слоя
Глобальный препарер для всего сервисаGOTEST-11отдельный <Domain>DatabasePreparer на Bounded Context
Create*-методы в FK-нарушающем порядкеGOTEST-13сначала родительская запись, потом дочерняя
Прямые pool.Exec(...) в теле теста, минуя препарерGOTEST-11все DB-операции в Arrange-фазе — через методы препарера
Отсутствие t.Helper() в методах препарераGOTEST-16t.Helper() в каждом методе; stack trace на строку теста

Куда дальше

  • go/basics — TestMain, newTestServer, принципы интеграционного теста на Go.
  • go/base-integration-test — как newTestServer собирает зависимости и передаёт препарер.
  • go/one-test — полный пример теста с Clear+Create в Arrange-фазе.
  • go/no-kafka-redis-async — почему FindOutboxEvents вместо проверки Kafka.
  • Persistence — sqlc + pgx — схема таблиц и FK-структура, которую повторяет препарер.