提升 PyPI 测试套件速度 81% – The Trail of Bits Blog
提升 PyPI 测试套件速度 81%
Alexis Challande 2025年5月1日 supply-chain, ecosystem-security, engineering-practice, open-source
多年来,Trail of Bits 一直与 PyPI 合作,致力于为 Python 包生态系统添加功能和改进安全默认设置。
我们之前的文章重点介绍了诸如数字证明和 Trusted Publishing 等功能,但今天我们将探讨整体软件安全中一个同样重要的方面:测试套件性能。
一个强大的测试套件对于复杂代码库的安全性和可靠性至关重要。 然而,随着测试覆盖率的增长,执行时间也会增加,从而在开发过程中产生摩擦,并降低频繁和有意义(即深入)测试的积极性。 在这篇文章中,我们将详细介绍我们如何系统地优化 Warehouse (PyPI 后端) 的测试套件,将执行时间从 163 秒减少到 30 秒,同时测试数量从 3,900 个增加到 4,700 多个。
图 1:Warehouse 在过去 12 个月(2024 年 3 月至 2025 年 4 月)内的测试执行时间。
我们通过以下几个步骤实现了 81% 的性能提升:
- 使用
pytest-xdist
并行执行测试(相对减少 67%) - 使用 Python 3.12 的
sys.monitoring
进行更高效的覆盖率检测(相对减少 53%) - 通过策略性 testpaths 配置优化测试发现
- 消除不必要的导入,从而减少启动开销
这些优化可以直接应用于许多 Python 项目,特别是那些测试套件不断增长,并已成为开发工作流程瓶颈的项目。 通过实施这些技术中的一部分,您可以显著提高自己的测试性能,而无需任何成本。
本文中报告的所有时间均来自在指定的日期,在 n2-highcpu-32 机器上运行 Warehouse 测试套件。 虽然并非旨在作为正式的基准测试结果,但这些测量结果清楚地证明了我们优化的影响。
难题:Warehouse 的测试套件
PyPI 是 Python 生态系统中的一个关键组件:它每天提供超过 10 亿次的发行版下载,全球的开发人员都依赖它的可靠性和完整性来获得他们集成到各自技术栈中的软件组件。
这种关键性使得全面的测试变得不可协商,而 Warehouse 相应地展示了模范的测试实践:截至 2025 年 4 月,4,734 个测试提供了单元和集成套件组合的 100% 分支覆盖率。 这些测试是使用 pytest
框架实现的,并且作为强大的 CI/CD 管道的一部分在每个 pull request 和 merge 上运行,该管道还强制执行 100% 覆盖率作为验收要求。 在我们的基准测试系统中,当前套件的执行时间约为 30 秒。
这个性能相比 2024 年 3 月有了巨大的提升,当时的测试套件:
- 包含大约 3,900 个测试(减少了 17.5% 的测试)
- 需要 161 秒才能执行(时间长了 5.4 倍)
- 在开发工作流程中产生了巨大的摩擦
下面,我们将探讨为实现这些改进而采取的系统方法,从影响最大的更改开始,逐步进行到更精细的优化,这些优化共同改变了 PyPI 贡献者的测试体验。
并行化测试执行以获得巨大收益
最显著的性能改进来自一个基本的计算原则:并行化。 测试通常非常适合并行执行,因为精心设计的测试用例是隔离的,并且没有副作用或全局可观察的行为。 Warehouse 的单元测试和集成测试已经很好地隔离,这使得并行化成为我们优化工作的首要目标。
我们使用 pytest-xdist
实现了并行测试执行,这是一个流行的插件,可以将测试分发到多个 CPU 核心。
pytest-xdist
配置很简单:只需更改这一行就足够了!
# In pyproject.toml
[tool.pytest.ini_options]
addopts = [
"--disable-socket",
"--allow-hosts=localhost,::1,notdatadog,stripe",
"--durations=20",
+ "--numprocesses=auto",
]
图 2:配置 pytest 以使用 pytest-xdist 运行。
通过这个简单的配置,pytest
会自动使用所有可用的 CPU 核心。 在我们的 32 核测试机器上,这立即产生了显著的改进,同时也揭示了一些需要仔细解决的挑战。
挑战:数据库 fixtures
每个测试 worker 都需要其隔离的数据库实例,以防止跨测试污染。
@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(
图 3:数据库 fixture 的更改。
此更改使每个 worker 使用其自己的数据库实例,从而防止了不同 worker 之间的任何交叉污染。
挑战:覆盖率报告
测试并行化破坏了我们的覆盖率报告,因为每个 worker 进程都独立收集覆盖率数据。 幸运的是,coverage 文档 中介绍了此问题。 我们通过添加 sitecustomize.py
文件解决了这个问题。
try:
import coverage
coverage.process_startup()
except ImportError:
pass
图 4:在使用多个 worker 时启动覆盖率检测。
挑战:测试输出可读性
并行执行产生了交错的、难以阅读的输出。 我们集成了 pytest-sugar,以提供更清晰、更有组织的测试结果 (PR #16245)。
结果
这些更改已在 PR #16206 中合并,并产生了显著的结果:
| |之前 | 之后 | 提升| | -------- | -------- | -------- | -------- | | 测试执行时间 | 191 秒 | 63 秒 | 减少 67% |
这个单一的优化提供了我们大部分的性能提升,同时只需要相对较少的代码更改,这证明了在微调各个组件之前解决架构瓶颈的重要性。
使用 Python 3.12 的 sys.monitoring
优化覆盖率
Coverage 7.7.0+ 注意:当在 Python 3.14 之前的版本中使用 分支覆盖率 时,COVERAGE_CORE=sysmon
设置会自动禁用,并会发出警告。
我们的分析表明,代码覆盖率检测是另一个显著的性能瓶颈。 覆盖率测量对于测试质量至关重要,但传统的实现方法会给测试执行增加相当大的开销。
PEP 669 引入了 sys.monitoring
,这是一种更轻量级的方式来监视执行。 coverage.py
库在 7.4.0 版本中开始支持这个新的 API:
在 Python 3.12 及更高版本中,您可以通过定义
COVERAGE_CORE=sysmon
环境变量来尝试基于新的sys.monitoring module
的实验性核心。 这应该会更快,尽管插件和动态上下文尚不支持它。 (source)
Warehouse 中的更改
# In 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)
图 5:对 Makefile 的更改,允许设置 COVERAGE_CORE 变量。
多亏了 Ned Batchelder 的出色文档和辛勤工作,使用这个新的 coverage
功能非常简单!
更改的影响
此更改已在 PR #16621 中合并,结果也同样显著:
| |之前 | 之后 | 提升| | -------- | -------- | -------- | -------- | | 测试执行时间 | 58 秒 | 27 秒 | 减少 53% |
这个优化突出了 Warehouse 开发过程的另一个优势:通过相对较快地采用新的 Python 版本(在本例中为 3.12),Warehouse 能够利用 sys.monitoring
并直接受益于它为 coverage
带来的性能改进。
加速 pytest 的测试发现阶段
了解测试收集开销
在大型项目中,pytest 的测试发现过程可能会变得非常昂贵:
- Pytest 递归扫描目录以查找测试文件
- 它导入每个文件以发现测试函数和类
- 它收集测试元数据并应用筛选
- 只有这样才能开始实际的测试执行
对于 PyPI 的 4,700 多个测试,仅此发现过程就消耗了超过 6 秒 - 占并行化后总测试执行时间的 10%。
使用 testpaths
进行策略性优化
Warehouse 测试都位于单个目录结构中,这使得它们成为强大的 pytest
配置选项 testpaths
的理想候选对象。 这个简单的一行更改指示 pytest
仅在指定的目录中查找测试,从而消除了扫描无关路径的浪费精力:
[tool.pytest.ini_options]
...
testpaths = ["tests/"]
...
图 6:使用 testpaths 配置 pytest。
$ 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:计算测试收集时间。
这表示收集时间减少了 66%。
影响分析
此更改已在 PR #16523 中合并,将总测试时间从 50 秒减少到 48 秒 - 对于单行配置更改来说还不错。
虽然与我们的并行化收益相比,2 秒的改进似乎并不显著,但重要的是要考虑:
- 成本效益比:此更改仅需要一行配置。
- 比例影响:收集占我们剩余测试时间的 10%。
- 累积效应:每个优化都会复合以产生整体改进。
此优化适用于许多 Python 项目。 为了获得最大收益,请检查您的项目结构,并确保 testpaths
精确地指向您的测试目录,而不包括不必要的路径。
删除不必要的导入开销
在实施之前的优化之后,我们使用 Python 的 -X importtime
选项开始分析导入时间。 我们感兴趣的是,在测试期间没有使用的模块导入花费了多少时间。 我们的分析表明,测试套件花费了大量时间导入 ddtrace
,这是一个在生产中广泛使用但在测试期间未使用的模块。
# Before uninstall ddtrace
> time pytest --help
real 0m4.975s
user 0m4.451s
sys 0m0.515s
# After uninstall ddtrace
> time pytest --help
real 0m3.787s
user 0m3.435s
sys 0m0.346s
图 8:加载 pytest 在有和没有 ddtrace 的情况下所花费的时间。
| |之前 | 之后 | 提升| | -------- | -------- | -------- | -------- | | 测试执行时间 | 29 秒 | 28 秒 | 减少 3.4% |
这个简单的更改已在 PR #17232 中合并,将我们的测试执行时间从 29 秒减少到 28 秒 - 一个适度但有意义的 3.4% 的改进。 这里的关键见解是识别在测试期间没有提供任何价值但会产生显著启动成本的依赖项。
数据库迁移合并实验
作为我们系统性能调查的一部分,我们分析了数据库初始化阶段,以识别潜在的优化。
量化迁移开销
Warehouse 使用 Alembic 来管理数据库迁移,自 2015 年以来已累积了 400 多个迁移。 在测试初始化期间,每个并行测试 worker 必须执行这些迁移以建立干净的测试数据库。
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")
图 9:一种快速而肮脏的方式来衡量迁移开销。
每次迁移大约需要 1 秒,因此我们可以进一步改进。
设计解决方案
虽然 Alembic 官方不支持迁移合并,但我们根据 社区反馈 开发了一个概念验证。 我们的方法:
- 创建一个合并迁移,表示当前模式状态。
- 实施环境检测以在路径之间进行选择:
- 测试将使用单个合并迁移
- 生产将继续使用完整的迁移历史记录
我们的概念验证进一步将测试执行时间减少了 13%。
决定不合并
经过仔细审查,项目维护人员决定不合并此更改。 管理合并迁移和第二个迁移路径的增加的复杂性超过了时间收益。
这个探索说明了性能工程的一个关键原则:并非所有改进指标的优化都应该实施。 整体评估还必须考虑长期维护成本。 有时,接受性能开销是项目长期健康的正确架构决策。
测试性能作为一种安全实践
优化测试性能不仅仅是为了方便开发人员 - 它是安全思维模式的一部分。 更快的测试可以缩短反馈循环,鼓励更频繁的测试,并使开发人员能够在问题到达生产环境之前捕获它们。 更快的测试时间也是安全态势的一部分。
本文中描述的所有改进都是在不修改测试逻辑或减少覆盖率的情况下实现的 - 这证明了在不牺牲安全性的情况下可以获得多少性能。
加速测试套件的快速胜利
如果您希望将这些技术应用于您自己的测试套件,这里有一些关于如何优先考虑您的优化工作以获得最大影响的建议。
- 并行化您的测试套件:安装
pytext-xdist
并将--numprocesses=auto
添加到您的pytest
配置。 - 优化覆盖率检测:如果您使用的是 Python 3.12+,请设置
export COVERAGE_CORE=sysmon
以使用coverage.py 7.4.0
及更高版本中更轻量级的监视 API。 - 加速测试发现:在您的
pytest
配置中使用testpaths
,将测试收集集中在仅相关的目录上,并减少收集时间。 - 消除不必要的导入:使用
python -X importtime
来识别加载缓慢的模块,并在可能的情况下删除它们。
通过几个非常有针对性的更改,您可以在保持测试套件作为质量保证工具的有效性的同时,在您自己的测试套件中实现显著的改进。
安全喜欢速度
快速的测试使开发人员能够做正确的事情。 当您的测试在几秒钟而不是几分钟内运行时,诸如测试每个更改和在合并之前运行整个套件之类的安全实践将成为现实的期望,而不是有抱负的指导原则。 您的测试套件是一道前线防御,但前提是它实际运行。 使它足够快,以至于没有人会三思而后行。
致谢
Warehouse 是一个社区项目,我们并不是唯一改进其测试套件的人。 例如,@twm 的 PR #16295 和 PR #16384 也通过关闭 postgres
的文件同步和缓存 DNS 请求来提高性能。
如果没有更广泛的开源开发人员社区维护 PyPI 和为其提供支持的库,这项工作是不可能实现的。 特别是,我们要感谢 @miketheman 激励和审查这项工作,以及他自己对 Warehouse 开发人员体验的不断改进。 我们还要向 Alpha-Omega 表示衷心的感谢,感谢他们为这项重要工作提供资金,以及为 @miketheman 作为 PyPI 的安全工程师的角色提供资金。
我们的优化也站在 pytest
、pytest-xdist
和 coverage.py
等项目的肩膀上,这些项目的维护者投入了无数的时间来构建强大而高性能的基础。