Раздел 29 · Тестирование

Параллелизм и изоляция: почему тест зелёный локально и красный в CI

middle-senior~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Параллелизм и изоляция: почему тест зелёный локально и красный в 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.