第 8 章 · fixture 高级模式
本章目标:掌握"工厂 fixture"、
request对象、fixture 自身参数化与usefixtures标记,能够为任意复杂度的资源编写可复用的测试基础设施。
8.1 工厂 fixture:一次注入,多次生成
第 7 章里每个 fixture 只产生一个对象。但真实场景常常是:一个测试里需要创建多个同类资源——比如给购物车加三件商品、给群组拉三个用户。直接把 fixture 写死成单个返回值就不够用了。
官方推荐的解法是 factory as fixture(工厂即夹具)模式:fixture 不返回数据,而是返回一个生成数据的函数:
import pytest
@pytest.fixture
def make_customer_record():
# 返回的不是记录本身,而是"造记录"的工厂函数
def _make_customer_record(name):
return {"name": name, "orders": []}
return _make_customer_record
def test_customer_records(make_customer_record):
c1 = make_customer_record("Lisa")
c2 = make_customer_record("Mike")
c3 = make_customer_record("Meredith")
assert {c["name"] for c in (c1, c2, c3)} == {"Lisa", "Mike", "Meredith"}工厂函数可以带任意参数,还能配合 yield 做批量清理——这是该模式最实用的形态:
@pytest.fixture
def make_customer_record():
created_records = []
def _make(name):
record = {"name": name, "orders": []}
created_records.append(record) # 记录所有创建过的对象
return record
yield _make # 测试期间持续提供工厂
for record in created_records: # teardown 阶段统一销毁
record.clear()
def test_orders(make_customer_record):
rec = make_customer_record("Alice")
rec["orders"].append({"sku": "A1", "qty": 2})
assert len(rec["orders"]) == 1
# 测试结束后所有由工厂创建的记录都会被清理记忆口诀
"要一个用 fixture,要多个用 factory"。工厂模式把"造多少个"的决定权交还给测试函数,同时清理逻辑仍集中在 fixture 里。
8.2 request 对象:让 fixture 感知请求上下文
fixture 函数可以声明一个特殊参数 request,它是 pytest 注入的请求上下文对象。常用属性:
| 属性 | 含义 |
|---|---|
request.param | fixture 参数化时当前参数值(见 8.3) |
request.module | 发起请求的测试模块 |
request.cls / request.instance | 请求所在的测试类/实例 |
request.function | 请求的测试函数 |
request.addfinalizer(fn) | 注册 teardown 回调 |
request.getfixturevalue("name") | 按名字动态获取其他 fixture |
一个经典用法是从测试模块读取配置,让同一 fixture 服务不同配置的测试:
# content of conftest.py
import smtplib
import pytest
@pytest.fixture(scope="module")
def smtp_connection(request):
# 从使用方模块读取 smtpserver 属性,没有就用默认值
server = getattr(request.module, "smtpserver", "smtp.gmail.com")
conn = smtplib.SMTP(server, 587, timeout=5)
yield conn
print(f"finalizing {conn} ({server})")
conn.close()# content of test_anothersmtp.py
smtpserver = "mail.python.org" # 会被 conftest 里的 fixture 读取
def test_showhelo(smtp_connection):
code, _ = smtp_connection.noop()
assert code == 250getfixturevalue 则支持运行期动态决定依赖哪个 fixture(普通参数写法要求依赖在收集期就确定):
@pytest.fixture
def driver(request):
browser = getattr(request.module, "BROWSER", "chromium")
# 动态获取同名的 fixture(需事先定义 chromium/firefox fixture)
impl = request.getfixturevalue(browser)
yield impl
impl.quit()8.3 fixture 自身参数化:一套测试 × N 种实现
第 5 章我们参数化了测试函数;同样地,fixture 也可以参数化——通过 @pytest.fixture(params=[...]) 声明,每个参数值都会让 fixture 执行一次,所有依赖它的测试自动重跑一遍。参数值通过 request.param 获取:
# content of conftest.py
import pytest
@pytest.fixture(params=["sqlite", "postgres"])
def db(request):
# 每种数据库各执行一次,request.param 是当前参数值
if request.param == "sqlite":
engine = FakeSQLiteEngine()
else:
engine = FakePostgresEngine()
yield engine
engine.drop_all()
class FakeSQLiteEngine:
def query(self, sql):
return [{"id": 1}]
def drop_all(self): ...
class FakePostgresEngine(FakeSQLiteEngine):
pass# content of test_repo.py
def test_find_user(db):
rows = db.query("SELECT * FROM users")
assert rows[0]["id"] == 1运行结果会生成两个测试项:test_find_user[sqlite] 和 test_find_user[postgres]。测试代码一行未改,就完成了"业务逻辑在两种存储上行为一致"的验证——这正是 fixture 参数化相对测试函数参数化的独特价值:它天然面向"多实现同一契约"的场景。
测试 ID 默认取参数值的字符串表示,可以用 ids 定制,也可以用 pytest.param 给个别参数打 mark:
@pytest.fixture(
params=[
"v1", # 正常跑
pytest.param("legacy", marks=pytest.mark.xfail(reason="旧格式不再支持")),
],
ids=["current", "old"],
)
def api_version(request):
return request.param8.4 usefixtures:不打扰函数签名的依赖声明
有时测试只是需要某个副作用(比如切到空目录),并不想接收 fixture 的返回值。这时可以在函数签名里塞一个不用的参数,也可以更优雅地用 usefixtures 标记:
# content of conftest.py
import os
import tempfile
import pytest
@pytest.fixture
def cleandir():
with tempfile.TemporaryDirectory() as newpath:
old_cwd = os.getcwd()
os.chdir(newpath)
yield
os.chdir(old_cwd)# content of test_setenv.py
import os
import pytest
@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit:
def test_cwd_starts_empty(self):
assert os.listdir(os.getcwd()) == []
with open("myfile", "w", encoding="utf-8") as f:
f.write("hello")
def test_cwd_again_starts_empty(self):
assert os.listdir(os.getcwd()) == []usefixtures 可以叠加多个 fixture(@pytest.mark.usefixtures("a", "b")),也可以在模块级用 pytestmark = pytest.mark.usefixtures("cleandir") 对整个文件生效,甚至写进配置文件的 usefixtures 选项对整个项目生效。
usefixtures 的两个限制
- 它不能用在 fixture 函数上——fixture 之间的依赖只能通过参数签名声明;
- 因为拿不到返回值,需要 fixture 数据时必须走参数注入。
8.5 本章小结
- 工厂 fixture 返回"生成函数",适合一测多用 + 统一清理的场景;
request对象提供param/module/function/addfinalizer/getfixturevalue等上下文能力;@pytest.fixture(params=...)让依赖它的全部测试按参数组合自动重跑,是验证"多实现同一契约"的利器;usefixtures适用于只要副作用不要返回值的场景,但不能用于 fixture 内部。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. "工厂 as fixture"模式下,fixture 应该返回什么?
2. 在 @pytest.fixture(params=["a", "b"]) 的 fixture 里,如何拿到当前参数值?
3. 关于 @pytest.mark.usefixtures,下列说法错误的是?
4. 想在 fixture 运行期根据条件动态加载另一个 fixture,应该用?
🛠️ 动手实践
- 为一个假的
UserRepository编写工厂 fixturemake_users:能按需创建 N 个用户并在 teardown 时统一清空仓库。 - 用 fixture 参数化让同一个
test_stack_behavior同时跑在list和collections.deque两种实现上。 - 把本章
cleandirfixture 改造成"临时目录 + 还原工作目录",并分别用参数注入和usefixtures两种方式各写一个测试,体会两者的差异。
下一章我们把 pytest 内置的"瑞士军刀"——
tmp_path、capsys、caplog、monkeypatch、cache一网打尽:第 9 章。