Skip to content

第 14 章 · 并行与分布式执行

本章目标:用 pytest-xdist 把测试套件铺到多个 CPU 上,理解 --dist 四种分发策略的适用场景,避开 fixture 隔离与共享资源的并行陷阱,并让 xdist 与覆盖率统计和平共处。

14.1 五分钟上手:-n auto

当套件规模增长到"跑一轮要喝杯咖啡"时,就该上 pytest-xdist 了。它把测试分发给多个进程(worker)并行执行:

bash
pip install pytest-xdist
pytest -n auto        # 按物理 CPU 核数启动 worker
pytest -n 8           # 显式指定 worker 数
pytest -n logical     # 用逻辑核数(需 Python>=3.13 或 psutil)
pytest -n 0           # 关闭并行,回到单进程

-n auto 的 worker 数还可以通过环境变量 PYTEST_XDIST_AUTO_NUM_WORKERS 或在 conftest 里实现 pytest_xdist_auto_num_workers(config) hook 来定制;另有 --maxprocesses=N 给 auto 模式设置上限:

python
# content of conftest.py —— 按运行环境自定义 auto worker 数
import os


def pytest_xdist_auto_num_workers(config):
    if "CI" in os.environ:          # CI 机器核少但测试重,固定 4 个
        return 4
    return None                     # 返回 None 则回退到默认行为
bash
# 本地最多开 4 个 worker,避免开发机卡死
pytest -n auto --maxprocesses=4

14.2 分发策略:--dist 的四种选择

默认策略 load 是"哪个 worker 空了就塞给它下一个",不保证顺序。但有些测试必须在同一进程内连续执行——比如依赖昂贵的模块级 fixture、或有顺序耦合——这时就要换策略:

bash
pytest -n 4 --dist load         # 默认:自由负载均衡
pytest -n 4 --dist loadscope    # 函数按模块分组、方法按类分组,同组同 worker
pytest -n 4 --dist loadfile     # 同一文件的所有测试固定在一个 worker
pytest -n 4 --dist loadgroup    # 按 xdist_group 标记分组
pytest -n 4 --dist worksteal    # 负载窃取:忙闲不均时自动转移任务

loadgroup 配合 xdist_group 标记可以手工圈定"必须同生共死"的用例:

python
# content of test_cluster.py
import pytest


@pytest.mark.xdist_group(name="group1")
def test_create_resource():
    create_shared_resource()          # 与 test_use_resource 保证在同一 worker


class TestA:
    @pytest.mark.xdist_group("group1")   # 类上的标记同样生效
    def test_use_resource(self):
        assert shared_resource_ready()

选型经验

  • 默认 load 适合绝大多数纯单元测试;
  • 有昂贵的 module/class 级 fixture → loadscope
  • 测试间时长差异巨大(混合快慢测)→ worksteal
  • 存在跨测试共享状态的存量代码 → loadfile/loadgroup 过渡,最终目标是消除状态耦合。

14.3 并行陷阱清单

并行不是免费午餐。xdist 官方文档列出的限制之外,工程上最常见的翻车点:

  1. 共享外部资源:两个 worker 同时连同一个数据库 schema / 写同一个文件 / 抢同一个端口。

    python
    # ❌ 所有 worker 都往固定路径写,必然互踩
    LOG = open("/tmp/app.log", "w")
    
    # ✅ 用 tmp_path 隔离(每个 worker 进程独立目录)
    def test_write(tmp_path):
        target = tmp_path / "app.log"
        write_log(target)
        assert target.exists()
  2. session 级 fixture 每 worker 执行一次scope="session" 的 fixture 在每个 worker 进程里各跑一遍,不是全局唯一。需要真正全局唯一的资源(如一次性初始化的数据库)要用文件锁或主进程预创建。

    python
    # content of conftest.py —— 用文件锁保证数据库只被初始化一次
    import filelock
    import pytest
    
    
    @pytest.fixture(scope="session")
    def database(tmp_path_factory):
        # rootdir 下的一次性锁文件,所有 worker 抢同一把锁
        lock = tmp_path_factory.getbasetemp().parent / "db.lock"
        with filelock.FileLock(str(lock)):
            db = create_test_database()   # 锁内初始化,其余 worker 等待后复用
        yield db
  3. 顺序假设:依赖执行顺序或模块级可变全局状态的测试,在 load 策略下会随机炸。修测试而不是调策略。

  4. 输出与报告交错:失败摘要仍会汇总,但 print 输出归属哪个 worker 不直观;定位问题时可加 -v 看 worker 前缀(如 gw0gw1)。

bash
# 排查并行-only 失败的标准三板斧
pytest -n 4                 # 并行跑,复现
pytest -n 0                 # 串行跑,确认是否为并行特有
pytest -n 4 -k "test_x"     # 缩小范围定位冲突对

14.4 xdist + coverage:并行下也要准确

多进程各自测量再简单相加是错的(同一行可能被不同 worker 各执行一部分)。pytest-cov 从设计上就兼容 xdist:worker 结束时会自动把数据回传合并,你不需要任何额外操作:

bash
pytest -n auto --cov=src --cov-report=term-missing

唯一的硬性要求是 所有 worker 都必须装了 pytest-cov(插件要通过 entry-point 在每个 worker 注册)。若还要测量被测程序拉起的子进程,按第 13 章提示配置 coverage 的 subprocess patch。

别忘了 CI 里的 CPU 数

CI 机器常见只有 2–4 核,-n auto 收益有限甚至因进程开销变慢。可以在 CI 中显式 -n 4,并把并行收益最大的集成测试放到独立的 job 里跑。

14.5 本章小结

  • -n auto/N 一行命令开启多进程并行;--maxprocesses 控制 auto 上限;
  • --dist 四策略:load(默认)、loadscope/loadfile(分组保序)、loadgroup(xdist_group 手工分组)、worksteal(负载窃取);
  • 三大陷阱:共享资源要隔离(tmp_path)、session fixture 是每 worker 一份、禁止顺序与全局状态假设;
  • pytest-cov 自动合并多 worker 覆盖率,前提是 worker 环境都装有该插件。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. -n auto 启动的 worker 数量由什么决定?

2. 一组测试依赖昂贵的模块级 fixture,希望它们始终在同一个 worker 内执行,最合适的策略是?

3. 关于 scope="session" 的 fixture 在 xdist 下的行为,正确的是?

4. pytest-cov 与 pytest-xdist 一起使用时,覆盖率的正确说法是?

🛠️ 动手实践

  1. 构造一个包含 20 个 time.sleep(0.1) 参数化用例的套件,分别以 -n 0-n auto 计时对比,记录加速比。
  2. 制造一个并行冲突:两个测试都向同一固定路径写文件且断言内容,观察 -n auto 下的偶发失败,然后用 tmp_path 修复。
  3. xdist_group 把"先建后删"的两个测试圈进同名 group,分别在 --dist loadgroup 和默认 load 下运行多次,验证前者不再出现删除先于创建的竞态。

下一章进入参数化的深水区:indirect、笛卡尔积堆叠与自定义 ID 体系:第 15 章