Параллелизм и изоляция: почему тест зелёный локально и красный в CI
Сцена · два повара, одна кастрюля
Два повара готовят быстрее одного, пока не полезут в одну кастрюлю. Один посолил, второй посолил, блюдо несъедобно, и каждый уверен, что солил один раз. Так ведут себя тесты, когда раннер запускает их параллельно, а они делят общее: одну строку в базе, один файл, один модульный объект.
Отсюда самый коварный класс багов: тест зелёный, когда ты гоняешь его один, и красный в CI, где он бежит рядом с соседями. Причина не в коде функции, а в общем состоянии.
Карта урока · что заберёшь
- поймёшь, как раннер распараллеливает тесты и где это ломается;
- разберёшь три стратегии изоляции и их цену;
- увидишь, почему в Effect изоляция получается по построению через слой и Scope;
- поймёшь, почему BatSchool держит serial-pool против живого Postgres.
Раздел 1 · откуда берётся гонка
Идея словами
Vitest запускает тесты в нескольких воркерах, единицей параллелизма обычно служит файл. Пока тесты трогают только свои локальные данные, всё честно. Проблема начинается, когда два теста пишут в одно общее место. Тогда исход зависит от того, кто успел первым, а это меняется от запуска к запуску. Так рождается flaky-тест.
Картинка · гонка за общим остатком
Запусти двух воркеров без изоляции: оба читают остаток 100, оба списывают по 30, оба пишут 70. Одно списание потерялось, итог 70 вместо 40, тест красный. Включи изоляцию: у каждого своя транзакция, списания не затирают друг друга, итог 40, тест зелёный. Код функции при этом не менялся ни на строку.
Что взять с собой
- раннер параллелит тесты по воркерам, единица обычно файл;
- гонка возникает на общем изменяемом состоянии;
- “зелёный локально, красный в CI” это почти всегда про изоляцию.
Раздел 2 · три стратегии изоляции
Идея словами
Изолировать тесты от общих данных можно по-разному, и у каждого способа своя цена.
| Стратегия | Как работает | Цена |
|---|---|---|
| Транзакция с откатом | каждый тест в своей транзакции, после теста rollback | дёшево, но не годится проверять сами транзакции |
| Схема на воркер | у каждого воркера своя схема в одной базе | честно, дороже по настройке |
| Контейнер на воркер | у каждого воркера свой Postgres в контейнере | полная изоляция, дорого по ресурсам |
Выбор зависит от того, что проверяешь. Для обычной бизнес-логики хватает транзакции с откатом. Для тестов самих транзакций нужна схема или контейнер.
Шаг 1 · самый дешёвый вариант
it('списывает платёж в репозиторий', async () => {
await withRollback(async (db) => {
await seed(db, { id: 'acc_1', balance: 100 });
const next = await applyPaymentToAccount(db, 'acc_1', 30);
expect(next.balance).toBe(70);
});
});
withRollback открывает транзакцию, отдаёт тесту, а после теста откатывает всё назад. База после теста чистая, соседи ничего не замечают.
Что взять с собой
- транзакция с откатом дёшева, но не для тестов самих транзакций;
- схема на воркер честнее, но дороже;
- контейнер на воркер даёт полную изоляцию ценой ресурсов.
Раздел 3 · в Effect изоляция по построению
Идея словами
В Effect общее изменяемое состояние это не глобал, а сервис, спрятанный за Layer. Дать каждому тесту свой слой это и есть изоляция, без отдельной дисциплины. А ресурсы (контейнер, коннект) живут в Scope: тело it.effect работает в собственной области, поэтому ресурсы закрываются после теста сами, забытого teardown не бывает.
Шаг 1 · своё состояние у каждого теста
import { it } from '@effect/vitest';
import { Effect } from 'effect';
import { money } from 'kassa-ledger';
import { expect } from 'vitest';
import { openAccount, payAccount, readAccount } from '../src/operations.ts';
import { TestServicesLive } from '../src/layers/test.ts';
it.effect('списывает платёж и сохраняет в репозиторий', () =>
Effect.gen(function* () {
const account = yield* openAccount(money(100));
yield* payAccount(account.id, money(30));
const next = yield* readAccount(account.id);
expect(next.balance).toBe(70);
}).pipe(Effect.provide(TestServicesLive)),
);
TestServicesLive собирает in-memory реализации портов: AccountRepoInMemoryLive хранит счета в Ref. Слой строится заново для теста, поэтому у каждого теста своя память. Делить нечего, значит и гонки нет.
Шаг 2 · ресурс закрывается сам
it.effect('поднимает Postgres из контейнера и закрывает его', () =>
Effect.gen(function* () {
const pg = yield* PostgresContainer; // ресурс в Scope
const next = yield* applyPaymentAgainst(pg, 'acc_1', 30);
expect(next.balance).toBe(70);
}).pipe(Effect.provide(PostgresContainer.layer)),
);
Контейнер заведён как ресурс в Scope: слой строит его через Layer.effect и вешает финализатор. Тест упал или прошёл, неважно, it.effect закроет область теста, а вместе с ней и контейнер. Отдельного it.scoped для этого больше не нужно: любой эффектный тест по умолчанию живёт в своей области. Это прямая иллюстрация мысли серии: подмена зависимости и управление её жизнью это один и тот же механизм слоёв и Scope.
Реальный кейс BatSchool
Сама BatSchool держит pool: 'forks' и fileParallelism: false для тестов против живого Postgres. Это не лень: интеграционные тесты бьют в общую базу, и serial-pool это осознанный выбор простоты вместо гонок. Уйти от него можно, выдав каждому воркеру свою схему через слой-конфиг подключения, но цена настройки должна оправдаться. В Kassa сервер тестируется так же: apps/kassa-api/vitest.config.ts ставит pool: 'forks', fileParallelism: false.
Когда код пишет агент
Агент включает максимальный параллелизм по умолчанию: так быстрее. На чистом домене это безопасно и правильно. На интеграционных тестах против общей базы это прямой путь к flaky-набору. Параллелить домен, изолировать или сериализовать интеграцию это решение, которое принимает человек, понимающий, где общее состояние.
Что взять с собой
- в Effect состояние это сервис в слое, у каждого теста свой слой;
- ресурс в Scope закрывается сам, потому что
it.effectдаёт тесту свою область; - serial-pool против живой базы это осознанный выбор, а не недоработка.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.