| Как ускорить автотесты на Python в Pytest в 8,5 раз |
| 14.05.2026 00:00 |
|
Автор: Анатолий Бобунов Меня зовут Анатолий Бобунов, я работаю SDET в компании EXANTE. Однажды я пришел на проект, на котором выполнение некоторых тест-сьютов занимало больше часа, настолько медленно, что запускать их на каждый merge request (MR) было просто нереально. Мы хотели запускать автотесты на каждый коммит в MR, но с такой скоростью это было невозможно. В результате мне удалось, за счёт серии небольших, но точных изменений добиться 8,5-кратного ускорения - без переписывания тестов с нуля. В статье расскажу, какие проблемы у нас возникли и как мы их решали. Как медленные автотесты тормозят команду Скорость фидбека автотестов напрямую влияет на продуктивность разработчиков и тестировщиков. Чем дольше мы ждем результат, тем сильнее снижаются мотивация и концентрация внимания. Медленные автотесты приводят к организационным и психологическим проблемам.
Постепенно техническая проблема влияет на разработку. Команда теряет темп, частота релизов снижается, обратная связь о регрессиях приходит слишком поздно. Мы столкнулись с этим на проекте и поняли, что команде нужно вернуть ощущение «живого» цикла разработки. С чего началась оптимизация тестовОбычно на старте проекта нужно как можно быстрее покрыть автотестами большую часть функциональности. Из-за жестких сроков приходится идти на компромиссы. Но позже временные решения без рефакторинга превращаются в системные проблемы. Именно в таком состоянии находился проект, когда я пришел в компанию. Время выполнения некоторых тест-сьютов превышало один час. Если отбросить крайние значения, среднее время прогона большинства сьютов составляло примерно 20-25 минут. У нас была цель - запускать автотесты на каждый коммит в merge request. Стало очевидно, что без ускорения тестов это будет невозможно. Мне поручили выбрать несколько сьютов и провести анализ - понять, какие узкие места влияют на скорость, и насколько реально сократить общее время прогона. Анализ показал несколько серьезных архитектурных проблем:
После рефакторинга первых двух сьютов и обсуждений с командой я сформулировал цель: сократить время выполнения каждого сьюта максимум до трех минут. Анализ производительности тестов: что оказалось самым медленнымЧтобы понять, что именно оптимизировать, сначала нужны были данные. Поскольку я планировал использовать 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’е. Это помогает сохранить консистентность при использовании общих фикстур или данных. Из-за этой особенности я столкнулся с двумя крупными проблемами, которые мешали использовать мультипроцессорный режим эффективно:
Чтобы решить эти проблемы, пришлось пересмотреть архитектуру тестов, структуру файлов и принципы генерации данных. Разбиваем монолитные тест-сьюты ради параллельностиНаш проект с автотестами развивается уже много лет. Изначально тесты группировались по бизнес-логике, поэтому одни файлы постепенно разрастались до 1500+ строк, а другие оставались компактными, по 50-100 строк. Когда я занялся оптимизацией, меня в первую очередь интересовали именно крупные файлы с тестами. Их можно было разбить на мелкие, что позволило бы распределить нагрузку между процессами равномернее и сделать время прогона более предсказуемым. Параллельно с этим я провел анализ актуальности и корректности самих тестов. Потребовалось активное взаимодействие с командами тестировщиков, у которых была свежая информация о последних изменениях в сервисах и связанных бизнес-процессах. В процессе обнаружилось немало устаревших сценариев, а также тестов, которые проверяли все и сразу - без четкой цели или понятной изоляции. Такие тесты усложняли поддержку и мешали параллельному запуску, потому что создавали непредсказуемые зависимости. После нескольких итераций рефакторинга структура тестовых сьютов стала значительно чище. Где-то мы просто разбили большие файлы на несколько логических частей. Где-то пересмотрели логику и разделили тесты на новые отдельные сьюты. В результате тесты выполнялись быстрее, стабильнее и понятнее. А распределение нагрузки между worker’ами стало гораздо ровнее, чем раньше. От sleep() к умным ожиданиям: как мы сократили время прогонаВо время последующего рефакторинга автотестов я заметил жестко заданные интервалы ожидания изменений на стороне серверов. Чаще всего это выглядело как простая команда sleep(N) - классическая детская ошибка при написании автотестов. Зачастую именно такие ожидания увеличивали общее время выполнения тестов, иногда в несколько раз. В некоторых случаях простое удаление sleep сокращало это время с нескольких минут до нескольких секунд. Однако все оказалось не так просто. В ряде тестов удаление ожиданий приводило к падениям. Это происходило в точках взаимодействия со сторонними сервисами, где нужно было дожидаться завершения процессов, прежде чем переходить к следующему шагу. Мы пересмотрели подход к ожиданиям и решили полностью уйти от жестко заданных sleep. Мой коллега написал несколько универсальных декораторов под разные типы ситуаций. Каждый из них реализует логику повторных попыток с контролем времени и постепенным увеличением интервала между запросами. Тестировщики написали атомарные функции ожидания, которые теперь называют в едином стиле - wait_*. На эти функции они навешивают соответствующие декораторы, например @retry_if_code. Подход оказался удобным и гибким: теперь можно точно задать, какие ситуации считать допустимыми для повторной попытки, как долго и с каким шагом их выполнять, а также как быстро увеличивать паузы между запросами. Важно, что мы не ждем определенного кода, а используем его как условие для retry - повторяем только при допустимых ответах и сразу падаем в остальных случаях. Теперь такие ожидания выполняются столько, сколько действительно нужно - без лишних задержек и с точной логикой повторов. В результате общее время прогона тестов заметно сократилось, а стабильность осталась на прежнем уровне. Конфликт неуникальных тестовых данных: скрытый враг параллельного тестированияПри запуске тестов в нескольких под процессах я столкнулся с проблемой не уникальности тестовых данных. Пока тесты выполнялись последовательно в один поток, это почти не проявлялось. Но в многопроцессном режиме стало видно множество пересечений — разные тесты пытались работать с одними и теми же данными. Чаще всего причина была простой: при создании нового тестового файла тестировщик просто копировал константы и настройки из начала другого файла в том же сьюте. Что-то уникальное добавлялось только тогда, когда нужно было проверить специфичный кейс. После обсуждения с командой мы решили:
Такое разделение позволило сделать запуск каждого тест-файла независимым друг от друга и сохранить возможность зависимостей внутри самого тест-файла. Благодаря этому параллельные запуски стали стабильнее, а диагностика ошибок гораздо проще. Полностью изолировать отдельные тесты мы не стали: из-за тесной связности сервисов создание отдельных данных для них заняло бы слишком много времени. Вместо этого мы автоматизировали процесс подготовки данных и используем предустановленные тестовые данные на уровне тестового файла. Presetup: автоматизация предварительной настройки данныхИсторически, когда тестов было немного и их только начинали писать, использовали артефакты ручного тестирования. Поэтому долго не создавали отдельную обвязку для подготовки данных - так было быстрее. Во время обновления тестовых данных мой коллега предложил: хорошо бы иметь скрипт или набор скриптов, которые автоматически приводят тестовые данные к эталонному состоянию. Так появился инструмент, который мы позже назвали Presetup. Основная идея Presetup заключалась в том, чтобы проверять наличие нужных сущностей, создавать их при отсутствии и обновлять, если их состояние отличалось от ожидаемого. А логика pytest fixtures использовалась уже на уровне запуска этих Presetup-классов, чтобы удобно управлять их выполнением в тестах. Если автотесты запускаются с параметром PRESETUP=True, перед выполнением автотестов Presetup автоматически проверяет наличие и соответствие состоянию. Если данные уже существуют и находятся в корректном состоянии, повторное создание и настройка не выполняется. Этот набор скриптов выполняет несколько ключевых функций:
Такой подход избавил команду от рутинной подготовки данных, позволил чаще создавать уникальные тестовые данные, ускорил прогоны и уменьшил количество нестабильных тестов, связанных с несогласованностью окружений. Результаты оптимизации: сокращение времени на 88%В момент, когда мы взялись ускорять прогоны, среднее время одного сьюта было около 20-25 минут. Если тесты начинали падать, общая длительность прогона быстро росла из-за повторных попыток и «жестких» ожиданий - вплоть до тайм-аута CI на два часа. После серии изменений среднее время выполнения одного сьюта стабилизировалось на уровне около двух минут. Результат оптимизации - сокращение времени выполнения на 88%, с 17 до двух минут. Процесс стал в 8,5 раз короче. При этом прогоны стали предсказуемее, а локализация проблем быстрее. Как ускорить автотесты на практике:
В результате изменений команда начала получать обратную связь по merge request гораздо быстрее. Это сократило количество переключений контекста и сделало коммиты компактнее и логичнее. Падения тестов стали реже и понятнее — уменьшилось число флейков, ускорилась отладка, а доверие к тестам заметно выросло. Пайплайны CI стали стабильнее и короче по времени. Все это увеличило скорость релизов и сделало процесс ревью более спокойным и предсказуемым. Если вам интересно, с чего началось развитие нашего CI в GitLab и первые шаги построения фреймворка, об этом я подробно рассказал в предыдущей статье. |