Меня зовут Анатолий Бобунов, я работаю SDET в компании EXANTE. Однажды я пришел на проект, на котором выполнение некоторых тест-сьютов занимало больше часа, настолько медленно, что запускать их на каждый merge request (MR) было просто нереально. Мы хотели запускать автотесты на каждый коммит в MR, но с такой скоростью это было невозможно. В результате мне удалось, за счёт серии небольших, но точных изменений добиться 8,5-кратного ускорения - без переписывания тестов с нуля. В статье расскажу, какие проблемы у нас возникли и как мы их решали.
Как медленные автотесты тормозят команду
Скорость фидбека автотестов напрямую влияет на продуктивность разработчиков и тестировщиков. Чем дольше мы ждем результат, тем сильнее снижаются мотивация и концентрация внимания. Медленные автотесты приводят к организационным и психологическим проблемам.
Переключение контекста. Разработчик пишет код, запускает пайплайн, не дожидается результата и переключается на другую задачу. Когда фидбек наконец приходит, неудобно возвращаться к старому контексту.
Рост объема merge request’ов. Если тесты крутятся долго, коллеги делают MR крупнее, чтобы не тратить время ожидания зря. Во-первых, ревью от этого становится сложнее и дольше. Во-вторых, большие MR труднее проверять, и это увеличивает вероятность пропуска неочевидных ошибок.
Снижение доверия к тестам. Когда CI постоянно крутится часами, тесты кажутся уже не инструментом контроля качества, а раздражающим тормозом.
Постепенно техническая проблема влияет на разработку. Команда теряет темп, частота релизов снижается, обратная связь о регрессиях приходит слишком поздно. Мы столкнулись с этим на проекте и поняли, что команде нужно вернуть ощущение «живого» цикла разработки.
С чего началась оптимизация тестов
Обычно на старте проекта нужно как можно быстрее покрыть автотестами большую часть функциональности. Из-за жестких сроков приходится идти на компромиссы. Но позже временные решения без рефакторинга превращаются в системные проблемы.
Именно в таком состоянии находился проект, когда я пришел в компанию. Время выполнения некоторых тест-сьютов превышало один час. Если отбросить крайние значения, среднее время прогона большинства сьютов составляло примерно 20-25 минут.
У нас была цель - запускать автотесты на каждый коммит в merge request. Стало очевидно, что без ускорения тестов это будет невозможно. Мне поручили выбрать несколько сьютов и провести анализ - понять, какие узкие места влияют на скорость, и насколько реально сократить общее время прогона.
Анализ показал несколько серьезных архитектурных проблем:
долгие предварительные настройки тестовых данных;
слишком крупные файлы с автотестами мешают параллельному запуску;
частое использование жестко заданных timeout (hardcoded sleep);
некорректная обработка ошибок приводит к лишним циклам ожидания и повторным провер��ам.
После рефакторинга первых двух сьютов и обсуждений с командой я сформулировал цель: сократить время выполнения каждого сьюта максимум до трех минут.
Анализ производительности тестов: что оказалось самым медленным
Чтобы понять, что именно оптимизировать, сначала нужны были данные.
Поскольку я планировал использовать pytest-xdist, мне хотелось знать статистику по каждому запуску: какой тест-файл и какой конкретно тест сколько времени выполнялся. Для этого я написал небольшой pytest-хук, который собирал данные о времени выполнения тестов и выводил их в консоль после завершения прогона. Также этот хук прекрасно работал с pytest-xdist и давал мне статистику выполнения для каждого xdist worker. Можно сказать, это pytest_terminal_summary на стероидах - привычный отчет, но с дополнительными метриками по каждому xdist worker’у и тест-файлу.
Кроме разовых замеров, я хотел видеть историческую динамику - как менялась скорость прогона сьютов от запуска к запуску. Поскольку в компании уже были настроены InfluxDB и Grafana, я решил использовать их для сбора метрик: настроил сохранение данных о каждом прогоне автотестов в базу InfluxDB.
Чуть позже, после обсуждения с командой, я добавил несколько дашбордов в Grafana, где можно было удобно отслеживать изменения во времени - среднее время прогона, распределение по сьютам и тренды оптимизации. Я поделился дашбордами с командой, чтобы каждый мог отслеживать, как наши изменения влияют на производительность тестов.
Параллельный запуск тестов с pytest-xdist: первый серьезный рывок
Первое, что приходит в голову, когда думаешь об ускорении авто тестов на Python - запустить их в несколько процессов с помощью pytest-xdist.
Плагин pytest-xdist расширяет возможности pytest и добавляет опцию распределенного запуска автотестов на нескольких процессах одновременно. Это значительно ускоряет их выполнение.
По умолчанию pytest-xdist использует стратегию распределения --dist=load, при которой тесты динамически балансируются между worker’ами. Однако после обсуждения с командой я решил, что для нашего проекта удобнее группировать тесты по файлам, а целые файлы передавать в отдельные xdist worker’ы. Такой подход оказался необходим, потому что мы не могли запускать тесты параллельно внутри одного файла из-за общих тестовых данных и зависимостей меж��у тестами.
Для этого в pytest-xdist есть специальная опция --dist=loadfile, которая гарантирует, что все тесты из одного файла будут выполняться на одном worker’е. Это помогает сохранить консистентность при использовании общих фикстур или данных.
pytest tests/your_suite_name -n auto --dist loadfile
Из-за этой особенности я столкнулся с двумя крупными проблемами, которые мешали использовать мультипроцессорный режим эффективно:
Неравномерное распределение тестов по файлам. Одни файлы содержали десятки коротких тестов, другие - пару тяжелых сценариев с длительными подготовками. Это приводило к тому, что часть worker’ов простаивала, пока один обрабатывал тяжелый файл.
Неуникальные тестовые данные или shared state, то есть общие состояния. Некоторые тесты работали с одинаковыми наборами данных. При параллельном запуске это вызывало конфликты: тесты могли обращаться к одним и тем же объектам, менять состояние или затирать результаты друг друга.
Чтобы решить эти проблемы, пришлось пересмотреть архитектуру тестов, структуру файлов и принципы генерации данных.
Разбиваем монолитные тест-сьюты ради параллельности
Наш проект с автотестами развивается уже много лет. Изначально тесты группировались по бизнес-логике, поэтому одни файлы постепенно разрастались до 1500+ строк, а другие оставались компактными, по 50-100 строк.
Когда я занялся оптимизацией, меня в первую очередь интересовали именно крупные файлы с тестами. Их можно было разбить на мелкие, что позволило бы распределить нагрузку между процессами равномернее и сделать время прогона более предсказуемым.
Параллельно с этим я провел анализ актуальности и корректности самих тестов. Потребовалось активное взаимодействие с командами тестировщиков, у которых была свежая информация о последних изменениях в сервисах и связанных бизнес-процессах. В процессе обнаружилось немало устаревших сценариев, а также тестов, которые проверяли все и сразу - без четкой цели или понятной изоляции. Такие тесты усложняли поддержку и мешали параллельному запуску, потому что создавали непредсказуемые зависимости.
После нескольких итераций рефакторинга структура тестовых сьютов стала значительно чище. Где-то мы просто разбили большие файлы на несколько логических частей. Где-то пересмотрели логику и разделили тесты на новые отдельные сьюты. В результате тесты выполнялись быстрее, стабильнее и понятнее. А распределение нагрузки между worker’ами стало гораздо ровнее, чем раньше.
От sleep() к умным ожиданиям: как мы сократили время прогона
Во время последующего рефакторинга автотестов я заметил жестко заданные интервалы ожидания изменений на стороне серверов. Чаще всего это выглядело как простая команда sleep(N) - классическая детская ошибка при написании автотестов. Зачастую именно такие ожидания увеличивали общее время выполнения тестов, иногда в несколько раз. В некоторых случаях простое удаление sleep сокращало это время с нескольких минут до нескольких секунд.
Однако все оказалось не так просто. В ряде тестов удаление ожиданий приводило к падениям. Это происходило в точках взаимодействия со сторонними сервисами, где нужно было дожидаться завершения процессов, прежде чем переходить к следующему шагу.
Мы пересмотрели подход к ожиданиям и решили полностью уйти от жестко заданных sleep. Мой коллега написал несколько универсальных декораторов под разные типы ситуаций. Каждый из них реализует логику повторных попыток с контролем времени и постепенным увеличением интервала между запросами. Тестировщики написали атомарные функции ожидания, которые теперь называют в едином стиле - wait_*. На эти функции они навешивают соответствующие декораторы, например @retry_if_code.
Подход оказался удобным и гибким: теперь можно точно задать, какие ситуации считать допустимыми для повторной попытки, как долго и с каким шагом их выполнять, а также как быстро увеличивать паузы между запросами. Важно, что мы не ждем определенного кода, а используем его как условие для retry - повторяем только при допустимых ответах и сразу падаем в остальных случаях.
def retry_if_code(
status_code: int,
text: str | None = None,
timeout: float = 5.0,
time_step: float = 0.5,
*,
backoff: float = 1.0,
max_time_step: float | None = None,
):
"""
Retry until an expected HTTP status code appears in an AssertionError message.
Args:
status_code: Expected HTTP code that should appear in the assertion message.
text: Optional substring that must also be present in the message.
timeout: Maximum total time to wait (in seconds).
time_step: Initial sleep interval between retries.
backoff: Multiplier to increase the delay after each failed attempt (1.0 = constant).
max_time_step: Optional cap for the sleep interval.
Example:
@retry_if_code(202, timeout=10, time_step=0.5, backoff=2.0, max_time_step=4.0)
def wait_for_accepted(...):
assert r.code == 202, f"{r.code}: {r.data}"
"""
def retry_for_code_decorator(f: Callable):
def func_with_retries(*args, **kwargs):
end = time() + timeout
last_msg = "..."
pause = time_step
while time() < end:
try:
return f(*args, **kwargs)
except AssertionError as err:
last_msg = err.args[0] if err.args else "Assertion failed ..."
# Fail fast if the message does not contain the expected code or text
if str(status_code) not in str(last_msg):
raise RetryException(f"Unexpected status code: {last_msg}")
if text and text not in str(last_msg):
raise RetryException(f"Unexpected error description: {last_msg}")
sleep(pause)
pause *= backoff
if max_time_step is not None:
pause = min(pause, max_time_step)
raise AssertionError(f"Timeout reached; expected {status_code}. Last message: {last_msg}")
return func_with_retries
return retry_for_code_decorator
@retry_if_code(404, timeout=database_replication_timeout)
@step("Wait cash conversion settings")
def wait_cash_conversion_settings(client: Core, account: str) -> CashConversionSettings:
r = client.get_cash_conversion_settings(account)
assert r.is200, f"{r.code}: {r.data}"
return CashConversionSettings.from_json(r.data)
Теперь такие ожидания выполняются столько, сколько действительно нужно - без лишних задержек и с точной логикой повторов. В результате общее время прогона тестов заметно сократилось, а стабильность осталась на прежнем уровне.
Конфликт неуникальных тестовых данных: скрытый враг параллельного тестирования
При запуске тестов в нескольких под процессах я столкнулся с проблемой не уникальности тестовых данных. Пока тесты выполнялись последовательно в один поток, это почти не проявлялось. Но в многопроцессном режиме стало видно множество пересечений — разные тесты пытались работать с одними и теми же данными.
Чаще всего причина была простой: при создании нового тестового файла тестировщик просто копировал константы и настройки из начала другого файла в том же сьюте. Что-то уникальное добавлялось только тогда, когда нужно было проверить специфичный кейс.
После обсуждения с командой мы решили:
общие сущности вынести в отдельные файлы, придерживаясь иерархии global → suite → module;
каждый тестовый файл должен использовать собственные уникальные тестовые данные.
Такое разделение позволило сделать запуск каждого тест-файла независимым друг от друга и сохранить возможность зависимостей внутри самого тест-файла. Благодаря этому параллельные запуски стали стабильнее, а диагностика ошибок гораздо проще. Полностью изолировать отдельные тесты мы не стали: из-за тесной связности сервисов создание отдельных данных для них заняло бы слишком много времени. Вместо этого мы автоматизировали процесс подготовки данных и используем предустановленные тестовые данные на уровне тестового файла.
Presetup: автоматизация предварительной настройки данных
Исторически, когда тестов было немного и их только начинали писать, использовали артефакты ручного тестирования. Поэтому долго не создавали отдельную обвязку для подготовки данных - так было быстрее. Во время обновления тестовых данных мой коллега предложил: хорошо бы иметь скрипт или набор скриптов, которые автоматически приводят тестовые данные к эталонному состоянию. Так появился инструмент, который мы позже назвали Presetup.
Основная идея Presetup заключалась в том, чтобы проверять наличие нужных сущностей, создавать их при отсутствии и обновлять, если их состояние отличалось от ожидаемого. А логика pytest fixtures использовалась уже на уровне запуска этих Presetup-классов, чтобы удобно управлять их выполнением в тестах.
Если автотесты запускаются с параметром PRESETUP=True, перед выполнением автотестов Presetup автоматически проверяет наличие и соответствие состоянию. Если данные уже существуют и находятся в корректном состоянии, повторное создание и настройка не выполняется.
@pytest.fixture(scope="session", autouse=True)
def _pre_setup_global_entities():
...
@pytest.fixture(scope="package", autouse=True)
def _pre_setup_core_entities():
...
@pytest.fixture(scope="module", autouse=True)
def _pre_setup_module():
...
Этот набор скриптов выполняет несколько ключевых функций:
Тестировщик задает набор функций (_pre_setup_*) и использует внутри них специально написанные сущности для создания тест-данных и проверки их существования.
Глобальные настройки выносятся на уровень выше, чтобы тестировщики не сталкивались с ними в повседневной работе.
Запуск тестов без предварительной настройки данных, если используется стабильное окружение, где все уже готово.
Каждую ночь Presetup запускается в автоматическом режиме и приводит тестовые данные к эталонному состоянию. Он отправляет уведомление о том, какие данные были «замусорены» или изменены вручную.
Такой подход избавил команду от рутинной подготовки данных, позволил чаще создавать уникальные тестовые данные, ускорил прогоны и уменьшил количество нестабильных тестов, связанных с несогласованностью окружений.
Результаты оптимизации: сокращение времени на 88%
В момент, когда мы взялись ускорять прогоны, среднее время одного сьюта было около 20-25 минут. Если тесты начинали падать, общая длительность прогона быстро росла из-за повторных попыток и «жестких» ожиданий - вплоть до тайм-аута CI на два часа.
После серии изменений среднее время выполнения одного сьюта стабилизировалось на уровне около двух минут. Результат оптимизации - сокращение времени выполнения на 88%, с 17 до двух минут. Процесс стал в 8,5 раз короче. При этом прогоны стали предсказуемее, а локализация проблем быстрее.
Как ускорить автотесты на практике:
Используйте возможность параллельного запуска с pytest-xdist и подходящей вам стратегией.
Следите, чтобы нагрузка на xdist worker’ы и время выполнения были сбалансированными.
Замените sleep() на точечные ожидания (wait_*) с повторными проверками через декораторы.
Следите за уникальностью тестовых данных, чтобы избежать состояния ожидания.
В результате изменений команда начала получать обратную связь по merge request гораздо быстрее. Это сократило количество переключений контекста и сделало коммиты компактнее и логичнее. Падения тестов стали реже и понятнее — уменьшилось число флейков, ускорилась отладка, а доверие к тестам заметно выросло. Пайплайны CI стали стабильнее и короче по времени. Все это увеличило скорость релизов и сделало процесс ревью более спокойным и предсказуемым.
Если вам интересно, с чего началось развитие нашего CI в GitLab и первые шаги построения фреймворка, об этом я подробно рассказал в предыдущей статье.
Комментарии (2)

Andrey_Solomatin
02.12.2025 14:14В вашем случае, какие из применёных практик стоило бы подключить с самого начала проекта?
Andrey_Solomatin
Хорошая история успеха. Поставленна полезная цель. И метрики не забыли и коллаборации с другими командами есть. Кейс конфетка для резюме.