voidlearn
 

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 по TCP0,7 сраз на прогон
goose up, 4 миграции11 мсраз на базу
goose up, 60 миграций73 мсраз на базу
CREATE DATABASE … TEMPLATE с теми же 60 миграциями10 мсраз на пакет
CREATE SCHEMA + 4 миграции внутрь неё9 мсна тест или пакет
TRUNCATE четырёх таблиц, RESTART IDENTITY CASCADE0,75 мсна каждый тест
BEGIN + INSERT + ROLLBACK0,36 мсна каждый тест

Между первой строкой и двумя последними три порядка. Отсюда устройство прогона: контейнер и миграции выносят на пакет, между тестами трогают только данные. Сотня тестов, у каждого свой контейнер, съедает больше минуты; с одним контейнером на всех уходят десятые доли секунды.

Дальше вступает шаблонная база: на четырёх миграциях копия не выигрывает у прогона, на шестидесяти выигрывает всемеро, а в трёхлетнем сервисе их триста. На выбор изоляции не влияет, откуда база взялась физически: из testcontainers-go, из docker-compose или из сервиса рядом с job'ом. Для тестов это одинаковый DSN в переменной окружения.

Три способа изоляции и чем каждый платит

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

Что происходит на базе между тестом A и тестом B 1. Транзакция 0,36 мс на тест параллель возможна коммиты не видны BEGIN тест A пишет ROLLBACK BEGIN тест B пишет ROLLBACK база после прогона чистая, но ни одного COMMIT за весь прогон не случилось 2. Очистка 0,75 мс на тест только по очереди коммиты настоящие TRUNCATE тест A пишет TRUNCATE тест B пишет TRUNCATE берёт ACCESS EXCLUSIVE: параллельный тест либо ждёт, либо видит чужую пустоту 3. Схема на тест 9 мс на схему честная параллель миграции на каждую CREATE SCHEMA goose up тест A → схема t_1 тест B → схема t_2 таблицы у каждого свои, а search_path задаётся строкой подключения, а не запросом SET Цена растёт с числом миграций: копия шаблонной базы — 10 мс против 73 мс на прогон 60 миграций.
Три способа развести тесты по данным. Слева цена и ограничение каждого способа, справа то, что происходит на базе в промежутке между двумя тестами.

Транзакция с откатом

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

Два воркера разбирают таблицу jobs одновременно 1. Без блокировки: обычный SELECT … LIMIT 1 jobs: job-1 job-2 job-3 воркер A взял job-1 воркер B взял job-1 одна задача выполнена дважды, и никто об этом не узнал 2. FOR UPDATE: строка заблокирована до конца транзакции A jobs: job-1 job-2 job-3 воркер A взял job-1 воркер B стоит в очереди через 300 мс сработал lock_timeout: SQLSTATE 55P03, задача не взята 3. FOR UPDATE SKIP LOCKED: заблокированные строки пропускаются jobs: job-1 job-2 job-3 воркер A взял job-1 воркер B взял job-2 оба работают, ни одна задача не досталась двоим
Два воркера за одной строкой. Разница между тремя вариантами видна только на разных соединениях: на одном все три запроса ведут себя одинаково. В прогоне рядом ждущий воркер — третье соединение, C.
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 …
Когда miniredis достаточно

Когда Redis в коде работает кэшем или простым хранилищем ключей: положили, прочитали, удалили, поставили TTL, проверили сериализацию значения. Здесь miniredis обгоняет контейнер на порядки и даёт управляемые часы. Настоящий Redis нужен там, где проверяется семантика сервера: Lua-скрипты, вытеснение по памяти, SCAN с курсором на больших наборах, WATCH/MULTI, кластер, Pub/Sub с отключениями. Разделять прогоны удобно тем же билд-тегом, что и тесты на PostgreSQL.

Вопросы

5
Суть: способов три. Транзакция с откатом, TRUNCATE между тестами, своя схема или база. Транзакция самая быстрая, но не видит ничего, что происходит на 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 отдельным этапом. Интеграционный тест должен запускать старый сценарий на новой промежуточной схеме; не делай несовместимое требование одним шагом.