Почему, несмотря на накопленный опыт и современный инструментарий, число флаки‑тестов растёт год от года? Исследование BitRise 2025 года показало, что доля команд, которым приходится сталкиваться с флаки-тестами, выросла с 10% в 2022-м до 26% в 2025-м.
Нестабильные тесты сильно бьют по рабочим процессам: из опроса 1600 человек в 2023 году стало ясно, что флаки-тесты съедают 8% рабочего времени, почти столько же, сколько занимает наладка и поддержка тестовых сред. Но реальный вред флаков гораздо больше: они подрывают доверие к здоровым тестам и ставят под вопрос всю автоматизацию.
Вряд ли количество флаков растёт из-за того, что люди разучились писать атомарные тесты и правильно проектировать архитектуру приложений. Скорее проблема в том, что усложняется среда разработки и тестирования:
-
Больше этапов в пайплайнах
-
Более сложные рабочие процессы
-
Больше сторонних зависимостей
Сложность среды, сторонние зависимости — всё это бьёт в первую очередь по E2E-тестам. Проблема в том, что компенсировать эти источники сложности может быть очень дорого. И именно об этом я хочу поговорить сегодня: насколько дорого обеспечить стабильность E2E-тестов?
1. Частые причины нестабильности
Есть много работ с классификацией источников нестабильности во флаки-тестах; опираясь на них, я пройду по наиболее частым причинам.
Ожидания
Причина львиной доли флаков — проблемы с ожиданиями (подробнее про это тут и тут). Автору теста нужно угадать верхнюю границу, дольше которой тест не будет ждать отклика от браузера. Если дали слишком мало времени — тест упадёт. Если дали слишком много — тест будет медленный. Эта проблема — одна из главных причин, по которым переходят с Selenium на, скажем, Playwright с автоматическими ожиданиями. Сам по себе этот переход может сильно урезать количество флаков.
Использование общих ресурсов
Другая важная причина — использование общих ресурсов. Вот простой пример (по мотивам статьи):
def append_data(data, path, encoding="utf-8"): """ Добавляем данные в конец файла """ # готовим файл with open(path, 'a', encoding=encoding) as data_file: # записываем данные data_file.write(data)def test_data_written(): data = "Очень важное сообщение" file = Path("тестовый_файл.txt") # выполняем тестируемую функцию append_data(data, "тестовый_файл.txt") # проверяем запись assert file.read_text(encoding="utf-8") == data
(все примеры доступны здесь)
Тест test_data_written использует ресурс — файл тестовый_файл.txt. Предположим, к этому файлу есть доступ у других тестов. Если на момент выполнения теста в файле уже есть содержимое, тест будет красным: мы прочитаем не только то, что записали, но и то, что было раньше. Если же файл чистый, тест пройдёт, хотя и функция, и тест никак не изменились: это флак.
Зависимость от порядка выполнения
Эта причина, на самом деле, связана с предшествующей. Если у тестов общие ресурсы и один из них не «убрал за собой», все последующие упадут.
Добавим к нашему примеру ещё один тест, скажем, на запись с другой кодировкой:
def test_utf16_data_written(): data = "Друге важное сообщение" file = Path("тестовый_файл.txt") encoding = "utf-16" # выполняем тестируемую функцию append_data(data, "тестовый_файл.txt", encoding) # проверяем запись assert file.read_text(encoding) == data
Если его выполнить в одиночку, он пройдёт успешно, а если выполнить после test_data_written — упадёт, когда попытается прочитать записанный прошлым тестом текст в другой кодировке. Порядок выполнения влияет на результат: это флак.
Параллельное выполнение
Плохо настроенное параллельное выполнение тестов — тоже одна из наиболее частых причин сбоев. Если в нашем примере мы запустим тесты параллельно (например, с помощью pytest-xdist), они тоже упадут, из-за того, что запишут одновременно в один файл тексты с разными кодировками.
Зависимость от внешних систем
Предположим, мы написали функцию, запрашивающую у стороннего сервиса наш IP, и протестировали её:
import ipaddressimport requestsdef get_my_ip(): url = "https://api64.ipify.org?format=json" # отправляем запрос к сервису response = requests.get(url).json() # возвращаем только ip return response["ip"] def test_get_my_ip_returns_something():# вызываем проверяемую функциюip = get_my_ip()# вместо ассёрта вызываем функцию, которая упадёт при неправильном ip ipaddress.ip_address(ip)
Запустили тест, он прошёл зелёным. А на следующий день акула погрызла кабель на дне океана.
Мы не контролируем внешнюю систему, и любые изменения в ней могут повлиять на результат тестов — без каких-либо изменений в самих тестах или в тестируемом коде. Опять флак.
2. Избавляемся от общего состояния
В нашем примере в большинстве указанных случаев причиной было общее состояние у тестов. Как от него избавиться? Этому нас давно научили классики: нужно писать изолированные тесты.
Вернёмся в наш пример. Вместо того чтобы вслепую хватать общий файл и записывать в конец наши данные, сделаем так, чтобы каждый тест работал с отдельным, уникальным файлом, который будет создаваться только для этого теста и удаляться после запуска:
import uuidfrom pathlib import Pathimport pytest from demo_isolation_cost.test_function import append_data@pytest.fixture@profiledef temp_file():"""Временный файл, удаляемый после работы теста.""" # создаём уникальный путь # (это можно было сделать родной фикстурой Pytest # tmp_path, но мы будем измерять время выполнения # операций с файлами, поэтому важно, чтобы они были # в нашем коде, а не на стороне Pytest) path = Path(f"test_{uuid.uuid4().hex[:8]}.txt") # создаём файл path.write_text("") # передаём управление тесту yield path # убираемся за тестом path.unlink(missing_ok=True) @profiledef test_data_written(temp_file): data = "Очень важное сообщение" # выполняем тестируемую функцию append_data(data, temp_file) # проверяем, что данные записаны assert temp_file.read_text(encoding="utf-8") == data@profiledef test_utf16_data_written(temp_file): data = "Друге важное сообщение" encoding = "utf-16" # выполняем тестируемую функцию append_data(data, temp_file, encoding) # проверяем запись assert temp_file.read_text(encoding) == data
Теперь неважно, сколько раз и в каком порядке запускаются тесты, они не будут мешать друг другу.
Но изменилось и кое-что ещё. Теперь вместо двух обращений к файлу (запись, чтение) каждый тест выполняет четыре обращения (создание, запись, чтение, удаление). Попробуем измерить время, которое занимают эти операции:
Оказывается, наше создание и удаление файла заняло больше времени, чем сам тест.
Конечно, здесь речь идёт о долях миллисекунды, и это не повод экономить. Но это из-за того, что наш пример, во-первых, детский, а, во-вторых, относится к уровню юнит-тестов. Если же мы поднимемся выше по пирамиде тестирования, цена доступа к ресурсам быстро перестаёт быть детской.
3. Цена изоляции
Мартин Фаулер, описывая важность атомарных тестов, писал:
«…я считаю крайне важным сохранять тесты изолированными. Если тесты изолированы должным образом, их можно запускать в любом порядке. Но по мере продвижения к функциональным тестам с более широким операционным охватом поддерживать такую изоляцию становится всё сложнее.»
Все уже давно научились настраивать быстрые сюиты изолированных юнит-тестов, которые можно было бы запускать одним щелчком и использовать для проверки каждого пулл-риквеста. Сделать это на уровне E2E гораздо сложнее. Здесь оказывается, что изоляция тестов дорого стоит.
Ресурсы
Google использует при запуске тестов практику «герметичных сред»:
«Чтобы решить эти проблемы, в Google мы сделали ставку на эфемерные герметичные SUT (тестируемые системы) и интегрировали их в нашу CI/CD-инфраструктуру. Сначала мы создали универсальный фреймворк для определения, настройки и запуска SUT.
…
При этом подходе все зависимости теста — это компоненты SUT, одобренные командой, которая владеет соответствующей зависимостью. Так снижается нестабильность, характерная для традиционных общих тестовых зависимостей. Наша инфраструктура запускает эти компоненты в изолированных контейнерах, и, если у вас достаточно аппаратных ресурсов, все они могут быть запущены на одной машине. Это устраняет нестабильность и задержки, возникающие при вызовах через физическую сеть.
…
Так как SUT можно запускать отдельно для каждого теста, мы устраняем проблемы, возникающие из-за того, что несколько тестов выполняются параллельно и записывают одни и те же данные, или из-за того, что предыдущий тест оставил хранилище данных в несогласованном состоянии. Вы всегда можете быть уверены, что каждый тест начинается с предсказуемого, чистого состояния.»
Звучит как настоящее волшебство: не нужно ничего вручную убирать, всё окружение теста обнуляется автоматически. Во что обходится это волшебство? Запуск некоторых эвфемерных систем может занимать 10 минут или полчаса, и инженеры Google считают это решение хоть и крайне полезным, но дорогим.
Повторим для себя: Google — гигант в духе киберпанка, рыночная капитализация которого сравнима с ВВП богатой страны — считает это решение дорогим, использует выборочно, и признаёт, что не победил флаки.
Да, этот гигант работает с системами огромной сложности. Но инициализировать базу данных для каждого теста дорого и для простых смертных. Cypress сделал изоляцию тестов поведением по умолчанию, и считает это лучшей практикой — что, безусловно, очень здорово. Но при этом опция testIsolation: false всё равно остаётся, и подразумевается, что она будет использоваться именно для тяжеловесных E2E тестов.
Конечно, это преодолимая проблема, и на то, чтобы сделать изолированный запуск дешёвым, тратятся огромные усилия. И это тоже своя цена: создание хорошей инфраструктуры занимает много времени.
Время на создание инфраструктуры
Перейдём от зарубежного киберпанка к отечественному: Сбер, борясь с нестабильностью тестов, несколько лет назад создал механизм, который генерирует всю иерархию бизнес-сущностей для каждого теста, и удаляет её после завершения теста. Реализация этих генераторов заняла больше года, потому что для обеспечения максимального быстродействия тестов решили генерировать сущности не через API приложения, а через прямые вызовы к базе данных.
Справедливости ради нужно сказать, что:
-
приложение было на тот момент тяжеловесным и монолитным;
-
создание генераторов дало много побочных преимуществ — в частности, взглянув на генераторы, которые использует тест, теперь сразу понятно, с какими данными этот тест работает.
Достоверность
Ещё одна цена изоляции — достоверность: фокусируя тесты на отдельных операциях, мы уходим от реальных условий использования. В т.ч. из-за этого классический подход «пирамиды тестов» сейчас подвергают критике. С этим отчасти связано то, что более независимый тест может быть сложнее понять, поскольку он оторван от контекста.
Конечно, это не значит что стоит вернуться к практике двадцатилетней давности, когда писались в основном E2E: надёжность и быстрота с лихвой перевешивают достоверность.
Выводы
Всё это не недостатки, а именно цена. Никто не перестанет из-за этого создавать механизмы изолированного запуска тестов. Но вопрос в том, что создание этих механизмов обходится дорого по ресурсам и по времени, а пока это время идёт, с флаками всё равно нужно что-то делать.
4. Отслеживать дешевле, чем искоренять
К сожалению, чаще всего оказывается так, что правильная инфраструктура для «отлова» флаки стоит гораздо дешевле, чем правильная инфраструктура для их искоренения. Это не выбор или-или — нужно и то, и другое; но пока создаётся вторая, первую стоит уже иметь.
Какие здесь есть инструменты? Вот примерный перечень (не взаимоисключающий):
Перезапуск
Чтобы понять, случайно падение или нет, тест можно перезапустить. Иногда это очень дёшево — для того же Pytest существует [специальный плагин], а в сам Pytest версию по настоянию сообщества добавили опцию --lf, позволяющую запускать только упавшие на прошлом прогоне тесты. Правда, эта опция обеспечивает только локальный перезапуск у себя на машине; реализовать перезапуски масштабно тоже может быть дорого — тому же Сберу для этого пришлось переписать все сообщения об ошибках. Но они всё равно сделали это раньше, чем взялись за генераторы сущностей.
Слежение
TMS обеспечивают сбор аналитики по тестам, и с помощью неё можно определить, какие именно тесты падают случайно. По нынешним временам на этом этапе могут помочь нейросети: существуют (обычно платные) инструменты, обнаруживающие флаки по результатам тестов, а некоторые даже помогают исправлять причины нестабильностей.
Сортировка
Главное, что позволяет сократить вред от флаки — автоматическая сортировка результатов. Самый очевидный здесь вариант — настраиваемые категории в Allure Report, они автоматически сортируют упавшие тесты в т.ч. на основе сообщений об ошибках; если причина нестабильности известна, на неё можно не тратить время — система сама связывает с ней результаты тестов.
Карантин
Наконец, если нестабильный тест нельзя исправить сразу, он уходит в карантин, где ждёт своего часа — в идеале с выделением времени на работу с ним, как с любым техническим долгом.
5. Заключение
Почему, несмотря на накопленный опыт и современный инструментарий, число флаки‑тестов растёт год от года? Причина в том, что создать инфраструктуру, которая бы сделала флаки невозможными, очень, очень дорого — и чем сложнее продукты, тем дороже. Конечно, мы не перестанем из-за этого совершенствовать архитектуру и способы чистого запуска тестов. Но пока мы этим занимаемся, должна уже существовать инфраструктура для слежения, отлова и сортировки флаков.
ссылка на оригинал статьи https://habr.com/ru/articles/1047520/