Неустойчивый (нестабильный, flaky) тест.
Неустойчивый (flaky) тест на неизменном коде то проходит, то падает, потому что его исход зависит от тайминга, порядка выполнения, общего состояния или иных недетерминированных факторов, а не от проверяемого поведения.
##Признаки и симптомы
Тест, который на одном запуске зелёный, а на следующем красный без каких-либо изменений кода, является неустойчивым. Распознать его можно по поведению людей вокруг него не меньше, чем по коду: разработчики перезапускают 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 (не убирает за собой), Unrepeatable Test (состояние, оставшееся после одного прогона, меняет результат следующего, поэтому первый прогон ведёт себя иначе, чем последующие) и Nondeterministic Test (время, случайность и асинхронные гонки заставляют один и тот же прогон то проходить, то падать).
##Причины проблемы
Почему так происходит
- Неявный тайминг. Асинхронный код «синхронизируют» фиксированными
sleep-ами либо вообще не дожидаются его. Задержка — это догадка: сегодня её хватает, а завтра под нагрузкой она окажется слишком короткой. - Скрытая связанность. Тесты разделяют базу данных, синглтон, переменные уровня модуля или файлы на диске. Побочные эффекты одного теста становятся предусловиями другого (по Месарошу — Interacting Tests / Война тестовых прогонов (Test Run War)), поэтому результат зависит от порядка и от того, что выполнялось параллельно.
- Просачиваются недетерминированные входные данные. Реальное системное время,
Math.random(), локаль/часовой пояс и неупорядоченные коллекции меняются от запуска к запуску и от машины к машине. - Оптимистичная зависимость от окружения. Тесты обращаются к живой сети/сервису или предполагают, что файл существует (Ресурсный оптимизм, Resource Optimism), и никогда не освобождают захваченное (Resource Leakage).
- Проверки, которые можно пропустить. Если
expectпомещён в условие или в.catch, проверка может вообще не выполниться, и тест «проходит» по случайности.
Чем это вредит
- Ложная уверенность и замаскированные дефекты. Нестабильный тест может упасть по причинам, не связанным с продакшеном, и настоящая регрессия может спрятаться за падением, которое все списывают «просто на флаки». Сигнал больше не отличить от шума.
- Подрыв доверия. Как только за набором тестов закрепляется репутация нестабильного, разработчики игнорируют красные сборки и рефлекторно перезапускают их, что приучает команду полностью пренебрегать набором тестов.
- Потерянное время и сломанные пайплайны. Повторы, перезапуски и расследования «это я или тест?» тормозят всех и блокируют CI/CD на пустом месте.
- Плохая сопровождаемость. Неустойчивые тесты трудно отлаживать, потому что падение невоспроизводимо; их склонны отключать, а не чинить, тихо снижая реальное покрытие.
##Лечение
Относитесь к нестабильности как к дефекту в тесте, а не как к причуде, которую обходят перезапусками. Перезапуски нужны, чтобы обнаруживать нестабильность, но никогда — чтобы её прятать. Если тест блокирует пайплайн, поместите его в карантин, а затем найдите первопричину.
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 или заглушку для случайности; фиксируйте локаль/часовой пояс.
// до: зависит от реальной даты
const label = receiptLabel(); // внутри форматирует new Date()
expect(label).toBe(`Paid ${new Date().toISOString()}`); // гонка с часами
// после (Vitest): заморозить часы и проверить фиксированное значение
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T00:00:00Z'));
expect(receiptLabel()).toBe('Paid 2026-06-15T00:00:00.000Z');
vi.useRealTimers();
// Jest: то же самое через объект jest
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-15T00:00:00Z'));
expect(receiptLabel()).toBe('Paid 2026-06-15T00:00:00.000Z');
jest.useRealTimers();
3. Изолируйте каждый тест. Давайте каждому тесту свежие фикстуры, сбрасывайте общее состояние (БД, синглтоны, глобальные переменные модуля) в beforeEach/afterEach и избегайте изменяемых переменных уровня модуля. Запускайте набор в случайном порядке (например, --seed/рандомизация в Jest, MethodOrderer.Random в JUnit), чтобы рано выявлять зависимости от порядка.
4. Заглушайте внешние ресурсы. Мокируйте сеть, файловую систему и системные вызовы, чтобы тест никогда не зависел от доступности сервиса (лечит Resource Optimism); всегда освобождайте/убирайте захваченные ресурсы (лечит Resource Leakage).
5. Дожидайтесь всей асинхронной работы и проверяйте безусловно. Возвращайте/дожидайтесь промисов и асинхронных тестовых утилит; выносите побочные эффекты из коллбэков waitFor, чтобы они выполнялись один раз; никогда не закапывайте expect внутрь if/catch.
6. Проверьте исправление. Прогоните теперь уже детерминированный тест много раз (а также под нагрузкой / в перемешанном порядке), чтобы подтвердить стабильность, прежде чем выводить его из карантина.
##Обнаружение анализаторами
- sonar java:S5973 — Тесты должны быть стабильными
- sonar java:S2925 — «Thread.sleep» не должен использоваться в тестах
- eslint-jest jest/no-conditional-expect — Запрещает вызывать expect внутри условий
- eslint-vitest vitest/no-conditional-expect — Запрещает вызывать expect внутри условий
- eslint-testing-library testing-library/await-async-queries — Требует обрабатывать промисы асинхронных запросов (findBy*)
- eslint-testing-library testing-library/await-async-utils — Требует дожидаться промисов асинхронных утилит вроде waitFor
- eslint-testing-library testing-library/no-wait-for-side-effects — Запрещает побочные эффекты внутри колбэков waitFor