automationciflaky

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-тест — это не «не повезло», это конкретная причина: гонка, общее состояние, время, сеть. Найди её один раз и почини — иначе будешь «лечить» перезапуском ровно до того дня, когда за очередным миганием окажется настоящий баг.