ConstructiCat Logo
CodeBust.
Browse section ▾

Неустойчивый (нестабильный, flaky) тест.

Неустойчивый (flaky) тест на неизменном коде то проходит, то падает, потому что его исход зависит от тайминга, порядка выполнения, общего состояния или иных недетерминированных факторов, а не от проверяемого поведения.

##Signs and Symptoms

Тест, который на одном запуске зелёный, а на следующем красный без каких-либо изменений кода, является неустойчивым. Распознать его можно по поведению людей вокруг него не меньше, чем по коду: разработчики перезапускают CI, «чтобы прошло», добавляют @Flaky/retry(3) или помещают тест в карантин вместо того, чтобы починить.

Типичные приметы в коде и в характере падений:

  • Тайминг/асинхронность: «ожидания» через sleep/setTimeout, недождавшиеся промисы или проверки, состязающиеся с тестируемой системой. Падения коррелируют со скоростью машины или нагрузкой на CI.
  • Зависимость от порядка: тест проходит в изоляции, но падает в полном наборе, или наоборот. Перемешивание порядка тестов меняет результат.
  • Разделяемое изменяемое состояние: статические коллекции, переиспользуемая строка в БД, синглтон или фикстура, изменённая более ранним тестом.
  • Недетерминированные входные данные: Date.now()/new Date(), Math.random(), локаль/часовой пояс, порядок обхода хеш-таблицы или автогенерируемые идентификаторы.
  • Внешние ресурсы: реальная сеть, файловая система или часы. Падения выглядят как таймауты или «connection refused», а не как несовпадения в проверках.
  • Условные проверки: expect спрятан внутри if/catch/коллбэков, поэтому тест молча проходит, когда ветка не выполняется.
// Запах: захардкоженное «ожидание», общий массив и реальные часы
const created = [];                       // общий для тестов → interacting tests

test('shows a fresh receipt', async () => {
  render(<Checkout />);
  fireEvent.click(screen.getByText('Pay'));
  await new Promise(r => setTimeout(r, 300));   // надеемся, что запрос завершился
  created.push('order-1');                       // протекает в последующие тесты
  expect(screen.getByRole('status'))
    .toHaveTextContent(`Paid ${new Date().toISOString()}`); // меняется на каждом запуске
});

Это напрямую соответствует подзапахам Erratic Test по Мезаросу: Interacting Tests / Test Run War (общее состояние), Lonely Test (запускается только после другого), Resource Optimism (предполагает, что внешний ресурс на месте), Resource Leakage (не убирает за собой) и Nondeterministic / Unrepeatable Test (время, случайность, асинхронность).

##Reasons for the Problem

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

  • Неявный тайминг. Асинхронный код «синхронизируют» фиксированными sleep-ами либо вообще не дожидаются его. Задержка — это догадка: сегодня её хватает, а завтра под нагрузкой она окажется слишком короткой.
  • Скрытая связанность. Тесты разделяют базу данных, синглтон, переменные уровня модуля или файлы на диске. Побочные эффекты одного теста становятся предусловиями другого (по Мезаросу — Interacting Tests / Test Run War), поэтому результат зависит от порядка и от того, что выполнялось параллельно.
  • Просачиваются недетерминированные входные данные. Реальное системное время, Math.random(), локаль/часовой пояс и неупорядоченные коллекции меняются от запуска к запуску и от машины к машине.
  • Оптимистичная зависимость от окружения. Тесты обращаются к живой сети/сервису или предполагают, что файл существует (Resource Optimism), и никогда не освобождают захваченное (Resource Leakage).
  • Проверки, которые можно пропустить. Если expect помещён в условие или в .catch, проверка может вообще не выполниться, и тест «проходит» по случайности.

Чем это вредит

  • Ложная уверенность и замаскированные дефекты. Нестабильный тест может упасть по причинам, не связанным с продакшеном, и настоящая регрессия может спрятаться за падением, которое все списывают «просто на флаки». Сигнал больше не отличить от шума.
  • Подрыв доверия. Как только за набором тестов закрепляется репутация нестабильного, разработчики игнорируют красные сборки и рефлекторно перезапускают их, что приучает команду полностью пренебрегать набором тестов.
  • Потерянное время и сломанные пайплайны. Повторы, перезапуски и расследования «это я или тест?» тормозят всех и блокируют CI/CD на пустом месте.
  • Плохая сопровождаемость. Неустойчивые тесты трудно отлаживать, потому что падение невоспроизводимо; их склонны отключать, а не чинить, тихо снижая реальное покрытие.

##Treatment

Относитесь к нестабильности как к дефекту в тесте, а не как к причуде, которую обходят перезапусками. Перезапуски нужны, чтобы обнаруживать нестабильность, но никогда — чтобы её прятать. Если тест блокирует пайплайн, поместите его в карантин, а затем найдите первопричину.

1. Замените sleep-ы ожиданием по условию. Опрашивайте именно то состояние, которое вам важно, вместо того чтобы угадывать длительность.

// до — состязательная фиксированная задержка
fireEvent.click(screen.getByText('Pay'));
await new Promise(r => setTimeout(r, 300));
expect(screen.getByRole('status')).toBeInTheDocument();

// после — дождаться условия, затем проверить
fireEvent.click(screen.getByText('Pay'));
expect(await screen.findByRole('status')).toBeInTheDocument();

2. Сделайте входные данные детерминированными. Внедряйте часы или используйте фейковые таймеры; задавайте seed или заглушку для случайности; фиксируйте локаль/часовой пояс.

// до: зависит от реальной даты
expect(label).toBe(`Paid ${new Date().toISOString()}`);

// после: заморозить время (vitest/jest)
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T00:00:00Z'));

3. Изолируйте каждый тест. Давайте каждому тесту свежие фикстуры, сбрасывайте общее состояние (БД, синглтоны, глобальные переменные модуля) в beforeEach/afterEach и избегайте изменяемых переменных уровня модуля. Запускайте набор в случайном порядке (например, --seed/рандомизация в Jest, MethodOrderer.Random в JUnit), чтобы рано выявлять зависимости от порядка.

4. Заглушайте внешние ресурсы. Мокайте сеть, файловую систему и системные вызовы, чтобы тест никогда не зависел от доступности сервиса (лечит Resource Optimism); всегда освобождайте/убирайте захваченные ресурсы (лечит Resource Leakage).

5. Дожидайтесь всей асинхронной работы и проверяйте безусловно. Возвращайте/дожидайтесь промисов и асинхронных тестовых утилит; выносите побочные эффекты из коллбэков waitFor, чтобы они выполнялись один раз; никогда не закапывайте expect внутрь if/catch.

6. Проверьте исправление. Прогоните теперь уже детерминированный тест много раз (а также под нагрузкой / в перемешанном порядке), чтобы подтвердить стабильность, прежде чем выводить его из карантина.

##Detected by