ConstructiCat Logo
CodeBust.
Browse section ▾

Война тестовых прогонов.

Война тестовых прогонов — это когда тесты проходят у одного человека, но начинают случайно падать, стоит нескольким людям или CI-задачам запустить набор тестов одновременно, потому что тесты конфликтуют на общей персистентной фикстуре.

##Signs and Symptoms

Войну тестовых прогонов узнают по тому, кто и когда, а не по самому телу теста:

  • Набор тестов зелёный на вашей машине и в одиночных прогонах CI, но краснеет «без причины», когда коллега запускает его одновременно или когда две CI-задачи/конвейера обращаются к одному окружению.
  • Падения преходящи и невоспроизводимы — повторный запуск того же теста обычно делает его зелёным.
  • Падения группируются вокруг общего, изменяемого, персистентного ресурса: одна общая тестовая база, общий S3-бакет/очередь/кеш или фиксированный аккаунт/тенант.
  • Формы ошибок характерны: нарушения уникальности / дублирующегося ключа, expected 1 row but found 2 или record not found (чужой прогон её удалил).
// Оба теста считают, что монопольно владеют ОБЩЕЙ тестовой базой данных.
test('creates a user', async () => {
  await db.users.insert({ id: 42, email: 'alice@example.com' }); // жёстко зашитый ключ
  expect(await db.users.findById(42)).toMatchObject({ email: 'alice@example.com' });
});

test('counts active users', async () => {
  expect(await db.users.countActive()).toBe(1); // предполагает, что никто другой не добавлял строк
});

Когда два раннера обращаются к одной базе одновременно, вставка сталкивается на id: 42 (дублирующийся ключ), а глобальный счётчик уже не равен 1. По отдельности каждый тест проходит; вместе и одновременно — они дерутся.

##Reasons for the Problem

Почему это происходит. Тесты опираются на глобальную общую фикстуру (Shared Fixture) — обычно единственный персистентный ресурс (одна общая тестовая БД, общий бакет, фиксированный пользователь) вместо свежей фикстуры (Fresh Fixture), создаваемой на каждый тест. Жёстко зашитые идентификаторы и проверки глобального состояния молча предполагают, что тест владеет ресурсом. Пока в каждый момент к нему обращается ровно один прогон, иллюзия держится. Добавьте конкурентность — второго разработчика, параллельные CI-задачи или параллельные тестовые воркеры — и прогоны начинают переплетаться на одних и тех же строках и ключах.

Чем это вредит. Месарош относит это к разновидности непостоянного теста (Erratic Test): по сути это проблема взаимодействующих тестов (Interacting Tests), где взаимодействие происходит между конкурентными прогонами тестов, а не между тестами в рамках одного прогона.

  • Надёжность: падения недетерминированы и зависят от того, кто ещё запускается, поэтому их нельзя воспроизвести по требованию.
  • Ложная уверенность и утраченное доверие: команда привыкает «просто перезапустить» и начинает отмахиваться от красных сборок — включая настоящие регрессии, прячущиеся в шуме.
  • Сопровождаемость / отлаживаемость: причина невидима в упавшем тесте; она живёт в другом прогоне, а переплетение делает поиск первопричины мучительным.
  • Масштабируемость: это блокирует ровно то, чего команды хотят больше всего, — распараллеливание набора тестов и рост числа людей, способных запускать его одновременно.

##Treatment

Устраните общее, оспариваемое состояние. Конкретные шаги, примерно в порядке предпочтения:

  1. Предпочитайте свежую фикстуру (Fresh Fixture). Каждый тест создаёт ровно нужные ему данные и убирает их за собой — или выполняется внутри транзакции, откатываемой при завершении. Не зависьте от заранее существующих общих строк.
  2. Изолируйте на каждый раннер (песочница БД / партиционирование). Дайте каждому разработчику и каждой CI-задаче/воркеру собственную базу, схему или пространство имён (схема-на-воркер, эфемерная контейнерная БД или branch-база). Тогда конкурентные прогоны просто не видят друг друга.
  3. Используйте различимые сгенерированные значения (Distinct Generated Values) для ключей. Замените жёстко зашитые id/email уникальными сгенерированными значениями (UUID, последовательность или timestamp + workerId), чтобы конкурентные прогоны создавали различные объекты, а не сталкивались.
  4. Не проверяйте глобальные счётчики. Ограничивайте проверки данными, которые создал именно этот тест (фильтруйте по его уникальному ключу/тенанту), вместо count == 1.
  5. Делайте внешние ресурсы на каждый тест/воркер. Уникальные временные каталоги, уникальные имена очередей/топиков, БД в памяти или testcontainer на каждый процесс.
// ДО: жёстко зашитый ключ + глобальная проверка против общей БД
await db.users.insert({ id: 42, email: 'alice@example.com' });
expect(await db.users.countActive()).toBe(1);
// ПОСЛЕ: уникальный ключ + узкая проверка (Fresh Fixture + Distinct Generated Values)
const id = crypto.randomUUID();
const email = `alice+${id}@example.com`;
await db.users.insert({ id, email });
expect(await db.users.findById(id)).toMatchObject({ email });
// ...и направьте каждый воркер на собственную схему/песочную базу

Примечание: линтера для этого нет — оно проявляется только во время выполнения при конкурентности. Сделайте его воспроизводимым, намеренно запуская набор тестов дважды параллельно против одного окружения (или увеличив число воркеров раннера) в CI; если после этого набор краснеет — у вас есть война тестовых прогонов, которую нужно чинить.