PyPI测试套件性能优化:81%速度提升实战解析

本文详细记录了通过并行测试执行、Python 3.12的sys.monitoring优化覆盖率检测、pytest测试发现加速等技术手段,将PyPI后端Warehouse的测试时间从163秒降至30秒的全过程,涵盖具体代码修改与性能对比数据。

让PyPI测试套件提速81% - Trail of Bits博客

Alexis Challande | 2025年5月1日
supply-chain, ecosystem-security, engineering-practice, open-source

庞然大物:Warehouse的测试套件

PyPI是Python生态系统的关键组件:每日服务超过10亿次分发下载,全球开发者依赖其可靠性和完整性来获取集成到技术栈中的软件工件。这种关键性使得全面测试成为必需,Warehouse相应展示了典范的测试实践:截至2025年4月,4,734个测试实现了单元和集成测试组合的100%分支覆盖率。这些测试使用pytest框架实现,并在每个拉取请求和合并时作为稳健CI/CD管道的一部分运行,同时还强制要求100%覆盖率作为验收标准。在我们的基准系统上,当前套件执行时间约为30秒。

这一性能相比2024年3月有了显著提升,当时测试套件:

  • 包含约3,900个测试(减少17.5%)
  • 需要161秒执行(延长5.4倍)
  • 在开发工作流程中造成显著摩擦

下面我们将探讨实现这些改进的系统方法,从影响最大的更改开始,一直到共同改变PyPI贡献者测试体验的精细优化。

并行化测试执行实现巨大收益

最重要的性能改进来自一个基础计算原则:并行化。测试通常非常适合并行执行,因为设计良好的测试用例是隔离的,没有副作用或全局可观察行为。Warehouse的单元和集成测试已经良好隔离,使并行化成为我们优化工作的明显首要目标。

我们使用pytest-xdist(一个流行的插件,将测试分布到多个CPU核心)实现了并行测试执行。

pytest-xdist配置很简单:这一行更改就足够了!

1
2
3
4
5
6
7
8
# 在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核的测试机器上,这立即产生了显著改进,同时也揭示了几个需要仔细解决的挑战。

挑战:数据库夹具

每个测试工作器需要其隔离的数据库实例以防止测试间污染。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@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:数据库夹具的更改。

此更改使每个工作器使用自己的数据库实例,防止不同工作器之间的任何交叉污染。

挑战:覆盖率报告

测试并行化破坏了我们的覆盖率报告,因为每个工作器进程独立收集覆盖率数据。幸运的是,这个问题在覆盖率文档中有涵盖。我们通过添加sitecustomize.py文件解决了这个问题。

1
2
3
4
5
try:
    import coverage
    coverage.process_startup()
except ImportError:
    pass

图4:在使用多个工作器时启动覆盖率检测。

挑战:测试输出可读性

并行执行产生了交错、难以阅读的输出。我们集成了pytest-sugar来提供更清晰、更有组织的测试结果(PR #16245)。

结果

这些更改在PR #16206中合并,产生了显著结果:

指标 之前 之后 改进
测试执行时间 191s 63s 67%减少

这一单一优化提供了我们大部分性能收益,同时需要相对较少的代码更改,证明了在微调单个组件之前解决架构瓶颈的重要性。

使用Python 3.12的sys.monitoring优化覆盖率

覆盖率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模块的实验性核心。这应该更快,尽管插件和动态上下文尚不支持。 (来源)

Warehouse中的更改

1
2
3
# 在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的优秀文档和辛勤工作,使用这个新的覆盖率功能很简单!

更改影响

这一更改在PR #16621中合并,结果同样显著:

指标 之前 之后 改进
测试执行时间 58s 27s 53%减少

这一优化突显了Warehouse开发过程的另一个优势:通过相对快速地采用新的Python版本(本例中为3.12),Warehouse能够利用sys.monitoring并直接受益于它给覆盖率带来的性能改进。

加速pytest的测试发现阶段

理解测试收集开销

在大型项目中,pytest的测试发现过程可能变得异常昂贵:

  • Pytest递归扫描目录以查找测试文件
  • 它导入每个文件以发现测试函数和类
  • 它收集测试元数据并应用过滤
  • 只有这样实际测试执行才能开始

对于PyPI的4,700多个测试,仅这个发现过程就消耗了超过6秒——占我们并行化后总测试执行时间的10%。

使用testpaths进行战略优化

Warehouse测试都位于单个目录结构中,使它们成为强大pytest配置选项testpaths的理想候选。这个简单的一行更改指示pytest仅在指定目录中查找测试,消除了扫描无关路径的浪费努力:

1
2
3
4
[tool.pytest.ini_options]
...
testpaths = ["tests/"]
...

图6:使用testpaths配置pytest。

1
2
3
4
5
6
$ docker compose run --rm tests pytest --postgresql-host db --collect-only
# 优化前:
# 3,900+个测试在7.84秒内收集

# 优化后:
# 3,900+个测试在2.60秒内收集

图7:计算测试收集时间。

这表示收集时间减少了66%。

影响分析

这一更改在PR #16523中合并,将总测试时间从50秒减少到48秒——对于单行配置更改来说不错。

虽然2秒的改进相比我们的并行化收益可能显得 modest,但重要的是考虑:

  • 成本效益比:此更改只需要单行配置。
  • 比例影响:收集占我们剩余测试时间的10%。
  • 累积效应:每个优化都会复合产生整体改进。

此优化适用于许多Python项目。为获得最大收益,检查您的项目结构并确保testpaths精确指向您的测试目录,不包括不必要的路径。

移除不必要的导入开销

实施先前优化后,我们转向使用Python的-X importtime选项分析导入时间。我们感兴趣的是测试期间未使用的模块导入花费了多少时间。我们的分析显示,测试套件花费了大量时间导入ddtrace,这是一个在生产中广泛使用但在测试期间未使用的模块。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 卸载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

图8:加载pytest有和没有ddtrace的时间花费。

指标 之前 之后 改进
测试执行时间 29s 28s 3.4%减少

这一简单更改在PR #17232中合并,将我们的测试执行时间从29秒减少到28秒—— modest但有意义的3.4%改进。这里的关键洞察是识别在测试期间不提供价值但产生显著启动成本的依赖项。

数据库迁移压缩实验

作为我们系统性能调查的一部分,我们分析了数据库初始化阶段以识别潜在优化。

量化迁移开销

Warehouse使用Alembic管理数据库迁移,自2015年以来积累了400多个迁移。在测试初始化期间,每个并行测试工作器必须执行这些迁移以建立干净的测试数据库。

1
2
3
4
5
6
7
8
9
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%的测试执行时间。

决定不合并

经过仔细审查,项目维护者决定不合并此更改。管理压缩迁移和第二个迁移路径的额外复杂性超过了时间收益。

这一探索说明了性能工程的一个关键原则:并非所有改进指标的优化都应该实施。整体评估还必须考虑长期维护成本。有时,接受性能开销是对项目长期健康正确的架构决策。

测试性能作为安全实践

优化测试性能不仅仅是开发人员的便利——它是安全思维的一部分。更快的测试收紧反馈循环,鼓励更频繁的测试,并使开发人员能够在问题到达生产环境之前捕获它们。更快的测试时间也是安全态势的一部分。

本文描述的所有改进都是在不修改测试逻辑或减少覆盖率的情况下实现的——这证明了在没有安全权衡的情况下可以获得多少性能。

加速测试套件的快速技巧

如果您希望将这些技术应用到自己的测试套件中,以下是一些关于如何优先考虑优化工作以获得最大影响的建议。

  1. 并行化测试套件:安装pytest-xdist并在pytest配置中添加–numprocesses=auto。
  2. 优化覆盖率检测:如果您使用Python 3.12+,设置export COVERAGE_CORE=sysmon以在coverage.py 7.4.0及更新版本中使用更轻量级的监控API。
  3. 加速测试发现:在pytest配置中使用testpaths,将测试收集仅集中在相关目录上,减少收集时间。
  4. 消除不必要的导入:使用python -X importtime识别缓慢加载的模块,并在可能的情况下移除它们。

通过一些高度针对性的更改,您可以在自己的测试套件中实现显著改进,同时保持其作为质量保证工具的有效性。

安全热爱速度

快速测试使开发人员能够做正确的事情。当您的测试在几秒而不是几分钟内运行时,测试每个更改和在合并前运行整个套件等安全实践变得现实可行,而不是 aspirational 指南。您的测试套件是前线防御,但只有在实际运行时才是。使其足够快,以至于没有人会犹豫运行它。

致谢

Warehouse是一个社区项目,我们不是唯一改进其测试套件的人。例如,@twm的PR #16295和PR #16384也通过关闭postgres的文件同步和缓存DNS请求来提高性能。

这项工作离不开维护PyPI及其支持库的更广泛开源开发者社区。特别感谢@miketheman激励和审查这项工作,以及他对Warehouse开发者体验的不懈改进。我们还衷心感谢Alpha-Omega资助这项重要工作,以及资助@miketheman作为PyPI安全与安全工程师的角色。

我们的优化也站在pytest、pytest-xdist和coverage.py等项目的肩膀上,这些项目的维护者投入了无数时间构建稳健、高性能的基础。

如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计