ConstructiCat Logo
CodeBust.
Browse section ▾

Зависимость от порядка тестов.

Тест проходит или падает в зависимости от того, какие другие тесты выполнялись до него, потому что тесты «протекают» и полагаются на общее изменяемое состояние вместо того, чтобы каждый сам создавал и убирал свою фикстуру.

##Signs and Symptoms

Результат теста зависит от порядка, в котором выполняется набор, а не только от тестируемого кода. Предполагается, что каждый тест самодостаточен, но здесь один тест незаметно полагается на побочные эффекты, оставленные другим.

Характерные признаки:

  • Тест проходит в полном наборе, но падает при запуске в одиночку (через .only, фильтр по имени или запуск только этого файла). Месарош называет это одиноким тестом (Lonely Test).
  • Тесты ломаются, когда вы перемешиваете, шардируете или распараллеливаете запуск, или после обновления раннера, меняющего порядок по умолчанию.
  • Удаление, пропуск или перестановка одного теста приводит к падению, казалось бы, не связанного с ним теста.
  • Одно падение запускает каскад последующих падений (взаимодействующие тесты (Interacting Tests) у Месароша; война тестовых прогонов (Test Run War) у ван Дёрсена, когда параллельные раннеры сталкиваются из-за общей фикстуры).
  • Тесты периодически нестабильны (flaky) без изменения кода — зависимость от порядка одна из самых частых первопричин нестабильных тестов.

Структурный признак — общее изменяемое состояние, которое читается между тестами: переменные уровня модуля/static/глобальные, beforeAll, который однократно заполняет состояние, или несброшенный внешний ресурс (строки БД, файлы, кэши, переменные окружения, поддельные таймеры, моки).

let users = []; // общее состояние уровня модуля

test('creates a user', () => {
  users.push({ id: 1, name: 'Ada' });
  expect(users).toHaveLength(1);
});

// Зелёный только потому, что тест выше выполнился первым и изменил `users`.
// Запустите этот тест в одиночку или перемешайте порядок — и он упадёт.
test('finds the created user', () => {
  expect(users.find(u => u.name === 'Ada')).toBeDefined();
});

##Reasons for the Problem

Почему это происходит

  • Общая фикстура (созданная один раз в beforeAll, переменная уровня модуля, static-поле класса или реальная база данных/файл) изменяется тестами и никогда не сбрасывается между ними, поэтому каждый тест наследует остатки от предыдущего.
  • Удобство и скорость: разработчики переиспользуют дорогую подготовку или «достраивают» данные предыдущего теста, чтобы не создавать их заново (иногда это формализуется как цепочки тестов (Chained Tests)).
  • Зависимость случайна и невидима — ничто в коде теста не объявляет «запусти меня после того теста», поэтому она сохраняется до тех пор, пока не изменится порядок.

Почему это вредно

  • Надёжность / ложная уверенность. Набор тестов зелёный по счастливой случайности порядка. Измените порядок, распараллельте или запустите подмножество — и тесты упадут (или, что хуже, реальная регрессия скрывается, потому что более ранний тест случайно оставил «правильное» состояние). Тесты, зависящие от порядка, — одна из главных причин нестабильных (flaky) тестов.
  • Сопровождаемость. Вы не можете запустить, отладить или перезапустить один упавший тест в изоляции — сначала должен выполниться тест-предпосылка. Добавление, удаление или перестановка тестов вызывает «жуткое действие на расстоянии».
  • Читаемость. Тест больше не документирует одно поведение с явными входными данными; чтобы понять его, нужно прочитать всё, что выполнялось до него (таинственный гость (Mystery Guest), скрытый в порядке выполнения).
  • Блокирует параллелизм и выборочный запуск. Шардирование тестов, параллельные раннеры и инструменты выбора/влияния тестов — все они предполагают независимость; связанность по порядку делает их небезопасными.

##Treatment

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

  1. Дайте каждому тесту свежую фикстуру (Fresh Fixture). Перенесите общую подготовку из beforeAll в beforeEach (или создавайте её внутри теста), чтобы состояние пересоздавалось для каждого теста, а не накапливалось.
  2. Устраните общее изменяемое состояние. Не читайте и не пишите переменные уровня модуля, static-поля или глобальные переменные между тестами. Создавайте объекты локально; передавайте данные явно.
  3. Сбрасывайте внешние ресурсы в teardown. Откатывайте БД (транзакция на тест) или используйте уникальные данные для каждого теста; удаляйте временные файлы; восстанавливайте переменные окружения, глобальные значения и поддельные таймеры; очищайте моки (jest.clearAllMocks() / vi.restoreAllMocks(), jest.resetModules()). Предпочитайте автоматический/гарантированный teardown ручной очистке.
  4. Докажите независимость, рандомизируя порядок. Это и есть настоящий детектор — это динамическое свойство, а не то, что может увидеть линтер:
    • Jest: --randomize / randomize: true.
    • Vitest: sequence.shuffle (конфиг или --sequence.shuffle).
    • pytest: pytest-randomly или pytest-random-order.
    • Maven Surefire: -Dsurefire.runOrder=random. Запускайте также подмножество/одиночный тест в CI, чтобы выявить одинокие тесты (Lonely Tests).
  5. Если общая подготовка действительно нужна (дорогая фикстура только для чтения), сделайте её неизменяемой и общей только для чтения, либо в крайнем случае используйте намеренно задокументированный набор цепочечных тестов (Chained Test) — но никогда не случайную зависимость. Обратите внимание, что правило no-hooks из eslint-plugin-jest/eslint-plugin-vitest может отговорить от использования хуков setup/teardown, которые склонны провоцировать общее состояние, но само по себе оно не обнаруживает зависимость от порядка.
// До — зависит от порядка: тесту 2 нужны остатки от теста 1
let users = [];
test('creates a user', () => {
  users.push({ id: 1, name: 'Ada' });
  expect(users).toHaveLength(1);
});
test('finds the created user', () => {
  expect(users.find(u => u.name === 'Ada')).toBeDefined();
});

// После — каждый тест владеет своей фикстурой
function makeRepo(seed = []) {
  return { users: [...seed] };
}

test('creates a user', () => {
  const repo = makeRepo();
  repo.users.push({ id: 1, name: 'Ada' });
  expect(repo.users).toHaveLength(1);
});

test('finds an existing user', () => {
  const repo = makeRepo([{ id: 1, name: 'Ada' }]); // задаёт своё собственное предусловие
  expect(repo.users.find(u => u.name === 'Ada')).toBeDefined();
});