第 15 章 · 参数化高级技巧
本章目标:掌握
indirect参数化、多重参数化叠加、自定义测试 ID 与动态生成参数这四个进阶武器,并学会在「穷举式参数化」与「属性基测试」之间做选型。
15.1 indirect:让参数先经过 fixture 加工
第 5 章学过,@pytest.mark.parametrize 会把参数值直接传给测试函数。但很多场景下,参数不是"直接用的值",而是构造资源的配方:比如同一个测试要分别跑在 SQLite 和 PostgreSQL 上,参数只是数据库名,真正传给测试的应该是建好的连接对象。
这时给 indirect=True,pytest 会把参数值交给同名的 fixture 处理,fixture 通过 request.param 拿到原始参数,加工后返回:
# test_indirect.py
import pytest
@pytest.fixture
def db_conn(request):
"""接收参数化传入的数据库名,返回对应的连接对象(此处用假对象模拟)。"""
db_name = request.param # 拿到 parametrize 传来的原始参数
conn = FakeConnection(db_name) # 真实项目中这里是创建连接/启动容器等重活
yield conn # teardown:测试结束后关闭
conn.close()
class FakeConnection:
def __init__(self, name):
self.name = name
self.closed = False
def close(self):
self.closed = True
@pytest.mark.parametrize("db_conn", ["sqlite", "postgres"], indirect=True)
def test_query_works(db_conn):
# 这里拿到的是 fixture 返回的对象,而不是字符串本身
assert db_conn.name in ("sqlite", "postgres")
assert not db_conn.closed运行时每个参数都会触发一次完整的 fixture 建立/销毁流程,测试 ID 仍是 ["sqlite"]、["postgres"]。三个关键点:
- fixture 的名字必须与
parametrize("db_conn", ...)中一致; - fixture 内用
request.param读取参数; indirect也可以只对部分参数生效:indirect=["db_conn"](多参数时)。
为什么不直接在测试里 if/else
把"资源怎么造"留在 fixture、"行为怎么验"留在测试里,正是第 6 章 DI 思想的延续:同一份断言代码可以无痛切换底层实现。
15.2 叠加多个 parametrize:笛卡尔积
一个函数上叠两个 parametrize 时,参数组合是笛卡尔积:
# test_cartesian.py
import pytest
@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_double_param(x, y):
# 将产生 x×y = 4 个用例:
# [0-2] [0-3] [1-2] [1-3]
assert x + y > -1叠加时还可以用 pytest.param 给个别组合打标记或改 ID:
# test_known_bug.py
import pytest
@pytest.mark.parametrize("browser", ["chromium", "firefox"])
@pytest.mark.parametrize(
"viewport",
[
"desktop",
pytest.param(
"mobile",
marks=pytest.mark.xfail(
reason="移动端弹窗遮挡按钮,已知缺陷 #1024"
),
id="mobile-known-bug",
),
],
)
def test_login_button_visible(browser, viewport):
assert check_button(browser, viewport)
def check_button(browser, viewport):
return viewport != "mobile-known-bug"这样"已知失败的组合"被显式登记为 xfail,修复后删除标记即可,不会污染其他组合的结果。注意:装饰器离函数越近的参数变化越快(内层是"快轴"),安排轴的顺序可以影响失败信息的可读性。
15.3 自定义测试 ID:ids 与 idfn
默认 ID 是参数 repr,遇到复杂对象会变成 conn0、conn1 这种没有信息量的名字。两种解法:
# test_ids.py
import pytest
CASES = [
("admin", {"read", "write"}),
("guest", {"read"}),
]
def id_fn(val):
"""id 函数:逐个接收参数值,返回可读字符串;返回 None 则用默认 ID。"""
if isinstance(val, set):
return "+".join(sorted(val))
return str(val)
@pytest.mark.parametrize("role,perms", CASES, ids=id_fn)
def test_permissions(role, perms):
...也可以在单个 pytest.param 上指定 id=(见 15.2),或者直接传字符串列表 ids=["admin-rw", "guest-r"]。ID 不只是好看——它们配合 -k 表达式做精准筛选:
$ pytest -k "guest" # 只跑 ID 含 guest 的用例
$ pytest --collect-only -q # 预览全部生成的用例 ID注意
ids 列表长度必须与参数个数一致,否则 pytest 报错;idfn 返回 None 表示"这个值用默认 ID",这是它比字符串列表灵活的地方。
15.4 对类和模块整体参数化
parametrize 是普通 marker,可以直接贴在测试类上,类里所有方法都获得参数:
# test_class_level.py
import pytest
@pytest.mark.parametrize("n,expected", [(1, 1), (2, 4), (3, 9)])
class TestSquare:
def test_value(self, n, expected):
assert n ** 2 == expected
def test_type(self, n, expected):
assert isinstance(n ** 2, int)上面会产生 6 个用例(3 组参数 × 2 个方法),适合"同一组输入要过多个不变量"的场景。
需要更复杂的动态逻辑(比如根据命令行选项决定参数范围)时,用 pytest_generate_tests 钩子:
# conftest.py 或测试模块中
def pytest_generate_tests(metafunc):
"""收集阶段为包含 target 参数的测试动态生成参数。"""
if "target" in metafunc.fixturenames:
targets = ["fast", "slow"] if metafunc.config.getoption("--all") else ["fast"]
metafunc.parametrize("target", targets)metafunc.fixturenames 告诉你测试需要哪些参数,metafunc.parametrize 在收集期就展开用例——这是所有静态写法都做不到的终极灵活性(第 16 章会把它与插件钩子串起来讲)。
15.5 参数化 vs 属性基测试:怎么选
| 维度 | parametrize | Hypothesis(第 18 章) |
|---|---|---|
| 输入来源 | 人手工枚举 | 按策略随机生成 |
| 擅长 | 已知边界、回归用例、规格表 | 发现未知边界(如空串、超大数) |
| 可复现性 | 完全确定 | 失败后固定反例并可复现 |
| 失败定位 | 一眼看到哪组参数挂了 | 需要 shrinking 缩小反例 |
实践建议:两者互补。用参数化固化"已知必须成立的例子",用属性基测试扫荡"你没想到的输入空间"。例如排序函数既要有 (输入,期望输出) 规格表,也要有"sorted(x) 结果长度不变且有序"这样的性质测试。
本章小结
indirect=True让参数走同名 fixture 加工,request.param取原始值;- 多个
parametrize叠加产生笛卡尔积,pytest.param可单独打标/改名; ids列表或idfn函数生成可读 ID,配合-k精准筛选;- marker 贴在类上可整类参数化;
pytest_generate_tests(metafunc)支持收集期动态展开; - 参数化管已知例,属性基测试探未知域,二者互补而非替代。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. 使用 indirect=True 后,fixture 如何读取 parametrize 传入的原始参数?
2. 一个函数叠加两个 parametrize(各 3 个值),总共生成多少个用例?
3. 关于 ids=id_fn 中的 id_fn,下列说法正确的是?
4. 想根据命令行选项在"收集阶段"动态决定某测试的参数范围,应该使用?
🛠️ 动手实践
- 为一个
send_email(to, subject)函数写间接参数化测试:to参数经 fixture 校验格式后才注入,非法地址的用例用pytest.param+ 自定义异常断言处理。 - 把第 5 章某个参数化测试改造为带
idfn的版本,并用--collect-only -q对比改造前后的用例 ID。 - 编写
pytest_generate_tests:当命令行含--full-matrix时输出 3×3 全组合,否则只跑对角线上的 3 个组合。
下一章我们把今天见到的钩子正式升级成自定义插件。