《Python编程实战》4.1 pytest 工程化:fixture 分层与插件

把 pytest 从「能跑」推到「可维护」:conftest.py 向上合并与就近覆盖、fixture 作用域与依赖注入、工厂型 fixture 避免共享可变状态,再把约定写进 pyproject.toml,最后用一个本地 conftest 插件和实测插件清单收尾。

本节目标:让一套 pytest 用例在半年后仍可维护——用分层 conftest 共享前置、用作用域控制成本、用配置固定约定、用插件补齐能力。
适用版本:Python 3.12+(实测 3.14.6);pytest 9.1.1

4.1 pytest 工程化:fixture 分层与插件

前一章(指标、健康检查与告警接入 )谈的是「把服务跑起来并看得见」。服务一旦上线,改动就会源源不断;能不能放心改,取决于测试体系拦不拦得住。本节先把最底层的 pytest 用「工程化」的方式搭好:fixture 怎么分层、作用域怎么选、配置放哪、插件怎么接。下一节再讲集成测试,第三节讲属性测试与覆盖率门禁。

本节全部结论来自一个真实落盘的小项目,源码与测试在 /tmp 下跑通,命令与输出均实测。

4.1.1 一个可维护测试套件的四个痛点

单文件、几个 assert 的测试谁都会写。规模上来之后,退化几乎总发生在四个地方:

痛点典型症状工程化手段
前置代码重复每个测试自己建 Cart、连库、造数据fixture 分层
资源反复创建每个测试都重连数据库、重载模型fixture 作用域
共享状态串味测试 A 改的对象影响测试 B工厂型 fixture
约定靠口头有人用 --strict-markers,有人不用配置固化

下面按这四条依次解决。

4.1.2 conftest.py 的分层发现

pytest 的 fixture 不必写在测试文件里。放进 conftest.py,它会沿目录从根到测试文件逐级收集并合并。实测项目结构:

04/
├── conftest.py              # app_config(session 级)+ 本地插件钩子
├── pyproject.toml           # [tool.pytest.ini_options]
├── app/
│   └── pricing.py
└── tests/
    ├── conftest.py          # cart / filled_cart
    ├── unit/
    │   ├── conftest.py      # cart(覆盖上层同名 fixture)
    │   └── test_pricing.py
    └── integration/
        └── conftest.py      # engine / connection / session

根 conftest.py 只放真正全局的东西——一个会话级配置和一个钩子:

# conftest.py(仓库根)
import pytest


@pytest.fixture(scope="session")
def app_config():
    """整个测试会话共享的配置。"""
    return {"env": "test", "currency": "CNY"}

tests/conftest.py 放项目级 fixture:

# tests/conftest.py
import pytest

from app.pricing import Cart


@pytest.fixture
def cart() -> Cart:
    return Cart()


@pytest.fixture
def filled_cart() -> Cart:
    c = Cart()
    c.add("apple", 500, 2)   # 1000
    c.add("banana", 250, 4)  # 1000
    return c

规则是向上合并、就近覆盖:越靠近测试文件的 conftest.py 优先级越高。tests/unit/conftest.py 重新定义同名 cart,就会盖掉 tests/conftest.py 的版本:

# tests/unit/conftest.py
import pytest


@pytest.fixture
def cart():
    from app.pricing import Cart
    c = Cart()
    c.add("marker", 1, 1)
    return c

于是 tests/unit/ 下的用例拿到的是带 marker 商品的车,而 tests/integration/ 下仍用项目级 cart。实测这两个断言都通过:

def test_cart_fixture_is_overridden(cart):
    assert cart.items[0].sku == "marker"      # 就近覆盖生效

def test_filled_cart(filled_cart):
    assert filled_cart.subtotal() == 2000     # 上层 fixture 未被影响

这条机制的价值:公共前置放在 tests/conftest.py,某个子目录需要特殊版本时只在该子目录覆盖,不必污染全局。跨书对照可看《Python编程入门》14.2 节 。

4.1.3 fixture 作用域:一个 fixture 跑几次

scope 决定生命周期,取值四个:function(默认,每个测试)、class、module(每个文件)、session(整个会话)。口说无凭,三个 fixture 各打印 setup/teardown,跑两个测试:

@pytest.fixture(scope="session")
def session_res():
    print("\n[setup] session")
    yield "S"
    print("\n[teardown] session")


@pytest.fixture(scope="module")
def module_res():
    print("[setup] module")
    yield "M"
    print("[teardown] module")


@pytest.fixture(scope="function")
def func_res():
    print("[setup] function")
    yield "F"
    print("[teardown] function")


def test_a(session_res, module_res, func_res):
    assert (session_res, module_res, func_res) == ("S", "M", "F")


def test_b(session_res, module_res, func_res):
    assert func_res == "F"

pytest tests/test_scope.py -s 的真实输出(去掉首尾多余空行):

[setup] session
[setup] module
[setup] function
.[teardown] function
[setup] function
.[teardown] function
[teardown] module
[teardown] session

结论一眼可辨:session 与 module 各只 setup 一次,function 每个测试都重建。选作用域就是选「创建成本 vs 隔离程度」的平衡:

scope创建次数适合
function每测试一次可变对象、临时数据
module每文件一次只读夹具、大 fixture 复用
session全会话一次数据库引擎、容器、模型加载

yield 之前是 setup、之后是 teardown;即使测试失败,控制权也会回到 yield 之后——这是它比 return 强的唯一理由。

4.1.4 工厂型 fixture:别把可变对象共享出去

scope 越宽越省,但可变对象绝不能跨测试共享。把 Cart 做成 session 级,前一个测试塞进去的商品就会漏给后一个。正确做法是「给工厂,不给产品」:

@pytest.fixture
def make_cart():
    """fixture 工厂:调用一次造一个独立 Cart。"""
    def _make(*items: tuple[str, int, int]) -> Cart:
        c = Cart()
        for sku, price, qty in items:
            c.add(sku, price, qty)
        return c
    return _make


def test_factory_isolated(make_cart):
    a = make_cart(("x", 100, 1))
    b = make_cart(("y", 200, 2))
    assert a.subtotal() == 100
    assert b.subtotal() == 400
    assert a.items is not b.items      # 两个实例互不影响

工厂 fixture 本身是 function 级,但它产出的是「造对象的函数」,测试想造几个就造几个,且每个都是新的。数据准备逻辑集中在 _make 里,测试只声明「我要一辆装了什么的车」。

4.1.5 把约定写进 pyproject.toml

散落在命令行的开关迟早会被忘记。约定应固化进配置。pyproject.toml 的 [tool.pytest.ini_options]:

[tool.pytest.ini_options]
minversion = "9.0"
testpaths = ["tests"]
addopts = "-ra -q"
pythonpath = ["."]
markers = [
    "unit: 纯单元测试,无外部依赖",
    "integration: 需要数据库/网络等外部资源",
    "slow: 慢速用例,需 --runslow 才跑",
]
filterwarnings = ["error"]

逐条说明:

  • testpaths:不写路径时只扫这些目录,避免误收集 build/、venv/。
  • addopts:-ra 汇总非通过用例,-q 精简输出;不要把 -x、--pdb 塞进来,那会改变本地调试行为。
  • pythonpath:等价于在项目根注入 sys.path,省掉 conftest.py 里的 sys.path 手改。
  • markers:注册自定义标记。未注册的标记在 --strict-markers 下会直接报错,这是团队协作里最值得开的一道闸。
  • filterwarnings = ["error"]:把警告升级为错误。它抓过真实的资源泄漏——本项目里一条未关闭的 SQLite 连接曾因此暴露(见 4.2 节)。

等价的 pytest.ini 写法(二选一,同时存在会报错):

[pytest]
minversion = 9.0
testpaths = tests
addopts = -ra -q
markers =
    unit: 纯单元测试
    integration: 集成测试

4.1.6 插件:entry point 与本地区插件

pytest 的插件分两类。

第一类:通过 entry point 安装的第三方插件,装上即生效。实测环境的插件清单(pytest -v 头部自动打印):

platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
plugins: hypothesis-6.168.5, cov-7.1.0, asyncio-1.4.0,
         benchmark-5.3.0, time-machine-3.5.1, anyio-4.15.1

本书用到的主要是这几个:

插件实测版本作用
pytest-cov7.1.0覆盖率与门禁
pytest-asyncio1.4.0async def 测试
pytest-benchmark5.3.0性能回归基准
hypothesis6.168.5属性测试(自带 pytest 集成)

第二类:写在 conftest.py 里的本地插件,用钩子扩展行为,无需打包。例如给项目加一个 --runslow 开关:

# conftest.py(仓库根,同时也是一个本地插件)
def pytest_addoption(parser):
    parser.addoption(
        "--runslow", action="store_true", default=False,
        help="同时运行标记为 slow 的用例",
    )


def pytest_collection_modifyitems(config, items):
    if config.getoption("--runslow"):
        return
    skip_slow = pytest.mark.skip(reason="需要 --runslow")
    for item in items:
        if "slow" in item.keywords:
            item.add_marker(skip_slow)

默认运行会跳过 slow 用例,实测:

tests/test_plugin_demo.py s.
SKIPPED [1] tests/test_plugin_demo.py:4: 需要 --runslow
31 passed, 1 skipped

加 --runslow 后该用例正常执行。pytest_addoption / pytest_collection_modifyitems / pytest_configure / pytest_sessionfinish 这几个钩子覆盖了「加选项、改收集、注册标记、收尾统计」的绝大多数本地扩展需求。

4.1.7 一次完整运行

把以上拼起来,pytest -v 的真实结果:

rootdir: /private/tmp/python_book/scratch/04
configfile: pyproject.toml
testpaths: tests
plugins: hypothesis-6.168.5, cov-7.1.0, asyncio-1.4.0, benchmark-5.3.0, ...
collected 32 items

tests/integration/test_repo.py ...                                       [  9%]
tests/perf/test_bench.py ..                                              [ 15%]
tests/property/test_properties.py ......                                 [ 34%]
...
======================== 31 passed, 1 skipped in 3.16s =========================

参数化用例会被展开成独立条目,--collect-only 可核对 ID:

tests/unit/test_pricing.py::test_discount[0-2000]
tests/unit/test_pricing.py::test_discount[10-1800]
tests/unit/test_pricing.py::test_discount[100-0]

用标记切分运行范围也很直接——pytest -m integration 实测只跑集成用例。

小结

  • conftest.py 向上合并、就近覆盖;公共前置放 tests/,特例只在该子目录覆盖。
  • scope 选的是「创建成本 vs 隔离」:引擎/容器用 session,可变对象用 function。
  • 可变对象一律用工厂型 fixture 产出,绝不跨测试共享实例。
  • 约定固化进 [tool.pytest.ini_options]:testpaths、markers、filterwarnings = ["error"] 收益最高。
  • 插件分 entry point(第三方,装上即用)与 conftest 本地插件(钩子扩展)两类。

到这里,测试跑起来了,但全是「不碰外部依赖」的单元测试。真实系统里最难的 bug 往往藏在「应用与数据库/缓存之间」——下一节把集成测试和容器化测试环境接上。

延伸阅读:Python 测试与质量工程 。

阅读导航:上一节:指标、健康检查与告警接入 · 下一节:集成测试与容器化测试环境 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时