Флаки-тест - это тест, который на неизменном коде ведёт себя непредсказуемо: сегодня зелёный, завтра красный, без единой правки между запусками. Соблазн велик - объявить это фоновым шумом, погодой, с которой ничего не сделать, и просто перезапустить сборку. Но флаки - это не погода, а дефект. Либо в тесте спрятан источник недетерминизма, либо сам код зависит от условий, которые вы не контролируете. И то и другое - сигнал, а не помеха.
Естественная первая реакция - обернуть тест в retry: пусть проваленный прогон повторится два-три раза, и если хоть один зелёный, считаем тест пройденным. Логика понятна: команда не хочет, чтобы сборка падала из-за одной капризной проверки, ведь остальные девятьсот тестов в порядке. Retry выглядит как дешёвая страховка, которая возвращает пайплайну зелёный цвет и покой.
Где это ломается: retry не лечит, а прячет. Тест, который проходит с третьей попытки, по-прежнему содержит баг - вы лишь научились не смотреть на него. Хуже того, если недетерминизм живёт в самом продукте (гонка за общим состоянием, зависимость от системных часов), retry маскирует реальную ошибку, которая рано или поздно проявится у пользователя. Флаки-тест, которому позволили остаться флаки, обесценивает всю сюиту: люди перестают верить красному и начинают перезапускать сборку не глядя.
Причины укладываются в короткий список. Время: тест сравнивает Date.now() или полагается на реальный таймаут. Порядок: один тест оставляет мутировавший модуль или глобал, а следующий на это опирается. Сеть: обращение к реальному эндпоинту, который то отвечает, то нет. Гонки: асинхронная работа, которую не дождались. Общее состояние (shared state): переиспользуемый кэш, синглтон, база - живут между тестами. Почти всякая флаки сводится к одной из этих пяти.
Диагностика начинается с воспроизведения, а не с догадок. Зафиксируйте seed и запустите сюиту в одном процессе: jest --runInBand выключает пул воркеров, и тогда порядок становится детерминированным - если падение исчезает, виновата гонка между воркерами или общее состояние. Перемешайте порядок файлов, чтобы вытащить скрытую зависимость. Если Jest не завершается, --detectOpenHandles покажет открытые хендлы: живой сервер, таймер, соединение с базой. Таблица ниже связывает симптом, причину и первый шаг.
| Симптом |
|---|
| Вероятная причина |
|---|
| Диагностика |
|---|
| Падает только сюита | Общее состояние | --runInBand, перемешать порядок |
|---|---|---|
| Jest не завершается | Открытый handle | --detectOpenHandles, проверить server/timer/db |
| Только CI | Гонка, ресурсы, TZ | явные ожидания, фиксированная timezone |
| Медленно | Тяжёлый setup/transform | --logHeapUsage, профилирование медленных тестов |
Починка - это удаление источника недетерминизма, а не заглушение симптома. Часы делаете детерминированными через jest.useFakeTimers() и jest.setSystemTime(), таймауты продвигаете вручную jest.advanceTimersByTime(). Порядок лечится изоляцией: beforeEach сбрасывает состояние, clearMocks/resetMocks очищают моки между тестами, а модульный кэш - jest.resetModules(). Сеть заменяется моком на архитектурной границе. Асинхронность - явным await и ожиданием через findBy, вместо угадывания задержек. Часовой пояс фиксируется переменной TZ, чтобы CI и ноутбук считали даты одинаково.
Единственное оправданное применение retry - временный карантин внешнего E2E, который дёргает чужой сервис по сети: там недетерминизм не ваш, и повтор честно отражает реальность. Для unit-теста retry недопустим - это лечение симптома. Классический провал: тест корзины падает только в CI. Локально всё зелено, потому что машина быстрее и в другом часовом поясе; в CI медленный воркер не успевает домонтировать Cart до assert, а дата заказа пересекает полночь по UTC. Retry сделал бы сборку зелёной и оставил бы обе бомбы ждать пользователя.