5.3Тесты и база данных
Сопоставим стратегии изоляции PostgreSQL и проверим данные, транзакции и миграции на реальной СУБД.
- Куда уходит время и почему контейнер поднимают раз на пакет, а данные чистят на каждом тесте.
- Три способа изоляции, цену каждого и то, чего транзакция с откатом не проверит никогда.
- Почему
TRUNCATEиt.Parallel()не уживаются, а схема на тест уживается. - Сколько соединений съест прогон и что скажет сервер, когда они кончатся.
- Как тест ловит миграцию, которая ломает старый под, и почему юнит-тест её не найдёт.
- Откуда берётся мигающий тест на базе:
now(), последовательности, порядок строк. - Что на самом деле проверяют
pgxmockиminiredis.
Куда уходит время
Числа ниже сняты на PostgreSQL 18.6 в Docker Desktop 29.8 на Apple Silicon: PGDATA в tmpfs,
fsync=off, synchronous_commit=off, full_page_writes=off.
Так базу под тесты и настраивают. В каждой строке медиана нескольких прогонов на свежем
контейнере.
| Шаг | Сколько | Как часто делают |
|---|---|---|
Старт postgres:18 до первого SELECT 1 по TCP | 0,7 с | раз на прогон |
goose up, 4 миграции | 11 мс | раз на базу |
goose up, 60 миграций | 73 мс | раз на базу |
CREATE DATABASE … TEMPLATE с теми же 60 миграциями | 10 мс | раз на пакет |
CREATE SCHEMA + 4 миграции внутрь неё | 9 мс | на тест или пакет |
TRUNCATE четырёх таблиц, RESTART IDENTITY CASCADE | 0,75 мс | на каждый тест |
BEGIN + INSERT + ROLLBACK | 0,36 мс | на каждый тест |
Между первой строкой и двумя последними три порядка. Отсюда устройство прогона: контейнер и миграции выносят на пакет, между тестами трогают только данные. Сотня тестов, у каждого свой контейнер, съедает больше минуты; с одним контейнером на всех уходят десятые доли секунды.
Дальше вступает шаблонная база: на
четырёх миграциях копия не выигрывает у прогона, на шестидесяти выигрывает всемеро, а в
трёхлетнем сервисе их триста. На выбор изоляции не влияет, откуда база взялась физически: из
testcontainers-go, из docker-compose или из сервиса рядом с job'ом.
Для тестов это одинаковый DSN в переменной окружения.
Три способа изоляции и чем каждый платит
Изоляция решает одну задачу: тест начинается с состояния, которое сам и задал, независимо от соседей и порядка запуска. Способов три, и различаются они не скоростью, а тем, какую часть поведения кода скрывают.
Транзакция с откатом
func withTx(t *testing.T) pgx.Tx {
t.Helper()
tx, err := pool.Begin(context.Background())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = tx.Rollback(context.Background()) })
return tx
}
Способ самый быстрый и самый вежливый к соседям: незакоммиченных строк не видит никто. Платишь
тем, что обёртка прячет часть поведения кода. Репозиторий, которому транзакцию
передают параметром, в неё попадёт, а use case с собственным pool.Begin(ctx)
получит из пула другое соединение: строк теста он не увидит, а свои закоммитит мимо
отката.
$ go test ./isolation/ -count=1 -v
iso_test.go:79: тест вставил пользователя id=1 в свою транзакцию
iso_test.go:87: код на соединении из пула видит строк: 0
iso_test.go:94: Register закоммитил пользователя id=2 - откат теста его не уберёт
Отложенные ограничения (DEFERRABLE INITIALLY DEFERRED) проверяются на
COMMIT, а коммита тут не случается. Тест, который должен был поймать дубликат,
молчит:
iso_test.go:116: в транзакции с откатом: два одинаковых ника вставились без ошибки
iso_test.go:134: с настоящим COMMIT: SQLSTATE 23505, duplicate key value violates unique constraint "profiles_nick_deferred"
И третье: в pgx вложенный Begin на открытой транзакции разворачивается в
SAVEPOINT, а Commit на нём в RELEASE SAVEPOINT. Код
думает, что закоммитил; снаружи ноль строк, внутри теста одна. Ошибка, которая должна была
откатить всю транзакцию, откатывает только до точки сохранения.
- Всё, что случается на
COMMIT: отложенные ограничения,CONSTRAINT TRIGGER … DEFERRABLEи сам факт, что коммит проходит. - Код со своим пулом и своими транзакциями: другое соединение теста не видит, а свои коммиты оставляет после отката.
- Видимость между соединениями: outbox-поллер,
воркер с
SKIP LOCKED,LISTEN/NOTIFY— всё это работает с закоммиченными данными. - Последствия отката: последовательности откат не отматывает (см. ниже).
Очистка TRUNCATE
_, err := pool.Exec(ctx,
`TRUNCATE users, orders, order_items, jobs, accounts RESTART IDENTITY CASCADE`)
RESTART IDENTITY обнуляет привязанные к колонкам последовательности: без него
идентификаторы растут от прогона к прогону. CASCADE дотягивает чистку до таблиц,
которые внешними ключами ссылаются на перечисленные. Если такая найдётся, без него команда
откажется работать, а с ним вычистит таблиц больше, чем написано. Одна команда вместо
нескольких стоит одной блокировки и одной транзакции.
Ограничение у способа одно, зато жёсткое: TRUNCATE берёт
ACCESS EXCLUSIVE, самую тяжёлую блокировку, несовместимую даже с чтением. Пока один
тест чистит таблицу, второй на ней стоит, а проскочив между чисткой и вставкой, видит пустоту
вместо своих данных. Два параллельных теста расходятся на первых же итерациях, и каждый
прогон по-своему:
$ go test ./truncpar/ -count=1
--- FAIL: TestParallelB (0.01s)
trunc_test.go:49: итерация 1: в таблице 2 строк, ждали 1
--- FAIL: TestParallelA (0.01s)
trunc_test.go:49: итерация 3: в таблице 2 строк, ждали 1
FAIL
Своя схема или своя база
Третий способ разводит тесты не по времени, а по пространству имён: своя схема на тест или на
пакет, миграции внутрь неё, таблицы находятся через search_path. Чистить нечего:
общего ничего нет.
db, err := sql.Open("pgx", dsn+"&search_path="+name) // имя схемы - своё, не снаружи
require.NoError(t, err)
_, err = db.ExecContext(ctx, `CREATE SCHEMA IF NOT EXISTS `+name)
require.NoError(t, err)
require.NoError(t, goose.UpContext(ctx, db, "../migrations"))
Имя схемы приходится вклеивать конкатенацией, потому что параметры подстановки в DDL не
работают. Значит, оно должно быть своим: имя пакета, t.Name() с заменой
недопустимых символов или значение последовательности.
| Транзакция с откатом | TRUNCATE | Схема или база на тест | |
|---|---|---|---|
| Цена на тест | 0,36 мс | 0,75 мс | 9 мс (схема), 10 мс (копия базы) |
t.Parallel() | да | нет | да |
| Код со своим пулом | не проверяется | проверяется | проверяется |
Что происходит на COMMIT | не проверяется | проверяется | проверяется |
| Два соединения в одном тесте | нет | да | да |
| Где уместно | тесты репозитория, получающего Querier параметром |
use case и воркеры при последовательном прогоне | параллельные пакеты, тесты миграций, всё с двумя соединениями |
На среднем сервисе обычно берут схему (или базу) на пакет плюс TRUNCATE
между тестами внутри пакета. Транзакция с откатом остаётся там, где проверяется чистый SQL
репозитория. Как только в тесте появляется код, открывающий транзакцию сам, откат перестаёт быть
изоляцией и становится маскировкой.
Параллельные тесты на одной базе
Параллельность приходит с двух сторон. Внутри пакета её включает t.Parallel(),
между пакетами go test запускает тестовые бинари параллельно сам: флаг
-p по умолчанию равен GOMAXPROCS. На четырнадцати ядрах в базу
одновременно идут четырнадцать пакетов, и каждый несёт свой пул.
search_path и пул
SET search_path настраивает сессию, то есть одно соединение. Пул отдал соединение,
выполнил на нём SET, вернул обратно, а следующий запрос ушёл на соседнее, где
search_path прежний. Пока запросы идут по одному, пул отдаёт то же соединение и
всё работает. Под нагрузкой картина меняется: двадцать одновременных запросов к пулу из четырёх
соединений:
$ go test ./searchpath/ -count=1 -v
=== RUN TestSearchPath/SET_search_path_через_пул
sp_test.go:93: 20 одновременных запросов: удачных 4, упавших 16
sp_test.go:95: ошибка: ERROR: relation "notes" does not exist (SQLSTATE 42P01)
=== RUN TestSearchPath/search_path_в_строке_подключения
sp_test.go:102: 20 одновременных запросов: удачных 20, упавших 0
Удачными вышли запросы, попавшие на соединение с выполненным SET; сколько их
будет, решает пул, и число каждый раз другое. Ровно так выглядит тест, который
«иногда падает в CI». Рабочих вариантов два: параметр в строке подключения, который получает
каждое новое соединение, или тот же SET в AfterConnect.
p, err := pgxpool.New(ctx, dsn+"&search_path=tenant_a") // либо так,
cfg, err := pgxpool.ParseConfig(dsn) // либо так
cfg.AfterConnect = func(ctx context.Context, c *pgx.Conn) error {
_, err := c.Exec(ctx, `SET search_path TO tenant_a`)
return err
}
p, err = pgxpool.NewWithConfig(ctx, cfg)
Арифметика соединений
Пул на пакет умножается на число параллельных пакетов: восемь пакетов по десять соединений
дают восемьдесят сессий. Считаются они строками в pg_stat_activity, пока прогон
идёт.
# восемь пакетов, у каждого свой пул на 10 соединений;
# параллельно в цикле считаем строки в pg_stat_activity
$ go test -p 4 ./... -count=1
максимум соединений при -p 4: 41
$ go test -p 8 ./... -count=1
максимум соединений при -p 8: 81
Потолок сервера задаёт max_connections, но обычной роли достаётся меньше: часть
слотов зарезервирована. На postgres:18 с max_connections=100 обычная роль
открыла 96 соединений, а привычное «sorry, too many clients already» появилось, только когда
кончились и резервные:
$ go test ./conns/ -count=1 -v
conns_test.go:40: max_connections=100 superuser_reserved_connections=3
conns_test.go:54: соединение 97: SQLSTATE 53300, remaining connection slots are reserved for roles with the SUPERUSER attribute
conns_test.go:64: суперпользователь, соединение 4: SQLSTATE 53300, sorry, too many clients already
Когда соединения кончаются внутри пула, pgx не возвращает ошибку, а ждёт освобождения.
Тест не краснеет, а висит до дедлайна контекста, а без дедлайна до -timeout всего
прогона. Косвенный признак: тесты не падают, а становятся медленными, и в статистике пула растёт
EmptyAcquireCount.
conns_test.go:107: третий запрос ждал 300ms и вернул: context deadline exceeded
conns_test.go:108: пул: TotalConns=2 AcquireCount=2 EmptyAcquireCount=2
Отсюда счёт: MaxConns × число параллельных пакетов должно с запасом влезать в
max_connections минус резерв. В тестах пул почти всегда делают меньше прода:
двух-четырёх соединений на пакет хватает. И проверь GOMAXPROCS в контейнере CI:
с Go 1.25 рантайм учитывает лимит cgroup, и число параллельных пакетов там отличается от
локального.
Миграции в тесте
Миграции обычно считают частью подготовки: накатили до головы, дальше тестируем код. Но это тоже код, который поедет в прод, и у него есть свойства, которые проверит только настоящая база.
Down действительно откатывает
Секция -- +goose Down лежит в файле годами и исполняется впервые ночью при откате
релиза. Тест на неё пишется за десять строк: накатить до головы, откатить до нуля, убедиться,
что таблиц не осталось, и накатить снова.
db := openSchema(t, "m_updown") // своя схема, соседям не мешаем
require.NoError(t, goose.UpContext(ctx, db, "../migrations"))
require.NoError(t, goose.DownToContext(ctx, db, "../migrations", 0))
// ...в information_schema.tables не осталось ничего, кроме goose_db_version
require.NoError(t, goose.UpContext(ctx, db, "../migrations")) // ловит Down, откативший не всё
Повторный up в конце важнее первого: он ловит Down, удаливший таблицу,
но забывший тип, последовательность или функцию. Схема выглядит пустой, а
CREATE TYPE на втором проходе падает с 42710.
Старый код на новой схеме
Выкатка в Kubernetes идёт по одному поду: какое-то время старая версия сервиса работает уже с новой схемой. Отсюда правило «расширяй, мигрируй, сужай» (expand → migrate → contract) и тест, который берёт запросы старой версии и гоняет по новой схеме.
Так ломает старый под
ALTER TABLE orders
ADD COLUMN currency text NOT NULL;
Так переживает выкатку
ALTER TABLE orders
ADD COLUMN currency text NOT NULL DEFAULT 'RUB';
$ go test ./migrate/ -run TestOldCodeOnNewSchema -count=1 -v
contract сразу: старый INSERT даёт SQLSTATE 23502, null value in column "currency" of relation "orders" violates not-null constraint
expand: старый INSERT прошёл, currency = "RUB"
Юнит-тест такого не найдёт: обе версии кода по отдельности корректны и обе зелёные. Ошибка живёт
в паре «схема после миграции плюс запрос до миграции». Ловит этот тест короткий и
повторяющийся список: NOT NULL без значения по умолчанию, переименованная колонка,
суженный тип, удалённая колонка под SELECT *, новый CHECK на
существующих данных.
Кто накатывает миграции, когда пакетов много
Если пакеты делят базу и каждый в TestMain зовёт goose up, они сходятся
на этом одновременно. Первый успевает, остальным достаются обломки:
$ go test ./migrate/ -run TestConcurrentMigrations -count=1 -v
без блокировки, прогон 1: <nil>
без блокировки, прогон 2: ERROR: relation "goose_db_version" does not exist (SQLSTATE 42P01); ERROR: duplicate key value violates unique constraint "pg_type_typname_nsp_index" (SQLSTATE 23505)
с advisory-блокировкой: все четыре прогона вернули <nil>
locker, err := lock.NewPostgresSessionLocker() // goose v3.28, pg_try_advisory_lock
p, err := goose.NewProvider(goose.DialectPostgres, db, os.DirFS("../migrations"),
goose.WithSessionLocker(locker)) // остальные ждут первого
_, err = p.Up(ctx)
Поды, которые стартуют разом, накатывают миграции так же дружно, как пакеты в тесте, и с тем же результатом. Поэтому миграции выносят отдельной стадией пайплайна, а если они всё-таки идут из приложения, то под advisory-блокировкой. Тест на конкурентный прогон проверяет ровно это свойство и стоит секунду.
Фикстуры и фабрики
Дамп снимают с куска боевой базы через pg_dump или держат SQL-файлом,
который заливается перед прогоном. Читается плохо, привязан к схеме на момент снятия, ломается
на первой миграции с переименованием. Хуже того, тест перестаёт объяснять, что ему нужно: в
дампе двести строк, важны две, и какие именно, знает только автор.
Фабрика создаёт валидную строку с разумными умолчаниями и принимает точечные правки. Тест читается как требование: «пользователь с такой почтой, у него один оплаченный заказ». Миграция ломает одну функцию, а не двести строк дампа.
func makeUser(t *testing.T, opts ...UserOpt) int64 {
t.Helper()
ctx := context.Background()
// Уникальность берём у базы, а не у счётчика в пакете:
// счётчик обнулится между прогонами, последовательность - нет.
var seq int64
require.NoError(t, pool.QueryRow(ctx, `SELECT nextval('users_id_seq')`).Scan(&seq))
spec := userSpec{email: fmt.Sprintf("user-%d@example.com", seq)}
for _, o := range opts {
o(&spec)
}
var id int64
require.NoError(t, pool.QueryRow(ctx,
`INSERT INTO users (id, email) VALUES ($1, $2) RETURNING id`,
seq, spec.email).Scan(&id))
// Уборка своя на каждую строку: параллельному соседу она не мешает.
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
Общий teardown в конце пакета («потом всё вычистим») держит данные всех тестов вместе
до конца прогона, и они мешают друг другу. Уборка в фабрике снимает ровно то, что
фабрика создала, работает при t.Parallel() и переживает t.Fatal
в середине теста: стек Cleanup разматывается в любом случае.
$ go test ./fixtures/ -run TestFactoryParallel -count=1 -v
fixtures_test.go:94: пользователь 9: заказов 1
fixtures_test.go:94: пользователь 11: заказов 1
fixtures_test.go:94: пользователь 10: заказов 1
Номера 9, 11, 10 не совпадают ни с порядком сабтестов, ни с прошлым прогоном. Так фабрика и должна работать, а заодно это напоминание: выданный базой идентификатор в утверждениях теста участвовать не может.
Golden-дамп таблицы
Когда сценарий оставляет в базе десяток строк с десятком полей, проверка по одному полю
превращается в стену require.Equal, в которой не видно смысла. Здесь уместен
эталонный файл. Golden-файлы и флаг -update разобраны в главе 5.1; специфика базы
в том, что срез надо сделать детерминированным до сравнения.
err := pool.QueryRow(ctx, `
SELECT jsonb_pretty(jsonb_agg(x ORDER BY x.amount))
FROM (SELECT amount::text, status FROM orders WHERE user_id = $1) x`,
uid).Scan(&got)
Решений в запросе три. ORDER BY внутри агрегата задаёт порядок строк.
Идентификаторы и created_at в срез не попадают, потому что меняются от прогона к
прогону. amount::text превращает число в строку: масштаб numeric(12,2)
jsonb сохранит и сам, но чужой разбор JSON из 100.00 сделает 100, а
строка переживёт и это. Правило же общее для любых эталонов: дифф в testdata/
читают глазами на ревью, иначе -update превращается в ритуал.
Детерминизм данных
Три источника недетерминизма (время, случайность, конкурентность) разобраны в главе 5.1. База добавляет свои, и они дают львиную долю мигающих тестов на интеграционном прогоне.
now() — это не «сейчас»
now() возвращает время начала транзакции и внутри неё не идёт. Тест, который
вставляет две строки одной транзакцией и ждёт разный created_at, падает; тест,
который ждёт одинаковый, зеленеет внутри транзакции и краснеет, как только код начнёт коммитить
между вставками.
$ go test ./determinism/ -run TestNowIsFrozen -count=1 -v
now() сдвинулось на 0s
statement_timestamp() сдвинулось на 103ms
clock_timestamp() сдвинулось на 103ms
Отсюда практика: если время записи участвует в логике (истечение, окна, сортировка по
свежести), оно приходит из кода параметром, тем самым Clock, который инжектируют
в сервис. Тест двигает фейковые часы и не зависит ни от транзакций, ни от часового пояса
сервера. DEFAULT now() остаётся для полей, которые нужны человеку в отчёте, а не
коду в условии.
Последовательности откату не подлежат
nextval работает вне транзакции: число выдаётся и не возвращается, даже если
транзакция откатилась. Сделано это ради конкурентности, а тестам достаётся
непредсказуемый идентификатор.
$ go test ./determinism/ -run TestSequenceSurvivesRollback -count=1 -v
прогон 1: транзакция откачена, выданный id = 1
прогон 2: транзакция откачена, выданный id = 2
прогон 3: транзакция откачена, выданный id = 3
строк в таблице: 0
Утверждение require.Equal(t, int64(1), order.ID) проходит ровно один раз, первым
в свежей базе. Дальше оно зависит от числа откатов до него, от порядка тестов и от того,
запустили пакет целиком или с -run. Проверять тут можно разве что «не ноль» и
«тот же, что вернула вставка». С UUID так же: случайный uuidv4 в каждом прогоне
новый, сравнивать с эталоном и сортировать по нему нельзя. Предсказуемый идентификатор
генерирует инжектированный генератор, а не база; а для сортировки по времени создания есть UUID
седьмой версии: в PostgreSQL 18 функции uuidv4() и uuidv7() появились
прямо в ядре.
Порядок строк без ORDER BY
Самый частый источник мигающего теста на базе. SQL не обещает порядок без ORDER BY,
и на практике он меняется от обычного UPDATE: новая версия строки дописывается в
конец, старая помечается мёртвой.
$ go test ./determinism/ -run TestRowOrderWithoutOrderBy -count=1 -v
после вставки: [u0@example.com u1@example.com u2@example.com u3@example.com u4@example.com]
после UPDATE одной строки: [u0@example.com u2@example.com u3@example.com u4@example.com u1-renamed@example.com]
Поедет он и по другим причинам: планировщик переключится на чтение по индексу, когда таблица
подрастёт; включится параллельное чтение; после VACUUM новая строка ляжет в
освободившееся место в середине. В CI всё это случается на других данных, чем на ноутбуке.
Отсюда и «локально зелёный, в CI красный».
- Конкретный идентификатор.
id == 1держится до первого отката, повторного прогона или соседнего теста. - Порядок строк без
ORDER BY. Либо сортировка в запросе, либо сортировка результата в тесте, либо сравнение как множеств. - Точное время.
created_at == time.Now()не сойдётся: у базы своё разрешение и свой часовой пояс. Сравнивай с допуском или бери время из фейковых часов.
Тесты транзакционной логики
Всё, ради чего стоит поднимать настоящую базу, живёт здесь: блокировки, уровни изоляции, конкурентные обновления. Общее у таких тестов одно: им нужно минимум два соединения, потому что конфликт возникает между транзакциями, а не внутри одной. Изоляция «тест в одной транзакции» тут не годится в принципе.
Почему READ COMMITTED прячет ошибку
По умолчанию PostgreSQL работает на READ COMMITTED, и классическое потерянное
обновление (прочитали баланс, посчитали новое значение в коде, записали) проходит молча.
$ go test ./txlogic/ -run TestLostUpdate -count=1 -v
read committed вторая транзакция: ошибки нет баланс 1100
repeatable read вторая транзакция: 40001 could not serialize access due to concurrent update баланс 1100
Обе строки дают 1100 вместо 1200, но разница принципиальная. На READ COMMITTED
приложение об этом не узнаёт; на REPEATABLE READ сервер отвергает вторую запись
кодом 40001, и у кода появляется шанс повторить операцию. Отсюда правило:
уровень изоляции в тесте берут боевой. Тест на умолчании не воспроизведёт конфликт,
который сервис ловит на SERIALIZABLE, а код, рассчитанный на ретраи, на
READ COMMITTED покажет тихо неверный результат.
Очередь на SKIP LOCKED
const q = `SELECT payload FROM jobs WHERE NOT done ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1`
txA, _ := a.Begin(ctx) // соединение A: возьмёт job-1 и будет держать блокировку
txB, _ := b.Begin(ctx) // соединение B: job-1 пропустит, возьмёт job-2
// Тот же запрос без SKIP LOCKED встаёт в очередь. lock_timeout превращает
// бесконечное ожидание в ошибку и не даёт тесту зависнуть.
_, _ = c.Exec(ctx, `SET lock_timeout = '300ms'`)
$ go test ./txlogic/ -run TestSkipLocked -count=1 -v
воркер A взял: job-1 (<nil>)
воркер B взял: job-2 (<nil>)
воркер C без SKIP LOCKED ждал 300ms и получил: 55P03 canceling statement due to lock timeout
Дедлок и сериализационный сбой
Взаимная блокировка воспроизводится за десять строк: две транзакции берут те же две строки в
обратном порядке. Сервер обнаруживает цикл не сразу: сначала выжидает
deadlock_timeout (по умолчанию секунду), потом убивает одну из транзакций кодом
40P01.
$ go test ./txlogic/ -run TestDeadlock -count=1 -v
deadlock_timeout = 1s
pg_stat_activity, кто стоит на блокировке:
pid=200 state=active wait=Lock/transactionid query=UPDATE accounts SET balance = balance + 10 WHE
pid=201 state=active wait=Lock/transactionid query=UPDATE accounts SET balance = balance + 10 WHE
одна из транзакций упала: 40P01 deadlock detected
Запрос к pg_stat_activity посреди теста служит отладке, а не проверке.
Колонки wait_event_type и wait_event показывают, что сессия ждёт
именно блокировку, а query говорит, на каком запросе она встала. Когда тест зависает в CI,
а локально проходит, дамп этой вьюшки в лог теста отвечает на вопрос быстрее любых рассуждений.
Для приложения 40P01 и 40001 означают одно и то же: повтори
транзакцию. Повторять надо целиком, с чтения: после сбоя соединение откачено и прочитанные
данные устарели. Часто вместо этого ретраят один UPDATE внутри той же транзакции,
и PostgreSQL отвечает 25P02 на любой следующий запрос.
func isSerializationFailure(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && (pgErr.Code == "40001" || pgErr.Code == "40P01")
}
for range 5 {
err = add(conn, 100) // вся транзакция целиком, с самого BEGIN
if !isSerializationFailure(err) {
break
}
time.Sleep(10 * time.Millisecond) // в проде здесь backoff с джиттером
}
Первая: тесту нужны два соединения, поэтому изоляция «тест в одной транзакции» для него
не работает. Вторая: уровень изоляции в тесте совпадает с боевым, иначе проверяемый
конфликт просто не возникнет. Третья: у теста на блокировку обязан быть свой таймаут,
lock_timeout на сессии или дедлайн контекста. Иначе он не падает, а зависает до
-timeout всего прогона.
Моки базы: pgxmock и go-sqlmock
Эти библиотеки подменяют не репозиторий, а сам драйвер: код выполняет свои запросы, а вместо сервера ему отвечает заготовленный сценарий. Проверяют они две вещи: строку запроса и порядок вызовов с аргументами. Строку по умолчанию сравнивают регулярным выражением, причём частичным: ожидание засчитывается, если шаблон где-то в запросе нашёлся. Пробелы и переводы строк обе библиотеки перед сравнением схлопывают.
// В SQL репозитория живёт ошибка: колонки user_id в таблице users нет.
err := r.db.QueryRow(ctx,
`SELECT id, email FROM users WHERE user_id = $1`, id).Scan(&u.ID, &u.Email)
// А в тесте:
m.ExpectQuery(`SELECT id, email FROM users`). // шаблон совпал с началом запроса
WithArgs(int64(7)).
WillReturnRows(pgxmock.NewRows([]string{"id", "email"}).AddRow(int64(7), "a@example.com"))
тест зелёный: получили {ID:7 Email:a@example.com}, хотя в таблице users нет колонки user_id
настоящая база: SQLSTATE 42703, column "user_id" does not exist
Подделка драйвера не знает про схему. Она не проверит существование таблицы и колонки, типы,
NULL, уникальный индекс, внешний ключ, каскад, права. То есть всё, ради чего тест
на базе и нужен.
Отдельная ловушка ждёт в регулярных выражениях. Если записать ожидание как полный текст запроса,
$1 в нём станет якорем конца строки, и совпадения не будет; сообщение об ошибке при
этом печатает две на вид одинаковые строки:
ожидание записано как есть: could not match actual sql: "SELECT id, email FROM users WHERE user_id = $1" with expected regexp "SELECT id, email FROM users WHERE user_id = $1"
ожидание через regexp.QuoteMeta: <nil>
Лечится regexp.QuoteMeta вокруг ожидания или переключением на точное сравнение
(QueryMatcherEqual). Точное честнее, но привязывает тест к тексту запроса: добавил
колонку в SELECT, и тест красный, хотя поведение не изменилось. Тот самый налог с
рефакторинга, о котором шла речь в главе 5.2.
Подделка драйвера стоит на месте там, где нужного поведения от настоящей базы не
добьёшься или добьёшься слишком дорого: отмена запроса на сервере (57014), обрыв
соединения посреди транзакции, дедлайн контекста на медленном ответе, сбой на самом
COMMIT, редкие коды вроде 53300 в нужный момент. Всё это проверяет,
как код реагирует на ошибку, а не то, что он делает с данными.
m.ExpectQuery(`SELECT id, email FROM users`).WithArgs(int64(7)).
WillReturnError(&pgconn.PgError{Code: "57014", Message: "canceling statement due to user request"})
// а с WillDelayFor(50*time.Millisecond) и контекстом на 10 мс проверяется дедлайн
// отмена запроса на сервере: : canceling statement due to user request (SQLSTATE 57014)
// дедлайн контекста: context deadline exceeded
И ещё одна мелочь, которая стоит часа: pgxmock реализует интерфейсы pgx, а
интерфейсы растут. В pgx v5.11.0 у pgx.Rows появился метод TypeMap(),
и pgxmock v4.9.0 перестал собираться:
*rowSets does not implement pgx.Rows (missing method TypeMap). Прогоны этого раздела
сняты на pgx v5.10.0 именно поэтому.
Redis в тестах: miniredis против контейнера
miniredis переписывает Redis на Go и поднимает его внутри тестового процесса.
Стартует за микросекунды, слушает настоящий порт, говорит на протоколе
RESP, так что боевой клиент подключается без
изменений (mr := miniredis.RunT(t), дальше обычный
redis.NewClient на mr.Addr()). Устройство самого Redis разбирается в
теме «Кэширование и Redis»; здесь остаются только расхождения подделки с оригиналом.
Часы у него свои. Ключ с TTL не исчезнет от того, что тест поспал: время двигают вручную.
Для теста это скорее плюс: проверка истечения перестаёт зависеть от
time.Sleep, хотя настоящий Redis ведёт себя иначе.
$ go test ./rediscmp/ -run TestExpire -count=1 -v
после 400 мс сна: miniredis exists=1, настоящий Redis exists=0
после mr.FastForward(400ms): miniredis exists=0
Память не ограничена. Команду CONFIG miniredis не знает вовсе, а значит, ни
maxmemory, ни политику вытеснения задать нельзя: кэш ведёт себя как безразмерный
словарь. Двадцать тысяч ключей по килобайту против примерно пяти тысяч уцелевших на настоящем Redis с
maxmemory=8mb и allkeys-lru: вот и вся разница между «кэш работает»
и «кэш вытесняет».
$ go test ./rediscmp/ -run TestMaxmemory -count=1 -v
miniredis: CONFIG SET maxmemory=ERR unknown command `config`, …, ключей осталось 20000
redis:8: CONFIG SET maxmemory=<nil>, policy=<nil>, ключей осталось 4874
Набор команд меньше, и попадается это в основном на интроспекции:
OBJECT ENCODING, CLIENT INFO, FUNCTION LIST miniredis
v2.39.0 не поддерживает, а MEMORY USAGE, WAIT и LPOS
работают как у настоящего. Lua выполняется на другом движке, gopher-lua.
Простые скрипты идут одинаково: redis.call, cjson,
redis.sha1hex, redis.status_reply, pcall. Расхождения
начинаются на краях, причём в обе стороны: miniredis местами разрешает то, что настоящий Redis
запрещает:
$ go test ./rediscmp/ -run TestLua -count=1 -v
cjson miniredis: [1,2,3] redis:8: [1,2,3]
redis.status_reply miniredis: OK redis:8: OK
server.call (8.0) miniredis: PONG redis:8: ERR user_script:1: Script attempted to access …
Когда Redis в коде работает кэшем или простым хранилищем ключей: положили, прочитали, удалили,
поставили TTL, проверили сериализацию значения. Здесь miniredis обгоняет контейнер на
порядки и даёт управляемые часы. Настоящий Redis нужен там, где проверяется
семантика сервера: Lua-скрипты, вытеснение по памяти, SCAN с курсором на
больших наборах, WATCH/MULTI, кластер, Pub/Sub с
отключениями. Разделять прогоны удобно тем же билд-тегом, что и тесты на PostgreSQL.
Вопросы
5TRUNCATE между
тестами, своя схема или база. Транзакция самая быстрая, но не видит ничего, что происходит
на COMMIT и на других соединениях. TRUNCATE честный, но берёт
ACCESS EXCLUSIVE и потому несовместим с t.Parallel() на общей базе.
Схема на тест или пакет даёт настоящую параллель ценой прогона миграций в каждую схему.Чего не видит транзакция с откатом
- Код, который сам берёт соединение. Use case с
pool.Begin()получит другое соединение: строк теста не увидит, а свои закоммитит мимо отката. - Всё, что проверяется на
COMMIT. Отложенные ограничения внутри транзакции молчат и стреляют на коммите, а его тут не случается. - Вложенный
Begin. В pgx этоSAVEPOINT: «коммит» кода оказываетсяRELEASE SAVEPOINT, а ошибка откатывает не всю транзакцию, а до точки сохранения. - Видимость между соединениями. Поллеры outbox, воркеры с
SKIP LOCKED,LISTEN/NOTIFY.
Как это раскладывают
Порядок величин задаёт ответ: старт контейнера занимает секунды, прогон миграций десятки
миллисекунд, TRUNCATE и BEGIN/ROLLBACK доли миллисекунды.
Значит, контейнер и миграции один раз на пакет, а изоляция на уровне данных на каждый тест.
На практике это схема (или база) на пакет плюс
TRUNCATE … RESTART IDENTITY CASCADE между тестами внутри пакета. Транзакция с
откатом остаётся для тестов репозитория, которому транзакцию передают параметром: там
проверяется SQL, маппинг типов и превращение кода ошибки в доменную.
«Оборачиваем каждый тест в транзакцию и откатываем — и всё изолировано» звучит уверенно ровно
до вопроса «а если код внутри сам делает Begin и Commit?». Ответ
«тогда тест этого не увидит» стоит половины оценки за вопрос.
-count=10,
-shuffle=on, прогон в контейнере с теми же ресурсами. Дальше идти по короткому
списку: порядок строк без ORDER BY, утверждения про конкретные идентификаторы,
now() и часовой пояс, общие данные между параллельными тестами, нехватка
соединений в пуле, search_path, заданный запросом на пул.Воспроизведение
Локальный прогон отличается от CI тремя вещами: числом ядер (а значит, и числом параллельных
пакетов), объёмом данных в базе и скоростью диска. Поэтому сначала
go test -count=10 -shuffle=on ./..., затем то же под нагрузкой и в контейнере
с урезанным CPU. Если тест начинает падать при -shuffle=on, он завязан на
порядок, и дальше искать надо общие данные, а не гонку.
Короткий список причин, специфичных для базы
| Симптом | Что смотреть |
|---|---|
| Слетел порядок элементов | запрос без ORDER BY: план меняется от объёма данных, а UPDATE двигает строку в конец |
| Не сошёлся идентификатор | утверждение про id == 1: последовательность не откатывается и растёт от каждого отката |
| Не сошлось время | now() заморожен внутри транзакции; у контейнера CI другой часовой пояс |
| Чужие строки или пусто | параллельные тесты на общей базе; TRUNCATE соседа |
| «relation does not exist» через раз | SET search_path выполнен на пул и остался на одном соединении |
| Тест не падает, а висит | пул исчерпан и ждёт соединения; блокировка без lock_timeout |
| «too many clients» | MaxConns × число параллельных пакетов превысило max_connections минус резерв |
Смотреть удобно прямо из теста: дамп pg_stat_activity с state,
wait_event_type и query показывает, кто стоит на блокировке и на
каком запросе, а статистика пула pgx (TotalConns,
EmptyAcquireCount) отвечает, не упёрлись ли в соединения. С Go 1.26 такие файлы
складывают в t.ArtifactDir(), чтобы CI их сохранил.
Воспроизвести → сузить до одного теста → понять, зависит он от соседей или от данных →
починить причину, а не симптом. Перевод теста в последовательный режим или
t.Skip причину не убирает: если тест ловил настоящую гонку в коде, она уехала
в прод.
up до головы проходит на чистой базе;
down до нуля откатывает всё, и повторный up снова проходит;
запросы предыдущей версии сервиса работают на новой схеме. Последнее нужно потому, что при
выкатке по одному поду старый и новый код какое-то время живут рядом, а схема уже новая.Up, down, up
Секции Down не исполняются годами, и это выясняется ночью при откате релиза.
Тест накатывает миграции в свою схему, откатывает до нуля, проверяет, что таблиц не
осталось, и накатывает снова. Повторный up важнее первого: он ловит
Down, удаливший таблицу, но забывший тип, последовательность или функцию:
схема выглядит пустой, а CREATE TYPE на втором проходе падает.
Expand → migrate → contract
Rolling-деплой означает, что новая схема какое-то время обслуживает старый код. Поэтому миграция сначала только расширяет схему так, чтобы её понимали обе версии, следующим релизом данные переносятся, и только третьим лишнее убирается.
-- ломает старый под: он не знает про currency
ALTER TABLE orders ADD COLUMN currency text NOT NULL;
-- SQLSTATE 23502: null value in column "currency" of relation "orders" violates not-null constraint
-- переживает выкатку
ALTER TABLE orders ADD COLUMN currency text NOT NULL DEFAULT 'RUB';
Юнит-тест такого не найдёт: обе версии кода по отдельности корректны. Ошибка живёт в паре
«схема после миграции плюс запрос до миграции», и увидеть её можно только на настоящей базе.
Тот же тест ловит переименование колонки, суженный тип, удалённую колонку под
SELECT * и новый CHECK на существующих данных. Отдельно стоит
вопрос, кто миграции накатывает: если несколько пакетов делят базу и каждый зовёт up,
первый пройдёт, а остальные получат
relation "goose_db_version" does not exist. Лечится advisory-блокировкой или
базой на пакет. Ровно та же проблема живёт в проде, когда миграции идут из приложения на
нескольких репликах.
SKIP LOCKED проверяется, что второй воркер взял следующую задачу, а не ту же
и не ожидание. Для ретрая проверяется, что код ловит 40001 и повторяет
транзакцию целиком, а не один запрос.Тест на SKIP LOCKED
Две транзакции на разных соединениях. Первая берёт задачу и держит блокировку; вторая на том
же запросе с SKIP LOCKED должна получить следующую строку. Третьим шагом
полезно показать разницу: тот же запрос без SKIP LOCKED встаёт в очередь.
воркер A взял: job-1
воркер B взял: job-2
воркер C без SKIP LOCKED ждал 300ms и получил: 55P03 canceling statement due to lock timeout
SET lock_timeout = '300ms' на сессии теста превращает бесконечное ожидание в
понятную ошибку. Тот же приём работает со statement_timeout, когда проверяется
поведение кода на долгом запросе.
Тест на ретрай
Уровень изоляции в тесте должен быть боевым. На READ COMMITTED потерянное
обновление проходит молча: конфликта, ради которого писали ретрай, просто не возникнет. На
REPEATABLE READ и SERIALIZABLE вторая транзакция получает
40001 could not serialize access due to concurrent update, и на этом коде
проверяется, что обёртка с повтором доводит операцию до конца. Повторять нужно транзакцию
целиком, с самого чтения: после 40001 соединение откачено, а прочитанные данные
устарели, и попытка повторить один UPDATE внутри той же транзакции даст
25P02 на любой следующий запрос. Дедлок (40P01) для приложения
означает то же самое, поэтому оба кода обрабатываются одной функцией.
Чаще всего в этих тестах забывают ограничить ожидание. Тест не краснеет, а стоит до
-timeout всего прогона (10 минут по умолчанию) и потом печатает дамп горутин,
по которому причина не видна. Дедлайн контекста, lock_timeout на сессии,
statement_timeout: годится любой из трёх, но какой-то должен быть.
miniredis ближе к оригиналу, но у него свои часы, нет
CONFIG и вытеснения по памяти, а Lua идёт на другом движке.Что сверяют pgxmock и go-sqlmock
Строку запроса и последовательность вызовов. Матчер по умолчанию берёт регулярное выражение,
причём частичное: ожидание SELECT id, email FROM users совпадёт с запросом, у
которого дальше идёт любой мусор. Пробелы и переводы строк перед сравнением схлопываются.
тест зелёный: получили {ID:7 Email:a@example.com}, хотя в таблице users нет колонки user_id
настоящая база: SQLSTATE 42703, column "user_id" does not exist
За границей проверки остаётся всё, ради чего база нужна: существование таблиц и колонок,
типы, NULL, уникальные индексы, внешние ключи, каскады, транзакции и блокировки.
Отдельная ловушка сидит в $1: в регулярном выражении это якорь конца строки,
совпадения не будет, а сообщение об ошибке покажет две одинаковые на вид строки;
спасает regexp.QuoteMeta или точное сравнение. На месте подделка драйвера там,
где нужного от живого сервера не добьёшься: отмена запроса (57014), обрыв
соединения посреди транзакции, дедлайн контекста, сбой на самом COMMIT, редкие
коды вроде 53300 в нужный момент. Правило простое: подделка драйвера проверяет,
как код реагирует на ошибку, а что он делает с данными, проверяет настоящая
база.
miniredis
Он ближе к оригиналу: настоящий протокол, настоящий клиент, реальные структуры данных.
Расхождений немного. Часы у него свои: TTL не истекает от time.Sleep,
нужен FastForward. CONFIG не поддерживается вовсе, значит, ни
maxmemory, ни политику вытеснения задать нельзя, и кэш в тесте ведёт себя как
безразмерный словарь. Часть административных команд отсутствует, а Lua выполняется на
gopher-lua, и на краях поведение расходится в обе стороны.
Отсюда граница. Кэш и простое хранилище ключей закрывает miniredis; Lua-скрипты, вытеснение,
SCAN на больших наборах, WATCH/MULTI, кластер и
Pub/Sub требуют настоящего Redis в контейнере, под тем же билд-тегом, что и
тесты на PostgreSQL.
Что стоит запомнить
- Выбирай транзакцию, схему или базу по границам соединений кода.
- Реальные SQL, constraints и locks требуют PostgreSQL.
- Миграции проверяются на чистой базе.
Частые ошибки
- Общий cleanup может удалить чужие данные теста.
- Внешний connection pool может обойти test transaction.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Разбери проверку
Что делает этот PostgreSQL тест опасным при параллельном запуске? Назови конкретные проблемы в SQL и cleanup.
func TestCreate(t *testing.T) {
t.Parallel()
_, err := db.Exec("INSERT INTO item(name) VALUES (?)", "a")
if err != nil { t.Fatal(err) }
defer db.Exec("DELETE FROM item")
}
Ответ и разбор
Для PostgreSQL/pgx placeholder должен быть $1, а не ?. DELETE без WHERE удалит строки, созданные другими тестами; одинаковое имя также может конфликтовать с уникальным constraint. Используй уникальный ключ каждого теста и удаляй только эту запись либо изолируй тест транзакцией/схемой. Проверяй ошибку cleanup.
Код-ревью
2. Найди дефект и исправь
Внешняя транзакция не изолирует запись: service получает db pool и сам берёт отдельное соединение. Исправь границу так, чтобы тестовый rollback покрывал работу.
func TestCreate(t *testing.T) {
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
service := NewService(db)
_ = service.Create(ctx, item)
}
Ответ и разбор
service пишет через исходный pool, а не через tx; эта запись не участвует во внешнем rollback. Передай транзакцию в сервис через transaction handle или открой отдельную схему для теста. Проверь ошибки BeginTx и Create; вместо игнорируемого Rollback обработай её согласно test setup. Так проверяются и фактическая граница соединения, и cleanup транзакции.
Применение
3. Реши сценарий
Тест проверяет, что два конкурентных запроса не резервируют один и тот же последний товар. Какой уровень изоляции проверить и почему mock не даст ответ?
Ответ и разбор
Проверь реальный SQL/транзакционный протокол на PostgreSQL с конкурентными транзакциями и тем же isolation/locking, что в production. Mock не исполняет блокировки, MVCC или constraints, поэтому не обнаружит double reservation. После гонки должно быть ровно одно успешное резервирование, а остаток не ниже нуля.
Применение
4. Проверь альтернативу
Миграция добавила NOT NULL колонку status в таблицу, которой пользуется старая версия сервиса при rolling deploy. Как сделать тест совместимости и безопасный порядок?
Ответ и разбор
Проверь старый код на схеме после expand шага. Сначала добавь nullable/default-compatible колонку, выкатай код, который заполняет и читает её совместимо, затем backfill и только после этого добавляй NOT NULL constraint отдельным этапом. Интеграционный тест должен запускать старый сценарий на новой промежуточной схеме; не делай несовместимое требование одним шагом.