Flaky-тесты: почему автотесты мигают и как это лечить
Знакомая картина: тест упал, ты перезапустил — зелёный. Код не менялся. Через два дня он падает снова, на другом шаге. Это flaky-тест: недетерминированный результат при неизменном коде.
Проблема не в том, что один тест иногда врёт. Проблема в том, что flaky-тесты убивают доверие ко всему пакету. Когда красный билд «лечится» перезапуском, команда перестаёт читать отчёты и начинает жать «rerun» на автомате — и в этот момент настоящий баг проезжает в прод под видом ещё одного мигания. Google в своём Testing Blog писал, что около 16% их тестов имеют ту или иную степень флейки; это не «плохие тесты», это свойство больших систем, с которым нужно уметь работать.
Разберём, откуда берётся нестабильность и что с ней делать.
1. Тайминги и неявные ожидания
Причина №1 у UI-тестов. Тест кликает по кнопке раньше, чем она отрисовалась; проверяет текст до того, как приехал ответ от API. На быстрой машине проходит, на нагруженном CI-агенте — падает.
Главный антипаттерн — sleep(3). Он либо слишком короткий (флейки), либо слишком длинный (медленный пакет). Магической правильной цифры нет, потому что время отклика непостоянно.
- Жди условие, а не время. Explicit wait до состояния: «элемент кликабелен», «спиннер исчез», «в списке ≥1 строка». Playwright и Cypress делают это через auto-waiting из коробки — не обходи их ручными паузами.
- Убери
sleepиз тестов. Если очень нужно — это сигнал, что не хватает явного признака готовности (data-атрибут, событие, изменение DOM). - Жди правильный признак. Не «прошло 2 секунды», а «кнопка
Submitстала enabled» или «сеть затихла».
2. Общее состояние между тестами
Тесты делят одну базу, один аккаунт, один кэш, файл или очередь. Тест A создал запись, тест B её не ждал увидеть — и падает через раз в зависимости от того, кто отработал первым и что осталось от прошлого прогона.
- Изоляция. У каждого теста — свои данные: уникальный пользователь, свой tenant, префикс в ключах. Не полагайся на «пустую» базу.
- Чистое состояние. Setup создаёт то, что нужно; teardown убирает за собой (или оборачивай в транзакцию с rollback). «Оставлю данные, вдруг пригодятся» — источник флейки.
- Осторожно с параллелью. При параллельном запуске общий ресурс превращается в гонку. Либо изолируй ресурс на воркер, либо явно сериализуй тесты, которым он нужен.
3. Зависимость от порядка
Смежная беда: пакет зелёный только в определённом порядке. Тест 5 работает, потому что тест 3 залогинился раньше и оставил сессию. Поменяли порядок (или включили shuffle) — посыпалось.
- Каждый тест самодостаточен. Он сам приводит систему в нужное состояние и не полагается на побочные эффекты соседей.
- Гоняй в случайном порядке. pytest-randomly,
--order-randomlyи аналоги специально ломают скрытые зависимости — лучше поймать это у себя, чем на релизе. - Никаких «глобальных» логинов на весь модуль, если тесты могут запуститься по отдельности или параллельно.
4. Внешние зависимости и сеть
Тест ходит в реальный сторонний API, в почту, в платёжку. Внешний сервис тормознул, отдал 503, сработал rate limit — и твой «тест логики» покраснел не по своей вине.
- Мокай то, что не тестируешь. Проверяешь свою бизнес-логику — подставь стаб внешнего API (WireMock, MSW, встроенные моки фреймворка). Реальную интеграцию проверяй отдельным, малым набором contract/E2E-тестов, и относись к их флейки иначе.
- Retry — на уровне инфраструктуры, а не внутри ассертов. Нестабильную сеть лечат политикой ретраев транспорта, а не
try/exceptвокруг проверки. - Не завись от «живых» данных. Тест, который проверяет «на главной 10 товаров», сломается, когда контент поменяют. Фиксируй тестовые данные.
5. Анимации и асинхронный рендер
Элемент есть в DOM, но ещё едет по анимации; SPA дорисовывает список порциями; фокус/скролл не успел. Клик попадает «мимо» или в старую позицию.
- Отключай анимации в тестовой среде.
prefers-reduced-motion, флаг сборки, глобальный CSS* { transition: none }— стандартный приём для стабильности визуальных и UI-тестов. - Жди стабилизации, а не появления. Элемент виден ≠ элемент готов. Дождись, что позиция/размер перестали меняться, спиннер ушёл, запрос завершился.
- Визуальные тесты — с порогом и маскировкой. Динамические зоны (время, аватары, анимации) маскируй; сравнивай с допуском, а не пиксель-в-пиксель.
6. Недетерминированные данные, время и конкурентность
Тест зависит от now(), случайного значения, текущей локали или таймзоны агента. В полночь по UTC или на машине с другим часовым поясом — падает. Плюс настоящие race conditions в асинхронном коде.
- Замораживай время. libfaketime, freezegun, поддельные часы фреймворка. Не проверяй «дата = сегодня», если «сегодня» зависит от таймзоны раннера.
- Фиксируй seed у генераторов случайных данных, чтобы падение можно было воспроизвести.
- Уникальные данные вместо хардкода.
user_${uuid}вместо[email protected], который уже занят с прошлого прогона. - Гонки — это баг продукта, а не теста. Если тест стабильно ловит редкую гонку — не глуши его ретраем, чини причину.
Как ловить флейки и не тонуть
Нельзя починить то, что не измеряешь. Отдельно от самих фиксов нужна система обнаружения:
- Детектор перезапуском. Гоняй тест N раз подряд (или в CI по расписанию) — стабильно зелёный проходит, мигающий подсвечивается. Многие фреймворки и Allure умеют помечать flaky по истории.
- Карантин, а не удаление. Пойманный flaky выноси в отдельную группу, которая не блокирует релиз, но и не исчезает из виду. Удалить — значит потерять покрытие; оставить в основном пакете — значит приучить команду игнорировать красное.
- Не маскируй retry. Автоматический
retries: 2в раннере прячет проблему: билд зелёный, а тест по-прежнему мигает. Ретрай допустим как временный костыль с обязательным трекингом количества ретраев — иначе флейки копятся невидимо. - Дашборд нестабильности. Метрика «flaky rate» по пакету + топ самых мигающих тестов. Это очередь на починку, как баг-трекер.
Чек-лист
- В тестах нет
sleep(N)— только ожидания по условию. - Каждый тест создаёт своё состояние и убирает за собой; нет общих аккаунтов/данных.
- Пакет проходит в случайном порядке (
shuffle/randomly). - Внешние сервисы замоканы; «живые» интеграции — отдельный малый набор.
- Анимации в тестовой среде выключены; ждём стабилизацию, а не появление.
- Время заморожено, seed зафиксирован, тестовые данные уникальны.
- Есть детектор flaky (rerun/история) и карантин для пойманных.
- Retry в раннере — только с трекингом числа ретраев, не молча.
- Есть метрика flaky rate и очередь на починку.
Flaky-тест — это не «не повезло», это конкретная причина: гонка, общее состояние, время, сеть. Найди её один раз и почини — иначе будешь «лечить» перезапуском ровно до того дня, когда за очередным миганием окажется настоящий баг.