Python 测试策略:pytest fixture 作用域与 pytest-xdist 并行化
深入解析 pytest fixture 生命周期、conftest 隔离机制、parametrize 参数组合、pytest-xdist 并行执行,以及测试覆盖率优化策略。
1. fixture 生命周期
1.1 作用域层级
pytest fixture 支持四种作用域(scope):function(默认)、class、module、session。作用域决定了 fixture 的创建和销毁时机。
pytest
# function 作用域:每个测试函数执行一次
def function_scoped():
print("Created")
yield
print("Destroyed")
# module 作用域:整个模块只执行一次
(scope="module")
def module_scoped():
print("Module fixture created")
yield
print("Module fixture destroyed")
# session 作用域:整个测试会话只执行一次
(scope="session")
def session_scoped():
print("Session fixture created")
yield
print("Session fixture destroyed")
1.2 fixture 依赖顺序
pytest 按照依赖声明顺序创建 fixture。fixture A 依赖 fixture B 时,B 会先于 A 创建,但销毁顺序相反(LIFO)。
def base():
print("1-base created")
yield
print("1-base destroyed")
def derived(base):
print("2-derived created")
yield
print("2-derived destroyed")
def top(derived):
print("3-top created")
yield
print("3-top destroyed")
# 测试函数使用 top
test_order(top):
pass
# 输出顺序:
# 创建: 1-base -> 2-derived -> 3-top
# 销毁: 3-top -> 2-derived -> 1-base (LIFO)
1.3 autouse 与条件执行
autouse=True 使 fixture 自动为所有测试执行,无需显式声明依赖。配合 pytest.mark.skipif 或 pytest.importorskip 可实现条件 fixture。
# autouse fixture:自动为所有测试创建数据库事务回滚
(autouse=True)
def db_transaction(db_connection):
# 每个测试开始时开启事务
transaction = db_connection.begin()
yield
# 测试结束后回滚,不污染数据库
transaction.rollback()
# 条件 fixture:仅当特定模块可用时
def redis_client():
redis = ("redis")
client = redis.Redis()
(not client.ping(), "Redis not available")
yield client
2. conftest 隔离
2.1 conftest 查找机制
pytest 按照以下顺序查找 conftest.py:1. 测试类内部(不推荐)→ 2. 测试模块所在目录 → 3. 父目录 → 一直向上到 rootdir。找到的第一个 conftest.py 的 fixture 会被加载。
# 目录结构示例
# tests/
# conftest.py <- 被所有测试加载
# unit/
# conftest.py <- 只被 unit/ 下测试加载
# test_math.py
# integration/
# conftest.py <- 只被 integration/ 下测试加载
# test_api.py
# conftest.py 内容自动对同级及子目录测试可用
# 无需 import,pytest 自动加载
2.2 fixture 名称冲突
当不同层级的 conftest.py 定义了同名 fixture 时,子目录的 fixture 会覆盖父目录的。但这种行为可能导致混淆,应尽量避免同名 fixture。
# tests/conftest.py
def db():
return "parent_db"
# tests/unit/conftest.py
def db():
return "unit_db"
# tests/unit/test_math.py
test_db(db):
print(db) # "unit_db" - 子目录覆盖了父目录
2.3 conftest 与插件
conftest.py 是 pytest 插件系统的核心。可以通过 pytest_configure 和 pytest_collection_modifyitems 钩子实现测试收集的自定义逻辑。
# tests/conftest.py
# 在测试收集完成后修改测试项
def pytest_collection_modifyitems(config, items):
for item in items:
# 为所有异步测试添加 asyncio 标记
if asyncio.iscoroutinefunction(item.function):
item.add_marker(pytest.mark.asyncio)
# 配置 hook
def pytest_configure(config):
config.addinivalue_line("markers", "slow: marks tests as slow")
config.addinivalue_line("markers", "integration: marks tests as integration tests")
# session 级别的 hook
def pytest_sessionstart(session):
print("Test session starting...")
def pytest_sessionfinish(session, exitstatus):
print(f"Test session finished with exit status {exitstatus}")
3. parametrize 组合
3.1 基础 parametrize
@pytest.mark.parametrize 是 pytest 最强大的功能之一,它允许定义测试参数的笛卡尔积,每个组合独立运行。
("input,expected", [
(1, 2),
(2, 4),
(3, 6),
])
test_double(input, expected):
assert input * 2 == expected
# 运行结果:3 个测试用例
# test_double[1-2] PASSED
# test_double[2-4] PASSED
# test_double[3-6] PASSED
3.2 多参数组合
多个 parametrize 标记会产生笛卡尔积——每个参数的每个值都会与其他参数的每个值组合。
("x", [1, 2])
("y", ["a", "b", "c"])
test_xy(x, y):
pass
# 生成 2 * 3 = 6 个测试用例:
# test_xy[1-a] test_xy[1-b] test_xy[1-c]
# test_xy[2-a] test_xy[2-b] test_xy[2-c]
# 命名组合使用 ids
("x,y", [
(1, "a"),
(2, "b"),
], ids=["case_small", "case_large"])
test_named(x, y):
pass
# 生成: test_named[case_small] test_named[case_large]
3.3 依赖参数与 indirect
indirect=True 允许 parametrize 值作为 fixture 参数传递,实现参数与 fixture 之间的联动。
# 当 parametrize 值是 "db",pytest 会查找名为 "db" 的 fixture
def db(request):
# request.param 接收 parametrize 传来的值
db_type = request.param
return Database(db_type)
("db", ["sqlite", "postgres"], indirect=True)
test_db_query(db):
# db fixture 会根据参数创建不同类型的数据库
result = db.query("SELECT 1")
assert result == [1]
4. pytest-xdist 并行
4.1 xdist 执行模型
pytest-xdist 通过 -n auto 或 -n NUM 参数启动多个 worker 进程。支持两种模式:forked(默认)和 spawn。
# 安装
# pip install pytest-xdist
# 使用 CPU 核数作为 worker 数
pytest -n auto
# 指定 4 个 worker
pytest -n 4
# 在模块级别设置(pytest.ini 或 pyproject.toml)
# addopts = -n auto
# 只运行空闲的 worker
pytest -n auto --maxprocesses 8
4.2 fixtures 与并行兼容性
并行执行时,session 和 module 作用域的 fixture 会被所有 worker 共享(通过 pytest 的 session manager)。但 function 作用域的 fixture 会在每个 worker 进程中独立创建。
# session 作用域 fixture - 所有 worker 共享
(scope="session")
def shared_config():
# 只创建一次,所有 worker 复用
return Config.load()
# module 作用域 fixture - 每个 worker 模块独立
(scope="module")
def worker_module_db():
# 每个 worker 进程创建一次
return TestDatabase()
# function 作用域 fixture - 每个测试函数独立
def test_data():
# 每个测试函数执行一次
return generate_test_data()
4.3 并行化注意事项
并行执行时需要特别注意:测试隔离(无共享状态)、端口冲突(多个 worker 绑定同一端口)、文件竞争(多个进程写入同一文件)。
| 问题 | 解决方案 |
|---|---|
| 共享状态污染 | 使用 function-scoped fixture,避免全局变量 |
| 端口冲突 | 使用 pytest-xdist 的 --dist loadscope |
| 数据库竞争 | 每个测试使用独立数据库 schema 或使用事务回滚 |
| 日志文件竞争 | 日志文件名包含 worker ID:log_{worker_id}.txt |
并行执行时,setup_class 和 teardown_class 只在 module 的第一个测试运行前/最后测试运行后执行。如果 class 中的测试依赖 setup_class 的副作用,并行化可能破坏这种依赖。
5. 覆盖率优化
5.1覆盖率配置
pytest-cov 是 pytest 的官方 coverage 插件。--cov 参数指定要分析的包,--cov-report 控制报告格式。
# pip install pytest-cov coverage
# 运行覆盖率测试
pytest --cov=mypackage --cov-report=html
# 多格式报告
pytest --cov=mypackage \
--cov-report=html \
--cov-report=term-missing \
--cov-report=xml
# 结合并行执行
pytest -n auto --cov=mypackage --cov-report=
5.2 并行覆盖率收集
pytest-cov 与 pytest-xdist 结合时,需要使用 --dist=loadfile 或 --dist=loadscope 来确保覆盖率数据正确合并。
# 推荐:使用 loadfile 分发策略
# 同一文件的所有测试在同一 worker 中运行
pytest -n auto --cov=mypackage --dist=loadfile
# coverage 配置文件 .coveragerc
# [run]
# source = mypackage
# parallel = True # 启用并行模式
# [report]
# exclude_lines =
# pragma: no cover
# def __repr__
# raise NotImplementedError
5.3 覆盖率优化策略
✅ 提升覆盖率的最佳实践
- 使用
@pytest.mark.skip(reason="...")标记无法覆盖的代码 - 使用
pragma: no cover排除不可能到达的分支 - 将 I/O 操作抽象为可 mock 的接口
- 使用
--cov-branch启用分支覆盖分析
❌ 覆盖率误区
- 100% 覆盖率 ≠ 高质量测试
- 追求覆盖率可能导致低价值测试(只触发 if 分支)
- 忽略边界条件和错误处理路径
- 第三方库调用难以覆盖,应使用 mock
# 启用分支覆盖
# coverage run --branch tests/
# 代码示例
def process(value):
if value > 0: # Branch: value>0 True/False
return value * 2
else:
return value / 2 # 需要测试 value<=0 分支
# 结合 pytest-cov
# pytest --cov=mypackage --cov-branch --cov-report=term-missing
覆盖率是辅助指标,不是目标。有效的测试策略应该关注:边界条件、错误处理路径、依赖 mock、以及测试的执行速度。并行化 + 适当的 fixture 设计可以将测试套件执行时间从分钟级缩短到秒级。