Неустойчивый (нестабильный, 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
- sonar java:S5973 — Tests should be stable
- sonar java:S2925 — "Thread.sleep" should not be used in tests
- eslint-jest no-conditional-expect — no-conditional-expect
- eslint-vitest no-conditional-expect — no-conditional-expect
- eslint-testing-library await-async-queries — await-async-queries
- eslint-testing-library await-async-utils — await-async-utils
- eslint-testing-library no-wait-for-side-effects — no-wait-for-side-effects