本节目标:让一套 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-cov | 7.1.0 | 覆盖率与门禁 |
pytest-asyncio | 1.4.0 | async def 测试 |
pytest-benchmark | 5.3.0 | 性能回归基准 |
hypothesis | 6.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 测试与质量工程 。
阅读导航:上一节:指标、健康检查与告警接入 · 下一节:集成测试与容器化测试环境 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。