Почему E2E-тесты флакают всё чаще и как с этим жить

от автора

Почему, несмотря на накопленный опыт и современный инструментарий, число флаки‑тестов растёт год от года? Исследование 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 приложения, а через прямые вызовы к базе данных.

Справедливости ради нужно сказать, что:

  1. приложение было на тот момент тяжеловесным и монолитным;

  2. создание генераторов дало много побочных преимуществ — в частности, взглянув на генераторы, которые использует тест, теперь сразу понятно, с какими данными этот тест работает.

Достоверность

Ещё одна цена изоляции — достоверность: фокусируя тесты на отдельных операциях, мы уходим от реальных условий использования. В т.ч. из-за этого классический подход «пирамиды тестов» сейчас подвергают критике. С этим отчасти связано то, что более независимый тест может быть сложнее понять, поскольку он оторван от контекста.

Конечно, это не значит что стоит вернуться к практике двадцатилетней давности, когда писались в основном E2E: надёжность и быстрота с лихвой перевешивают достоверность.

Выводы

Всё это не недостатки, а именно цена. Никто не перестанет из-за этого создавать механизмы изолированного запуска тестов. Но вопрос в том, что создание этих механизмов обходится дорого по ресурсам и по времени, а пока это время идёт, с флаками всё равно нужно что-то делать.

4. Отслеживать дешевле, чем искоренять

К сожалению, чаще всего оказывается так, что правильная инфраструктура для «отлова» флаки стоит гораздо дешевле, чем правильная инфраструктура для их искоренения. Это не выбор или-или — нужно и то, и другое; но пока создаётся вторая, первую стоит уже иметь.

Какие здесь есть инструменты? Вот примерный перечень (не взаимоисключающий):

Перезапуск

Чтобы понять, случайно падение или нет, тест можно перезапустить. Иногда это очень дёшево — для того же Pytest существует [специальный плагин], а в сам Pytest версию по настоянию сообщества добавили опцию --lf, позволяющую запускать только упавшие на прошлом прогоне тесты. Правда, эта опция обеспечивает только локальный перезапуск у себя на машине; реализовать перезапуски масштабно тоже может быть дорого — тому же Сберу для этого пришлось переписать все сообщения об ошибках. Но они всё равно сделали это раньше, чем взялись за генераторы сущностей.

Слежение

TMS обеспечивают сбор аналитики по тестам, и с помощью неё можно определить, какие именно тесты падают случайно. По нынешним временам на этом этапе могут помочь нейросети: существуют (обычно платные) инструменты, обнаруживающие флаки по результатам тестов, а некоторые даже помогают исправлять причины нестабильностей.

Сортировка

Главное, что позволяет сократить вред от флаки — автоматическая сортировка результатов. Самый очевидный здесь вариант — настраиваемые категории в Allure Report, они автоматически сортируют упавшие тесты в т.ч. на основе сообщений об ошибках; если причина нестабильности известна, на неё можно не тратить время — система сама связывает с ней результаты тестов.

Карантин

Наконец, если нестабильный тест нельзя исправить сразу, он уходит в карантин, где ждёт своего часа — в идеале с выделением времени на работу с ним, как с любым техническим долгом.

5. Заключение

Почему, несмотря на накопленный опыт и современный инструментарий, число флаки‑тестов растёт год от года? Причина в том, что создать инфраструктуру, которая бы сделала флаки невозможными, очень, очень дорого — и чем сложнее продукты, тем дороже. Конечно, мы не перестанем из-за этого совершенствовать архитектуру и способы чистого запуска тестов. Но пока мы этим занимаемся, должна уже существовать инфраструктура для слежения, отлова и сортировки флаков.

ссылка на оригинал статьи https://habr.com/ru/articles/1047520/