Меня зовут Анатолий Бобунов, я работаю 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

Из-за этой особенности я столкнулся с двумя крупными проблемами, которые мешали использовать мультипроцессорный режим эффективно:

  1. Неравномерное распределение тестов по файлам. Одни файлы содержали десятки коротких тестов, другие - пару тяжелых сценариев с длительными подготовками. Это приводило к тому, что часть worker’ов простаивала, пока один обрабатывал тяжелый файл.

  2. Неуникальные тестовые данные или 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 раз короче. При этом прогоны стали предсказуемее, а локализация проблем быстрее.

Как ускорить автотесты на практике:

  1. Используйте возможность параллельного запуска с pytest-xdist и подходящей вам стратегией.

  2. Следите, чтобы нагрузка на xdist worker’ы и время выполнения были сбалансированными.

  3. Замените sleep() на точечные ожидания (wait_*) с повторными проверками через декораторы.

  4. Следите за уникальностью тестовых данных, чтобы избежать состояния ожидания.

В результате изменений команда начала получать обратную связь по merge request гораздо быстрее. Это сократило количество переключений контекста и сделало коммиты компактнее и логичнее. Падения тестов стали реже и понятнее — уменьшилось число флейков, ускорилась отладка, а доверие к тестам заметно выросло. Пайплайны CI стали стабильнее и короче по времени. Все это увеличило скорость релизов и сделало процесс ревью более спокойным и предсказуемым.

Если вам интересно, с чего началось развитие нашего CI в GitLab и первые шаги построения фреймворка, об этом я подробно рассказал в предыдущей статье.

Комментарии (2)


  1. Andrey_Solomatin
    02.12.2025 14:14

    Хорошая история успеха. Поставленна полезная цель. И метрики не забыли и коллаборации с другими командами есть. Кейс конфетка для резюме.


  1. Andrey_Solomatin
    02.12.2025 14:14

    В вашем случае, какие из применёных практик стоило бы подключить с самого начала проекта?