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