Компания Trail of Bits уже несколько лет сотрудничает с репозиторием PyPI, помогая добавлять в проект новые возможности и улучшать стандартные параметры безопасности в экосистеме управления пакетами Python.

В наших предыдущих публикациях основное внимание уделялось таким возможностям, как цифровые аттестации (digital attestations) и система доверенной публикации пакетов (Trusted Publishing). А сегодня мы поднимем тему, столь же важную, как и предыдущие, касающуюся вопросов безопасности программного обеспечения. Это — производительность системы тестирования кода.

Наличие тщательно проработанного тестового набора чрезвычайно важно для обеспечения безопасности и надёжности сложной кодовой базы. Но, по мере того, как растёт покрытие кода тестами, увеличивается и время выполнения тестов, что замедляет процесс разработки и отбивает у программистов охоту к частому и полноценному (то есть — глубокому) тестированию кода. В этом материале мы подробно расскажем о том, как мы методично оптимизировали тестовый набор для Warehouse (это — бэкенд, на котором работает PyPI), уменьшив время выполнения тестов со 163 до 30 секунд. При этом количество тестов выросло с 3900 до более чем 4700.

Время выполнения тестов в системе Warehouse за 12-месячный период (с марта 2024 по апрель 2025). Красные точки — это количество тестов. Синяя линия — это время, потраченное на их выполнение.
Время выполнения тестов в системе Warehouse за 12-месячный период (с марта 2024 по апрель 2025). Красные точки — это количество тестов. Синяя линия — это время, потраченное на их выполнение.

Мы добились улучшения производительности на 81% за несколько шагов:

  • Параллелизация выполнения тестов с помощью pytest-xdist (относительное сокращение времени выполнения тестового набора на 67%).

  • Использование sys.monitoring из Python 3.12. Это позволило повысить эффективность системы формирования отчётов о покрытии кода тестами (относительное сокращение времени выполнения тестового набора на 53%).

  • Оптимизация обнаружения тестов с помощью стратегически продуманной настройки параметра testpaths.

  • Устранение ненужных команд импорта, создающих неоправданно большую нагрузку на систему во время запуска тестового набора.

Эти оптимизации можно непосредственно применить ко многим Python-проектам, в частности — к обладающим постоянно растущими тестовыми наборами, которые рано или поздно начинают тормозить процесс разработки. Если вы реализуете хотя бы некоторые из этих оптимизаций — вы сможете очень сильно и без дополнительных затрат улучшить производительность собственных тестов.

Все временные показатели, приведённые в этом материале, получены при выполнении тестового набора Warehouse на виртуальной машине n2-highcpu-32. Хотя приведённые здесь данные и не являются формальными результатами выполнения неких бенчмарков, эти измерения дают чёткое понимание того эффекта, к которому привели наши оптимизации.

Монстр: тестовый набор Warehouse

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

Такая вот важность делает всеобъемлющее тестирование кода обязательным условием существования PyPI. Warehouse, в свою очередь, демонстрирует образцовый подход к тестированию: 4734 теста (по состоянию на апрель 2025 года) дают 100% покрытие ветвей кода с применением модульных и интеграционных тестовых наборов. Эти тесты реализованы с использованием фреймворка pytest, они запускаются при выполнении каждого pull- и merge-запроса, являясь частью надёжного CI/CD-конвейера. Сам конвейер, кроме того, в качестве условия принятия изменений, требует 100% покрытия кода тестами. По результатам измерений, проведённых в нашей системе бенчмарков, текущее время выполнения тестов составляет примерно 30 секунд.

Этот уровень производительности являет собой серьёзнейшее улучшение в сравнении с уровнем марта 2024 года, когда тестовый набор отличался следующими характеристиками:

  • Он содержал примерно 3900 тестов (тестов было на 17,5% меньше, чем сейчас).

  • На его выполнение требовалась 161 секунда (это в 5,4 раза дольше).

  • Он значительно тормозил процесс разработки.

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

Параллелизация выполнения тестов — путь к значительному росту производительности

Главный источник роста производительности тестов — это базовый принцип ускорения вычислений: параллелизация. Обычно тесты прекрасно подходят для параллельного выполнения, так как хорошо спроектированные тестовые сценарии изолированы, у них нет побочных эффектов, их поведение не влияет на глобальные сущности. Модульные и интеграционные тесты Warehouse уже были хорошо изолированы друг от друга, благодаря чему параллелизация их выполнения стала естественной первой целью наших работ по оптимизации системы.

Мы реализовали параллельное выполнение тестов, воспользовавшись pytest-xdist — популярным плагином, который распределяет выполнение тестов по нескольким процессорным ядрам.

Настройка pytest-xdist — это простая и понятная задача: в конфигурацию достаточно было внести изменение, размер которого составляет всего в одну строку!

Вот как выглядит настройка выполнения pytest с использованием pytest-xdist.

# В файле pyproject.toml
[tool.pytest.ini_options]
addopts = [
 "--disable-socket",
 "--allow-hosts=localhost,::1,notdatadog,stripe",
 "--durations=20",
+  "--numprocesses=auto",
]

После этой простой настройки pytest автоматически использует все доступные ядра процессора. На 32-ядерной виртуальной машине, на которой запускались тесты, это сразу же привело к огромному улучшению. Но, кроме того, это высветило несколько сложных и интересных проблем, требующих внимательного и вдумчивого решения.

Проблема: фикстуры баз данных

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

Вот изменения, которые были внесены в фикстуру базы данных.

@pytest.fixture(scope="session")
- def database(request):
+ def database(request, worker_id):
 config = get_config(request)
 pg_host = config.get("host")
 pg_port = config.get("port") or os.environ.get("PGPORT", 5432)
 pg_user = config.get("user")
-   pg_db = f"tests"
+   pg_db = f"tests-{worker_id}"
 pg_version = config.get("version", 16.1)

 janitor = DatabaseJanitor(

Благодаря этому изменению каждый рабочий процесс теперь использует собственный экземпляр базы данных. Это предотвращает взаимное «загрязнение» тестов, выполняемых разными процессами.

Проблема: формирование отчётов о покрытии кода тестами

Параллелизация тестов поломала нашу систему формирования отчётов о покрытии кода тестами, так как каждый рабочий процесс собирал соответствующие данные независимо от других. К счастью, эта проблема была освещена в документации к пакету coverage. Мы справились с задачей, добавив в проект файл sitecustomize.py.

Вот как выглядит инструментация кода с помощью coverage при использовании нескольких рабочих процессов.

try:
    import coverage
    coverage.process_startup()
except ImportError:
    pass

Проблема: удобство чтения отчётов о выполнении тестов

При параллельном выполнении тестов получаются отчёты, содержащие перемешанные результаты, которые тяжело читать. Мы интегрировали в проект плагин pytest-sugar, что позволило получить более понятные, лучше организованные результаты тестов (PR #16245).

<script src=https://asciinema.org/a/vkZbLkq39TEqNnyuTrzL2scFW.js id=asciicast-vkZbLkq39TEqNnyuTrzL2scFW async></script>

Повышение удобства чтения отчётов о тестировании

Результаты

Эти изменения были добавлены в проект с помощью PR #16206. Они позволили достичь замечательных результатов.

До, с

После, с

Улучшение

Время выполнения тестов

191

63

Сокращение времени на 67%

Эта оптимизация дала наибольший прирост производительности, потребовав при этом сравнительно скромных изменений кода. Она демонстрирует важность решения архитектурных проблем проектов до начала тонкой настройки их отдельных компонентов.

Оптимизация формирования отчётов о покрытии кода тестами с использованием sys.monitoring из Python 3.12

Coverage 7.7.0+ Замечание: при измерении покрытия кода тестами для ветвлений кода (покрытие ветвлений, branch coverage) в Python до версии 3.14 настройка COVERAGE_CORE=sysmon автоматически отключается и выдаётся предупреждение.

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

В PEP 669 появился API sys.monitoring, который позволяет наблюдать за выполнением программ, затрачивая меньше ресурсов, чем затрачивалось раньше при использовании аналогичных инструментов. Библиотека coverage.py поддерживает этот новый API начиная с версии 7.4.0. Вот что об этом пишут в документации:

В Python версии 3.12 и выше вы можете попробовать экспериментальное ядро, основанное на новом модуле sys.monitoring. Для этого нужно определить переменную окружения COVERAGE_CORE=sysmon. Новое ядро должно работать быстрее старого, хотя вместе с ним пока нельзя использовать плагины и динамические контексты.

Изменения в Warehouse

Вот какие изменения были внесены в файл Makefile. Это позволило устанавливать параметр COVERAGE_CORE.

# В файле Makefile
-  docker compose run --rm --env COVERAGE=$(COVERAGE) tests bin/tests --postgresql-host db $(T) $(TESTARGS)
+ docker compose run --rm --env COVERAGE=$(COVERAGE) --env COVERAGE_CORE=$(COVERAGE_CORE) tests bin/tests --postgresql-host db $(T) $(TESTARGS)

Использование новой возможности coverage никаких сложностей не вызвало. Спасибо за это Нэду Батчелдеру, который подготовил отличную документацию и провёл огромную работу над coverage.py.

Воздействие изменений на производительность

Это изменение было добавлено в проект посредством PR #16621. Здесь, как и прежде, удалось получить замечательные результаты.

До, с

После, с

Улучшение

Время выполнения тестов

58

27

Сокращение времени на 53%

Эта оптимизация обратила наше внимание на ещё одну сильную сторону организации процесса разработки Warehouse. А именно, речь идёт о том, что проект сравнительно быстро переходит на новые версии Python (в данном случае речь идёт о версии 3.12), что помогло ему воспользоваться API sys.monitoring. Доступность нового API дало проекту тот рост производительности, который обеспечивают улучшения coverage, основанные на этом API.

Ускорение фазы обнаружения тестов в pytest

Разбираемся с дополнительной нагрузкой на систему, создаваемой в ходе сбора тестов

В больших проектах процесс обнаружения тестов при использовании pytest может оказаться на удивление ресурсоёмким:

  • Фреймворк pytest рекурсивно сканирует директории в поиске файлов тестов.

  • Он импортирует каждый из файлов для того, чтобы найти в нём функции и классы, реализующие тесты.

  • Он собирает метаданные тестов и выполняет фильтрацию.

  • Только после всего этого может начаться, собственно, тестирование кода.

Когда речь идёт о более чем 4700 тестов PyPI, только этот вот процесс обнаружения тестов занимает более 6 секунд — это 10% общего времени выполнения тестов после параллелизации.

Стратегическая оптимизация параметра testpaths

Все тесты Warehouse размещаются в одной директории. Это делает тестовый набор Warehouse идеальным кандидатом для применения в нём мощного конфигурационного параметра pytest, называемого testpaths. Оптимизация представлена одной единственной строчкой, сообщающей фреймворку pytest о том, что он должен искать тесты только в заданной директории. Это избавляет систему от затрат ресурсов на бессмысленный поиск тестов там, где их нет.

Вот как выглядит настройка параметра testpaths в pytest.

[tool.pytest.ini_options]
...
testpaths = ["tests/"]
...

Вот результаты измерения времени, необходимого на сбор тестов.

$ docker compose run --rm tests pytest --postgresql-host db --collect-only
# Before optimization:
# 3,900+ tests collected in 7.84s

# After optimization:
# 3,900+ tests collected in 2.60s

До оптимизации на сбор тестов надо было потратить 7,84 секунды, а после — 2,6 секунды. Это — экономия 66% времени, уходящего на сбор тестов.

Анализ воздействия улучшений на проекты

Вышеописанная оптимизация, добавленная в проект с помощью PR #16523, сократила общее время выполнения тестов с 50 до 48 секунд, что не так уж и плохо, учитывая то, что для этого понадобилась изменение всего одной строчки настроек.

Хотя это 2-секундное улучшение может показаться весьма скромным в сравнении с теми улучшениями, которые дала параллелизация, при её оценке важно учитывать следующее:

  • Соотношение затрат и выгод: это улучшение потребовало изменить всего одну строку настроек.

  • Пропорциональное влияние на производительность: на сбор тестов уходит 10% оставшегося времени тестирования.

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

Эта оптимизация применима ко многим Python-проектам. Чтобы извлечь из неё максимум выгоды — надо проверить структуру проекта и сделать так, чтобы параметр testpaths точно указывал бы именно на ту директорию, где находятся тесты. При этом в нём не должно быть бесполезных директорий, в которых тестов нет.

Устранение дополнительной нагрузки на систему, вызываемой ненужными командами импорта

После применения вышеописанных оптимизаций мы решили провести профилирование времени, необходимого на импорт модулей. Для этого мы воспользовались опцией Python -X importtime. Нам было интересно узнать о том, сколько времени тратится на импорт модулей, которые не используются в ходе выполнения тестов. Анализ показал, что тестовый набор тратит значительное время на импорт ddtrace — модуля, который интенсивно используется в продакшне, но не во время тестирования.

Время, необходимое на загрузку pytest до и после удаления ddtrace.

# До деинсталляции ddtrace
> time pytest --help
real    0m4.975s
user    0m4.451s
sys     0m0.515s

# После деинсталляции ddtrace
> time pytest --help
real    0m3.787s
user    0m3.435s
sys     0m0.346s

До, с

После, с

Улучшение

Время выполнения тестов

29

28

Сокращение времени на 3,4%

Это простое изменение попало в проект посредством PR #17232, уменьшив время выполнения тестов с 29 до 28 секунд. Это — скромное, но значимое улучшение, означающее сокращение времени выполнения тестов на 3,4%. Самое главное в подобных ситуациях — идентифицировать зависимости, от которых при тестировании нет никакой пользы, но которые при этом создают большую дополнительную нагрузку на систему при запуске проекта.

Эксперимент по сжатию миграций базы данных

Занимаясь систематическим исследованием производительности системы, мы, в поиске возможных путей оптимизации тестов, проанализировали фазу инициализации базы данных.

Оценка дополнительной нагрузки на систему, вызываемой миграциями базы данных

В проекте Warehouse для управления миграциями базы данных используется Alembic. С 2015 года набралось более 400 миграций. При инициализации тестов каждый из параллельных рабочих процессов должен выполнить эти миграции для того, чтобы получить в своё распоряжение чистую базу данных для нужд тестирования.

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

import time
import pathlib
import uuid

start = time.time()
alembic.command.upgrade(cfg.alembic_config(), "head")

end = time.time() - start
pathlib.Path(f"/tmp/migration-{uuid.uuid4()}").write_text(f"{end=}\n")

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

Создание прототипа

Хотя Alembic официально не поддерживает сжатие миграций, мы разработали прототип решения, основываясь на рекомендациях, данных членами сообщества пользователей Alembic. Вот что мы сделали:

  1. Создали сжатую миграцию, отражающую состояние текущей схемы базы данных.

  2. Реализовали механизм распознавания окружения, позволяющий выбрать один из двух путей:

    • Если это — окружение, где выполняются тесты — использовать единственную сжатую миграцию.

    • Если это — продакшн — продолжать пользоваться полной историей миграций.

Наш прототип ещё сильнее, на 13%, сократил время выполнения тестов.

Решение о том, что это изменение не нужно включать в проект

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

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

Производительность тестов и обеспечение безопасности проектов

Оптимизация производительности тестов — это не только забота об удобстве разработчиков. Это — часть образа мыслей, ориентированного на безопасность. Ускорение тестирования «сжимает» контуры обратной связи, подталкивает разработчиков к более частому использованию тестов, даёт разработчикам возможность находить проблемы до того, как они доберутся до продакшна. Ускорение тестирования — это, кроме прочего, часть сил и средств, направленных на обеспечение безопасности.

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

Меры, позволяющие достичь немедленной выгоды в ускорении работы тестовых наборов

Если вы хотите применить рассмотренные подходы к оптимизации в собственных тестовых наборах — вот несколько советов, касающихся расстановки приоритетов с целью достижения максимального эффекта от оптимизации:

  1. Распараллеливайте тесты: установите pytext-xdist и добавьте опцию
    --numprocesses=auto в настройки pytest.

  2. Оптимизируйте применение средств анализа покрытия кода тестами: если вы работаете на Python 3.12+ — задайте параметр COVERAGE_CORE=sysmon для использования более эффективного API мониторинга системы в coverage.py начиная с версии 7.4.0.

  3. Ускорьте обнаружение тестов: воспользуйтесь testpaths в настройках pytest для того, чтобы сбор тестов осуществлялся только в правильных директориях. Это позволит сократить время обнаружения тестов.

  4. Уберите ненужные команды импорта: воспользуйтесь конструкцией
    python -X importtime для выявления модулей, которые медленно загружаются, и, если это возможно, избавьтесь от этих модулей.

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

Итоги: скорость — союзник безопасности

Быстрое выполнение тестов позволяет разработчикам делать именно то, что нужно. Когда на выполнение тестов уходят считанные секунды, а не минуты, практики безопасности, вроде тестирования каждого изменения, и запуска всего тестового набора перед выполнением merge-запросов, превращаются из рекомендаций, которые всем нравятся, но не всегда выполняются, в обычное дело. Ваш тестовый набор — это первая линия обороны проекта, но так его можно назвать лишь в том случае, если им постоянно пользуются. Ускорьте его настолько, чтобы никто даже и не задумывался бы о том, запускать его, или не запускать.

О, а приходите к нам работать? ? ?

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде

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