📅 2026-05-26 👤 刘洋 🏷️ pytest · fixture · xdist

Python 测试策略:pytest fixture 作用域与 pytest-xdist 并行化

深入解析 pytest fixture 生命周期、conftest 隔离机制、parametrize 参数组合、pytest-xdist 并行执行,以及测试覆盖率优化策略。

1. fixture 生命周期

1.1 作用域层级

pytest fixture 支持四种作用域(scope):function(默认)、classmodulesession。作用域决定了 fixture 的创建和销毁时机。

fixture 作用域示例
import pytest

# function 作用域:每个测试函数执行一次
@pytest.fixture
def function_scoped():
    print("Created")
    yield
    print("Destroyed")

# module 作用域:整个模块只执行一次
@pytest.fixture(scope="module")
def module_scoped():
    print("Module fixture created")
    yield
    print("Module fixture destroyed")

# session 作用域:整个测试会话只执行一次
@pytest.fixture(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)。

fixture 依赖与销毁顺序
@pytest.fixture
def base():
    print("1-base created")
    yield
    print("1-base destroyed")

@pytest.fixture
def derived(base):
    print("2-derived created")
    yield
    print("2-derived destroyed")

@pytest.fixture
def top(derived):
    print("3-top created")
    yield
    print("3-top destroyed")

# 测试函数使用 top
def 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.skipifpytest.importorskip 可实现条件 fixture。

autouse 与条件执行
# autouse fixture:自动为所有测试创建数据库事务回滚
@pytest.fixture(autouse=True)
def db_transaction(db_connection):
    # 每个测试开始时开启事务
    transaction = db_connection.begin()
    yield
    # 测试结束后回滚,不污染数据库
    transaction.rollback()

# 条件 fixture:仅当特定模块可用时
@pytest.fixture
def redis_client():
    redis = pytest.importorskip("redis")
    client = redis.Redis()
    pytest.skipif(not client.ping(), "Redis not available")
    yield client

2. conftest 隔离

2.1 conftest 查找机制

pytest 按照以下顺序查找 conftest.py:1. 测试类内部(不推荐)→ 2. 测试模块所在目录 → 3. 父目录 → 一直向上到 rootdir。找到的第一个 conftest.py 的 fixture 会被加载。

conftest 查找路径
# 目录结构示例
# 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。

fixture 覆盖示例
# tests/conftest.py
@pytest.fixture
def db():
    return "parent_db"

# tests/unit/conftest.py
@pytest.fixture
def db():
    return "unit_db"

# tests/unit/test_math.py
def test_db(db):
    print(db)  # "unit_db" - 子目录覆盖了父目录

2.3 conftest 与插件

conftest.py 是 pytest 插件系统的核心。可以通过 pytest_configurepytest_collection_modifyitems 钩子实现测试收集的自定义逻辑。

conftest 钩子函数
# 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 最强大的功能之一,它允许定义测试参数的笛卡尔积,每个组合独立运行。

基础 parametrize
@pytest.mark.parametrize("input,expected", [
    (1, 2),
    (2, 4),
    (3, 6),
])
def 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 标记会产生笛卡尔积——每个参数的每个值都会与其他参数的每个值组合。

多参数笛卡尔积
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", ["a", "b", "c"])
def 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
@pytest.mark.parametrize("x,y", [
    (1, "a"),
    (2, "b"),
], ids=["case_small", "case_large"])
def test_named(x, y):
    pass

# 生成: test_named[case_small] test_named[case_large]

3.3 依赖参数与 indirect

indirect=True 允许 parametrize 值作为 fixture 参数传递,实现参数与 fixture 之间的联动。

indirect parametrize
# 当 parametrize 值是 "db",pytest 会查找名为 "db" 的 fixture
@pytest.fixture
def db(request):
    # request.param 接收 parametrize 传来的值
    db_type = request.param
    return Database(db_type)

@pytest.mark.parametrize("db", ["sqlite", "postgres"], indirect=True)
def 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

pytest-xdist 基本用法
# 安装
# 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 进程中独立创建。

并行执行下的 fixture 行为
# session 作用域 fixture - 所有 worker 共享
@pytest.fixture(scope="session")
def shared_config():
    # 只创建一次,所有 worker 复用
    return Config.load()

# module 作用域 fixture - 每个 worker 模块独立
@pytest.fixture(scope="module")
def worker_module_db():
    # 每个 worker 进程创建一次
    return TestDatabase()

# function 作用域 fixture - 每个测试函数独立
@pytest.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_classteardown_class 只在 module 的第一个测试运行前/最后测试运行后执行。如果 class 中的测试依赖 setup_class 的副作用,并行化可能破坏这种依赖。

5. 覆盖率优化

5.1覆盖率配置

pytest-cov 是 pytest 的官方 coverage 插件。--cov 参数指定要分析的包,--cov-report 控制报告格式。

pytest-cov 基本配置
# 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 设计可以将测试套件执行时间从分钟级缩短到秒级。