pytest 的参数化怎么用?parametrize 有哪些进阶技巧?
简化版
@pytest.mark.parametrize 让「一个测试函数 × 多组数据」变成「多个独立的测试用例」——这是它和「在测试里写 for 循环」的本质区别:参数化的每组数据是独立用例,一组失败不影响其他组继续跑,失败报告能精确指出是哪组数据出错;而 for 循环里第一次断言失败就整个测试终止,后面的数据根本没验证。基本用法是 @pytest.mark.parametrize("a,b,expected", [(1,2,3), (0,0,0)]),参数名可以用逗号分隔的字符串或列表。四个进阶技巧:① ids= 给每组起可读的名字(默认生成的 test_x[a0-b1] 很难看出是哪组,出错时定位困难);② pytest.param(..., marks=pytest.mark.xfail) 给单组数据加标记(跳过、预期失败);③ 多个 parametrize 叠加会产生笛卡尔积(2 组 × 3 组 = 6 个用例,适合测组合但容易爆炸);④ 间接参数化 indirect=True 把参数传给 fixture 而不是测试函数,用来参数化「测试环境」而不是「测试数据」。还有两个容易混淆的点:fixture 也能参数化(@pytest.fixture(params=[...]),它会让所有依赖这个 fixture 的测试都跑多遍,适合「同一套测试跑在多种后端上」);以及参数化的数据必须是「构造成本低且可重复」的——在 parametrize 里调用数据库或随机函数是反模式,因为它在收集阶段就会执行。核心记忆:参数化 = 多个独立用例,for 循环 = 一个用例;用 ids 让报告可读;多个 parametrize 是笛卡尔积;indirect=True 参数化 fixture。
详细版
参数化 vs 循环 vs 多个测试函数:
parametrize | for 循环 | 多个函数 | |
|---|---|---|---|
| 用例数 | N 个独立用例 | 1 个 | N 个 |
| 一组失败 | 其他继续跑 | 整体终止 | 其他继续 |
| 报告定位 | 精确到某组 | 只知道函数失败 | 精确 |
| 代码量 | 少 | 少 | 多(重复) |
| 适用 | 同逻辑多数据 | 几乎不用 | 逻辑不同 |
import pytest
# ① ★基本用法★
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_add(a, b, expected):
assert add(a, b) == expected
# ★运行结果:3 个独立用例★
# test_add[1-2-3] PASSED
# test_add[0-0-0] PASSED
# test_add[-1-1-0] PASSED
# ★参数名的三种写法★
@pytest.mark.parametrize("a,b", [(1,2)]) # ★逗号分隔字符串★
@pytest.mark.parametrize(["a", "b"], [(1,2)]) # 列表
@pytest.mark.parametrize("value", [1, 2, 3]) # ★单参数不用元组★
# ② ★★ids:让报告可读(最实用的技巧)★★
@pytest.mark.parametrize("email,valid", [
("a@b.com", True),
("no-at-sign", False),
("@nolocal.com", False),
("a@", False),
], ★ids=["正常邮箱", "缺@符号", "缺用户名", "缺域名"]★)
def test_email_validation(email, valid):
assert is_valid_email(email) is valid
# → test_email_validation[缺@符号] FAILED ★一眼看出是哪个场景★
# ★ids 也可以是函数★
@pytest.mark.parametrize("user", [User(1, "admin"), User(2, "guest")],
ids=lambda u: f"{u.role}-{u.id}")
def test_perm(user): ...
# ③ ★★pytest.param:给单组数据加标记★★
@pytest.mark.parametrize("n,expected", [
(1, 1),
(2, 2),
★pytest.param(0, 0, marks=pytest.mark.xfail(reason="已知 bug #123"))★,
★pytest.param(-1, 0, marks=pytest.mark.skip(reason="需求未定"))★,
★pytest.param(10**9, 10**9, marks=pytest.mark.slow)★,
pytest.param(3, 3, id="正整数"), # ★也能单独给 id★
])
def test_process(n, expected): ...
# ④ ★★多个 parametrize:笛卡尔积★★
@pytest.mark.parametrize("browser", ["chrome", "firefox"])
@pytest.mark.parametrize("os", ["win", "mac", "linux"])
def test_compat(browser, os): ...
# ★→ 2 × 3 = 6 个用例★
# test_compat[win-chrome] test_compat[win-firefox] ...
# ★★注意:底部的装饰器先应用,所以 os 在 id 前面★★
# ⑤ ★★间接参数化:参数传给 fixture★★
@pytest.fixture
def db(request):
engine = create_engine(★request.param★) # ★★从参数拿值★★
yield engine
engine.dispose()
@pytest.mark.parametrize("db", ["sqlite:///:memory:", "postgresql://..."],
★indirect=True★)
def test_query(db): # ★db 是 fixture 的返回值★
assert db.execute(...).scalar() == 1
# ★部分间接★
@pytest.mark.parametrize("db,expected", [("sqlite:///:memory:", 1)],
indirect=["db"]) # ★★只有 db 走 fixture★★
def test_x(db, expected): ...
# ⑥ ★★参数化 fixture(另一种思路)★★
@pytest.fixture(★params=["sqlite", "postgres", "mysql"]★)
def backend(request):
return make_backend(request.param)
def test_insert(backend): ... # ★★自动跑 3 遍★★
def test_query(backend): ... # ★★也跑 3 遍★★
# ★区别:parametrize 只影响标记的测试;fixture params 影响所有用它的测试★
⚠️ 三个必须记住的点:① 参数化和「测试里写 for 循环」是本质不同的。
for case in cases: assert f(case.input) == case.expected只是一个测试用例——第一组数据断言失败就抛出AssertionError,后面的数据完全没有被验证;而且失败报告只会说「test_x失败了」,你得自己去看是哪组数据。参数化则生成 N 个彼此独立的用例:一组失败其他照常跑完,报告里精确显示test_x[缺@符号] FAILED,还能单独重跑某一组(pytest "test_x[缺@符号]")。②ids参数值得每次都写。pytest 默认会用参数值生成 id,但对于复杂对象会退化成test_x[user0]、test_x[user1]这种毫无信息量的名字——测试失败时你完全不知道是哪个场景挂了,只能数着序号回去对照代码。写上ids=["正常邮箱", "缺@符号", ...]之后,报告本身就是一份可读的规格说明。③parametrize的参数值在「收集阶段」就会被求值——所以里面不能放数据库查询、随机数、当前时间、或任何有副作用的调用。@pytest.mark.parametrize("user", User.objects.all())这种写法会在 pytest 收集测试时就连数据库(可能还没建表、fixture 也还没执行),而且每次运行的用例集都可能不同,--lf(重跑上次失败的)之类的功能会失效。参数化的数据应该是字面量或纯函数生成的确定值。
完整版教学
一、参数化的本质
★ ★三种写法的对比★:
# ① ★for 循环(★几乎总是错的★)★
def test_add():
cases = [(1,2,3), (0,0,0), (-1,1,0)]
for a, b, expected in cases:
assert add(a, b) == expected
★ ✗ ★第一组失败就终止,后两组没验证★
★ ✗ ★报告只说 test_add 失败★
★ ✗ ★无法单独重跑某组★
★ ✗ 无法给某组加 skip/xfail
# ② ★多个测试函数(重复)★
def test_add_positive(): assert add(1,2) == 3
def test_add_zero(): assert add(0,0) == 0
def test_add_negative(): assert add(-1,1) == 0
★ ✓ 独立用例
★ ✗ ★代码重复,加一组要复制一个函数★
# ③ ★★parametrize(正确)★★
@pytest.mark.parametrize("a,b,expected", [...], ids=[...])
def test_add(a, b, expected): assert add(a,b) == expected
★ ✓ ★独立用例 + 零重复 + 可单独控制★
★ ★pytest 内部做了什么★:
收集阶段(collection):
① 发现 test_add 函数
② ★读取 parametrize 标记★
③ ★为每组参数生成一个 "test item"★
→ test_add[1-2-3]、test_add[0-0-0]、test_add[-1-1-0]
④ ★这些 item 和普通测试函数完全平等★
执行阶段:逐个运行,各自独立的 setup/teardown
★ ★所以:每组都会重新执行 function 作用域的 fixture★
★ ★用例 id 的生成规则★:
★数字/字符串/布尔 → 直接用值★:test_x[1-abc-True]
★None → None★
★复杂对象 → 参数名+序号★:test_x[user0]、test_x[user1] ★← 没信息量★
★bytes → 转义★
★enum → 名字★
★ ★中文能用(pytest 会保留)★,但某些终端/CI 显示可能有问题
→ ★-k 筛选时也要能输入★
★ ★选择性运行★:
pytest "test_add[1-2-3]" # ★单组★
pytest -k "缺@符号" # ★按 id 关键字筛★
pytest -k "not slow"
pytest --collect-only # ★★先看会生成哪些用例★★
★ ★什么时候不该用参数化★:
✗ ★每组的断言逻辑不同★
→ 说明它们是不同的测试,该拆成不同函数
✗ ★参数超过 4~5 个★
→ 可读性崩溃,考虑用 dataclass 打包或拆分
✗ ★用例数上百★
→ 考虑 property-based testing(Hypothesis)
✓ ★同一逻辑、不同输入、相同的断言形式★
参数化和 for 循环的区别是「N 个独立用例」vs「1 个用例」——for 循环第一组失败就终止、报告没有定位信息、无法单独重跑、无法给某组加 skip/xfail。pytest 在收集阶段就为每组参数生成一个独立的 test item,这些 item 和普通测试函数完全平等,所以每组都会重新执行 function 作用域的 fixture。用例 id 的生成规则要知道:数字和字符串直接用值、复杂对象会退化成「参数名 + 序号」(没有信息量)。三种不该用参数化的情况:每组断言逻辑不同(那是不同的测试)、参数超过 4~5 个(可读性崩溃)、用例数上百(考虑 property-based testing)。
二、ids 与可读性
★ ★为什么 ids 这么重要★:
# ✗ 没有 ids
@pytest.mark.parametrize("data,expected", [
({"name": "x", "age": 20}, True),
({"name": "", "age": 20}, False),
({"name": "x", "age": -1}, False),
])
def test_validate(data, expected): ...
# ★报告:test_validate[data1-False] FAILED★
# → ★data1 是哪个?要回去数第几组★
# ✓ 有 ids
], ids=["合法数据", "空名字", "负年龄"])
# ★报告:test_validate[空名字] FAILED★
# → ★★一眼知道是"空名字"的校验挂了★★
★ ★ids 的四种给法★:
# ① ★列表(最直观)★
ids=["case1", "case2", "case3"] # ★★长度必须和参数组数一致★★
# ② ★函数(按值生成)★
@pytest.mark.parametrize("user", users, ★ids=lambda u: f"{u.role}_{u.id}"★)
# ★返回 None 时回退到默认生成★
# ③ ★在 pytest.param 里单独指定★
pytest.param({"name": ""}, False, ★id="空名字"★)
# ④ ★★全局的 id 生成钩子(conftest.py)★★
def pytest_make_parametrize_id(config, val, argname):
if isinstance(val, User):
return f"user-{val.role}"
return None # ★None = 用默认规则★
★ ★把测试数据组织成 dataclass(★参数多时的最佳实践★)★:
from dataclasses import dataclass
@dataclass
class Case:
name: str
input: dict
expected: bool
reason: str = ""
CASES = [
Case("合法", {"name": "x", "age": 20}, True),
Case("空名字", {"name": "", "age": 20}, False),
Case("负年龄", {"name": "x", "age": -1}, False),
]
@pytest.mark.parametrize("case", CASES, ★ids=lambda c: c.name★)
def test_validate(case):
assert validate(case.input) is case.expected, case.reason
★ ✓ ★参数多时可读性好得多★
★ ✓ ★新增字段不用改所有元组★
★ ✓ IDE 有补全
★ ★中文 id 的注意事项★:
✓ pytest 支持(不会转义)
✗ ★某些 CI 的日志编码可能出问题★
✗ ★-k 筛选时要在命令行输中文★
✓ 折中:★英文 id + 中文注释★
ids=["valid", "empty_name", "negative_age"] # 合法/空名字/负年龄
★ ★参数化的自文档价值★:
★ ★写好 ids 的参数化测试本身就是一份规格说明★:
test_password_strength[太短]
test_password_strength[纯数字]
test_password_strength[无大写]
test_password_strength[合法]
→ ★读测试报告就知道这个功能有哪些规则★
ids 的价值是「让失败报告可读」——没有 ids 时报告显示 test_validate[data1-False],你得回去数第几组;有了就是 test_validate[空名字],一眼定位。ids 有四种给法:列表(长度必须和参数组数一致)、函数、pytest.param(id=)、以及 conftest 里的 pytest_make_parametrize_id 钩子。参数多时的最佳实践是把测试数据组织成 dataclass——可读性好得多、新增字段不用改所有元组、IDE 还有补全。中文 id pytest 支持,但某些 CI 的日志编码可能出问题、-k 筛选也要输中文,折中是「英文 id + 中文注释」。最后一个常被忽略的价值:写好 ids 的参数化测试本身就是一份规格说明——读测试报告就知道这个功能有哪些规则。
三、笛卡尔积与组合爆炸
★ ★多个 parametrize 会相乘★:
@pytest.mark.parametrize("browser", ["chrome", "firefox"]) # ★2★
@pytest.mark.parametrize("os", ["win", "mac", "linux"]) # ★3★
def test_compat(browser, os): ...
→ ★2 × 3 = 6 个用例★
# ★再加一个维度★
@pytest.mark.parametrize("lang", ["zh", "en", "ja"]) # ★3★
→ ★2 × 3 × 3 = 18 个★
# ★再加★
@pytest.mark.parametrize("theme", ["light", "dark"]) # ★2★
→ ★★36 个★★
★ ★装饰器的应用顺序(★容易搞错★)★:
@pytest.mark.parametrize("a", [1, 2]) # ★后应用★
@pytest.mark.parametrize("b", [3, 4]) # ★★先应用(离函数近)★★
def test_x(a, b): ...
# ★生成的 id:test_x[3-1] test_x[3-2] test_x[4-1] test_x[4-2]★
# ★★b 在前,因为它先被应用★★
★ ★★组合爆炸的应对★★:
① ★只测有意义的组合(显式列出)★
@pytest.mark.parametrize("browser,os", [
("chrome", "win"), ("chrome", "mac"),
("safari", "mac"), # ★★safari 只有 mac★★
])
★ ✓ ★避免了无意义的 (safari, win)★
② ★★正交表 / Pairwise(工程上的标准做法)★★
pip install allpairspy
from allpairspy import AllPairs
params = list(AllPairs([browsers, oses, langs, themes]))
★ ★原理:大多数 bug 由"两个因素的组合"触发★
→ ★只需覆盖所有"两两组合",用例数从 36 降到 ~9★
@pytest.mark.parametrize("browser,os,lang,theme", params)
③ ★分层:核心组合全测,边缘组合抽样★
@pytest.mark.parametrize("case", CORE_CASES) # 每次都跑
@pytest.mark.slow
@pytest.mark.parametrize("case", EDGE_CASES) # ★只在 nightly 跑★
④ ★用 Hypothesis 代替★(见 property-based testing)
★ ★笛卡尔积的合理用法★:
✓ ★维度少(2~3 个)且每个维度取值少★
✓ ★确实需要覆盖所有组合★(如序列化的类型 × 格式)
✗ ★"以防万一"式地堆维度★
★ ★叠加 parametrize 和 fixture params★:
@pytest.fixture(params=["sqlite", "postgres"]) # ★2★
def db(request): ...
@pytest.mark.parametrize("n", [1, 10, 100]) # ★3★
def test_bulk(db, n): ...
→ ★2 × 3 = 6 个用例★
★ ★fixture 的 params 同样参与笛卡尔积★
★ ★用例数的心理阈值★:
< 20 ★随便★
20~100 ★要有 ids 和分组★
100~500 ★考虑标记分层(smoke / full)★
> 500 ★★大概率是设计问题★★
→ 拆分 / pairwise / property-based
多个 parametrize 装饰器会产生笛卡尔积,维度一多就爆炸(4 个维度就 36 个用例)。装饰器的应用顺序容易搞错:离函数最近的先应用,所以它的参数出现在 id 的前面。组合爆炸有四种应对:只显式列出有意义的组合(避免 (safari, win) 这种不存在的)、用 Pairwise/正交表(原理是大多数 bug 由两个因素的组合触发,只覆盖所有两两组合能把 36 降到 9 个)、分层(核心组合每次跑、边缘组合只在 nightly 跑)、或改用 Hypothesis。注意 fixture 的 params 同样参与笛卡尔积。用例数有个心理阈值:超过 500 大概率是设计问题。
四、间接参数化与 fixture 参数化
★ ★★indirect=True:参数传给 fixture★★:
# ★普通参数化:值直接给测试函数★
@pytest.mark.parametrize("db_url", ["sqlite:///:memory:"])
def test_x(db_url):
engine = create_engine(db_url) # ★★测试里自己建(重复代码)★★
# ★★间接参数化:值先给 fixture★★
@pytest.fixture
def db(request):
engine = create_engine(★request.param★)
yield engine
engine.dispose() # ★★清理逻辑复用★★
@pytest.mark.parametrize("db", ["sqlite:///:memory:", "postgresql://..."],
★indirect=True★)
def test_x(db): # ★db 已经是 engine 对象★
...
★ ★什么时候用 indirect★:
✓ ★参数需要经过"加工"才能用★(URL → engine、路径 → 文件对象)
✓ ★需要 setup/teardown★(建连接、建临时目录、启动容器)
✓ ★多个测试共享同样的加工逻辑★
✗ 参数就是纯数据 → ★直接参数化更简单★
★ ★部分间接★:
@pytest.mark.parametrize("db,expected_count", [
("sqlite:///:memory:", 0),
("postgresql://...", 5),
], ★indirect=["db"]★) # ★★只有 db 走 fixture★★
def test_count(db, expected_count): ...
★ ★★fixture 参数化:影响所有使用者★★:
@pytest.fixture(★params=["sqlite", "postgres", "mysql"]★)
def backend(request):
b = make_backend(request.param)
yield b
b.cleanup()
def test_insert(backend): ... # ★跑 3 遍★
def test_query(backend): ... # ★跑 3 遍★
def test_delete(backend): ... # ★跑 3 遍★
★ → ★一共 9 个用例★
★ ★给 fixture params 加 ids★:
@pytest.fixture(params=["sqlite", "postgres"],
★ids=["SQLite", "PostgreSQL"]★)
★ ★用 pytest.param 加标记★:
@pytest.fixture(params=[
"sqlite",
pytest.param("postgres", ★marks=pytest.mark.integration★),
])
★ ★两者的选择★:
┌────────────────────────────┬────────────────────────┐
│ ★parametrize★ │ ★只影响标记的那个测试★ │
│ │ 适合:这个测试的输入数据│
│ ★fixture(params=...)★ │ ★影响所有依赖它的测试★ │
│ │ 适合:★测试环境的变体★ │
│ │ (多种数据库/多种配置) │
└────────────────────────────┴────────────────────────┘
★ ★request 对象的其他用法★:
def my_fixture(request):
request.param # ★参数化的值★
request.node.name # ★当前测试的名字★
request.node.get_closest_marker("slow") # ★读标记★
request.config.getoption("--env") # ★读命令行选项★
request.addfinalizer(cleanup) # ★注册清理(yield 的替代)★
request.getfixturevalue("other_fixture") # ★★动态获取其他 fixture★★
★ ★动态参数化(从命令行/环境读)★:
# conftest.py
def pytest_generate_tests(metafunc):
if "backend" in metafunc.fixturenames:
backends = metafunc.config.getoption("--backends").split(",")
★metafunc.parametrize("backend", backends)★
# ★运行:pytest --backends=sqlite,postgres★
★ ✓ ★CI 里跑全部,本地只跑 sqlite★
indirect=True 让参数先经过 fixture 加工——适合「参数需要转换才能用」(URL → engine)、「需要 setup/teardown」、「多个测试共享加工逻辑」的场景;纯数据就直接参数化。parametrize 和 fixture(params=...) 的区别是影响范围:前者只影响标记的测试(适合这个测试的输入数据),后者影响所有依赖它的测试(适合测试环境的变体,比如同一套测试跑在多种数据库上)。request 对象还有几个实用属性:request.node.name、request.config.getoption()、request.getfixturevalue() 动态获取其他 fixture。最后 pytest_generate_tests 钩子能做动态参数化——从命令行选项决定参数集,实现「CI 里跑全部后端、本地只跑 sqlite」。
五、参数化的反模式
★ ★★反模式一:在参数里做有副作用的事★★:
✗ @pytest.mark.parametrize("user", ★User.objects.all()★)
✗ @pytest.mark.parametrize("n", ★[random.randint(1,100) for _ in range(5)]★)
✗ @pytest.mark.parametrize("t", [★datetime.now()★])
★ ★原因:参数在"收集阶段"求值★
→ ★此时 fixture 还没执行、数据库可能还没建表★
→ ★每次运行的用例集不同 → --lf/--ff 失效、CI 结果不可比★
→ ★收集阶段就连数据库 = 收集变慢/失败★
✓ 用 fixture + indirect,或在测试内部获取数据
★ ★反模式二:参数化的分支逻辑★:
✗ @pytest.mark.parametrize("mode,expected", [("a", 1), ("b", 2)])
def test_x(mode, expected):
★if mode == "a":★
result = do_a()
★else:★
result = do_b()
assert result == expected
★ ★测试里有 if = 它其实是两个测试★
✓ 拆成 test_mode_a 和 test_mode_b
★ ★反模式三:参数太多不可读★:
✗ @pytest.mark.parametrize("a,b,c,d,e,f,g", [(1,2,3,4,5,6,7), ...])
★ ★读的人根本对不上哪个是哪个★
✓ 用 dataclass 或字典打包
✓ 或拆成多个更聚焦的测试
★ ★反模式四:期望值也算出来★:
✗ @pytest.mark.parametrize("a,b", [(1,2),(3,4)])
def test_add(a, b):
assert add(a, b) == ★a + b★ # ★★用被测逻辑验证被测逻辑★★
★ ★这个测试永远不会失败(除非 add 抛异常)★
✓ ★期望值必须是硬编码的字面量★
★ ★反模式五:共享可变对象★:
✗ SHARED = {"count": 0}
@pytest.mark.parametrize("d", [SHARED, SHARED])
def test_x(d): d["count"] += 1 # ★★用例之间互相污染★★
✓ 用不可变数据,或在测试内部深拷贝
★ ★反模式六:用参数化代替 fixture★:
✗ @pytest.mark.parametrize("db_path", ["/tmp/test.db"])
def test_x(db_path):
conn = sqlite3.connect(db_path) # ★★没有清理★★
✓ ★需要 setup/teardown 就用 fixture★
★ ★★参数化数据的组织★★:
# ① ★内联(3~5 组,简单数据)★
@pytest.mark.parametrize("a,b", [(1,2), (3,4)])
# ② ★模块级常量(多组、多个测试复用)★
VALID_EMAILS = ["a@b.com", "x.y@z.co.uk"]
INVALID_EMAILS = ["", "no-at", "@nolocal"]
@pytest.mark.parametrize("email", VALID_EMAILS)
def test_valid(email): ...
# ③ ★外部文件(数据量大、非开发者维护)★
def load_cases():
with open("tests/data/cases.json") as f: # ★★注意:这是收集期读文件★★
return json.load(f)
@pytest.mark.parametrize("case", load_cases(), ids=lambda c: c["name"])
★ ✓ 测试数据和代码分离
★ ✗ ★文件不存在时收集就失败★
★ ✗ ★重构时 IDE 找不到引用★
六个反模式里最严重的是「在参数里做有副作用的事」——参数在收集阶段求值,此时 fixture 还没执行、数据库可能还没建表,而且每次运行的用例集不同会让 --lf 失效、CI 结果不可比。「参数化的分支逻辑」也很典型——测试里有 if 就说明它其实是两个测试,该拆开。「期望值也算出来」是用被测逻辑验证被测逻辑,这个测试永远不会失败——期望值必须是硬编码的字面量。数据组织上:3~5 组用内联、多组或多测试复用用模块级常量、数据量大可以用外部文件但要接受「文件不存在时收集就失败」和「IDE 找不到引用」的代价。
六、实践清单
★ 检查清单:
□ ★用 parametrize 而不是 for 循环★
□ ★写了 ids(尤其是复杂对象)★
□ ★参数值是字面量,没有副作用★
□ ★期望值是硬编码的,不是算出来的★
□ ★测试体里没有 if 分支★
□ ★参数超过 4 个时用 dataclass 打包★
□ ★笛卡尔积的用例数可控★
□ ★需要 setup/teardown 时用 fixture 而不是参数★
□ ★已知失败的用例用 pytest.param + xfail 标记(不是注释掉)★
★ ★常用命令★:
pytest --collect-only -q # ★★先看会生成哪些用例★★
pytest "test_file.py::test_x[空名字]" # 跑单组
pytest -k "空名字 or 负年龄" # 按 id 筛
pytest --lf # ★重跑上次失败的★
pytest --ff # 先跑上次失败的
pytest -x # 第一个失败就停
★ ★参数化的三个层次★:
┌──────────────────────────────┬────────────────────┐
│ ★① 具体值参数化★ │ 最常用 │
│ parametrize("a,b", [...]) │ 边界值、等价类 │
│ ★② 环境参数化★ │ fixture(params=) │
│ 同一套测试跑多种后端 │ + indirect │
│ ★③ 属性化测试★ │ ★Hypothesis★ │
│ 不列举数据,描述"规律" │ 自动生成 + 收缩 │
└──────────────────────────────┴────────────────────┘
★ ★从①开始,数据组合多了考虑③★
★ ★一个完整的例子(密码强度校验)★:
@dataclass
class PwCase:
name: str; pw: str; ok: bool; err: str = ""
CASES = [
PwCase("合法", "Abc12345!", True),
PwCase("太短", "Ab1!", False, "至少8位"),
PwCase("纯数字", "12345678", False, "需要字母"),
PwCase("无大写", "abc12345!", False, "需要大写"),
PwCase("无特殊字符", "Abc12345", False, "需要特殊字符"),
PwCase("常见弱密码", "Password1!", False, "太常见"),
pytest.param(PwCase("超长", "A"*200+"b1!", True),
marks=pytest.mark.xfail(reason="长度上限未定 #456")),
]
@pytest.mark.parametrize("c", CASES, ids=lambda c: c.name)
def test_password_strength(c):
result = check_password(c.pw)
assert result.ok is c.ok
if not c.ok:
assert c.err in result.message
★ ★报告本身就是规格说明:★
test_password_strength[太短] PASSED
test_password_strength[纯数字] PASSED
...
★ 一句话总结:
★"parametrize 把『一个函数 × N 组数据』变成 N 个独立用例——
一组失败不影响其他组,报告能精确定位;
一定要写 ids 让报告可读;多个 parametrize 是笛卡尔积要控制规模;
参数值必须是无副作用的字面量(它在收集阶段就求值);
需要加工或清理就用 indirect + fixture。"★
检查清单里最容易忽略的两条:期望值必须硬编码(不能用被测逻辑算)、已知失败的用例用 pytest.param + xfail 标记而不是注释掉(注释掉就永远没人记得改回来,而 xfail 在 bug 修复后会变成 XPASS 提醒你)。pytest --collect-only -q 是个好习惯——参数化写完先看会生成哪些用例。最后那个密码强度校验的完整例子体现了理想形态:用 dataclass 组织数据、ids 用中文场景名、已知 bug 用 xfail 标记,最终报告本身就是一份规格说明。
记忆钩子:「★@pytest.mark.parametrize 把『一个测试函数 × N 组数据』变成 N 个独立的测试用例★——这是它和『测试里写 for 循环』的本质区别:★for 循环第一组断言失败就整个终止、后面的数据根本没验证,报告也只说函数失败★;★参数化则每组独立,一组失败其他照常跑完,报告精确显示 test_x[某场景] FAILED,还能单独重跑★。pytest 在★收集阶段★就为每组参数生成一个 test item,★这些 item 和普通测试函数完全平等,所以每组都会重新执行 function 作用域的 fixture★。★四个进阶技巧★:★① ids= 值得每次都写★——默认对复杂对象会退化成 test_x[user0] 这种毫无信息量的名字,★写好 ids 后报告本身就是一份规格说明★(可以用列表、lambda、pytest.param(id=)、或 conftest 的 pytest_make_parametrize_id 钩子);★② pytest.param(…, marks=…) 给单组加 xfail/skip/slow 标记★——★已知失败的用例要用 xfail 标记而不是注释掉★(注释掉永远没人改回来,xfail 在修好后会变 XPASS 提醒你);★③ 多个 parametrize 装饰器产生笛卡尔积★(4 个维度就 36 个用例),★注意离函数最近的装饰器先应用所以它的参数在 id 前面★,应对办法是★只显式列出有意义的组合★、★用 Pairwise/正交表(原理是大多数 bug 由两个因素组合触发,能把 36 降到 9)★、分层(核心每次跑边缘 nightly 跑)、或改用 Hypothesis;★④ indirect=True 让参数先传给 fixture 加工★(URL 变 engine),适合需要 setup/teardown 或多测试共享加工逻辑的场景。★parametrize 和 fixture(params=…) 的区别是影响范围★:前者只影响标记的测试(★这个测试的输入数据★),后者影响所有依赖它的测试(★测试环境的变体,如同一套测试跑多种数据库★),★两者都参与笛卡尔积★。★最严重的反模式是在参数里做有副作用的事★(数据库查询、random、datetime.now)——★因为参数在收集阶段求值★,此时 fixture 还没执行、表可能还没建,而且★每次运行的用例集不同会让 —lf 失效、CI 结果不可比★。其他反模式:★测试体里有 if 分支说明它其实是两个测试★、★期望值用被测逻辑算出来(assert add(a,b) == a+b)永远不会失败★、参数超过 4~5 个不可读(用 dataclass 打包)。实用命令:★pytest —collect-only -q 先看会生成哪些用例★。」
七、常见误区与追问
- 误区:在测试函数里写 for 循环遍历测试数据,和参数化效果一样。 差别很大,而且是实质性的。for 循环的所有数据共享同一个测试用例:第一组数据断言失败时抛出
AssertionError,函数直接终止,后面的数据一次都没被验证——你以为测了 10 组,实际只测了 1 组。而且失败报告只会显示test_validate FAILED加上一行断言信息,你得自己回代码里数是第几组出的问题。参数化则为每组数据生成独立的 test item:一组失败其他照常执行完,你能一次看到全部失败的组(这对定位「是某一类输入都有问题还是只有一个特例」非常关键);报告里显示test_validate[空名字] FAILED;还能用pytest "test_validate[空名字]"单独重跑那一组;甚至能给某组单独加xfail或skip。唯一勉强合理用循环的场景是「断言的是集合整体的性质」,但那时候本来也不该逐个断言。 - 误区:
ids只是让输出好看一点,可写可不写。 它直接决定了测试失败时的排查效率。pytest 默认的 id 生成规则对简单类型还行(test_add[1-2-3]),但遇到 dict、dataclass、自定义对象就会退化成「参数名 + 序号」——test_validate[data0]、test_validate[data1]、test_validate[data2]。CI 上跑失败时,你看到的就是这么一行毫无信息量的输出,只能打开代码数第几个元组,如果数据是从常量列表或外部文件来的还要再跳一层。写上ids=["合法数据", "空名字", "负年龄"]之后,报告直接告诉你「空名字的校验挂了」。更进一步的价值是:一组写好 ids 的参数化测试,它的用例列表本身就是这个功能的规格说明——pytest --collect-only出来的test_password[太短] / [纯数字] / [无大写]就是产品规则的清单,比文档更不容易过期。 - 误区:
@pytest.mark.parametrize("user", User.objects.all())可以用来测所有用户。 这行代码在 pytest「收集测试」的阶段就会执行数据库查询——那时候你的 fixture 一个都没运行(数据库可能还没建表、测试数据还没准备、连接配置可能还没加载),大概率直接报错;即使侥幸能连上,也有三个严重问题:① 用例集不确定——数据库里有多少用户就有多少用例,今天和明天跑出来的测试数量不同,CI 的结果无法横向比较;②--lf(重跑上次失败)、--ff、分布式执行都会失效,因为它们依赖用例 id 的稳定性;③ 收集阶段变慢甚至阻塞(pytest --collect-only也会连库)。同样的问题也出现在random.randint()、datetime.now()、读文件、调 API。参数化的值必须是字面量或纯函数生成的确定值;需要动态数据就用 fixture(配indirect=True)或在测试函数内部获取。 - 误区:想测多种数据库就多写几个
parametrize,装饰器叠着来。 要先分清你要参数化的是「数据」还是「环境」。如果只有一两个测试需要跑在多种数据库上,用@pytest.mark.parametrize("db", [...], indirect=True)是合适的。但如果是整个测试模块(几十个测试)都要在多种后端上验证,正确的做法是参数化 fixture:@pytest.fixture(params=["sqlite", "postgres"])——这样所有依赖这个 fixture 的测试自动跑多遍,不需要在每个测试上重复加装饰器,将来加一种后端也只改一处。另外要注意两者都参与笛卡尔积:一个 2 参数的 fixture 配一个 3 参数的 parametrize,会生成 6 个用例——维度一多用例数增长很快,要用pytest --collect-only -q | wc -l心里有个数。 - 误区:某组参数一直失败,先注释掉等有空再修。 注释掉的测试等于不存在,而且大概率永远不会被改回来。更好的做法是用
pytest.param(..., marks=pytest.mark.xfail(reason="已知 bug #123"))——它有三个好处:① 测试仍然会执行,只是失败被记为xfail(预期失败)而不算套件失败;②reason里写清楚原因和 issue 编号,代码即文档;③ 最关键的是——当这个 bug 被修复后,这一组会变成XPASS(意外通过),pytest 会明确提示你,你就知道该把 xfail 标记去掉了。而注释掉的代码不会给你任何信号。如果用strict=True(或全局配xfail_strict = true),XPASS还会直接算作失败,强制你处理。同类的还有pytest.mark.skip(明确不该跑)和skipif(按条件跳过,比如「这个特性需要 Python 3.12+」)。 - 追问:多个
parametrize装饰器的执行顺序和 id 顺序是怎样的? Python 的装饰器是从下往上应用的,所以离函数最近的那个parametrize先被应用。这直接影响生成的用例 id:@parametrize("a", [1,2])在上、@parametrize("b", [3,4])在下时,生成的 id 是test_x[3-1]、test_x[3-2]、test_x[4-1]、test_x[4-2]——b的值排在前面。这个细节在两个场景下有实际影响:① 用-k筛选时你得知道 id 的实际拼接顺序;② 想控制「哪个维度变化得慢」时(比如希望所有 chrome 的用例连着跑,可以把 browser 放在离函数远的位置)。实践建议:如果 id 顺序让你困惑,就不要叠加装饰器,改成一个parametrize显式列出组合——既清晰又能顺便剔除无意义的组合。想确认实际生成了什么,pytest --collect-only -q一看便知。 - 追问:参数化的用例数太多(几百上千)怎么办? 先判断这是不是设计问题:用例数爆炸通常来自「无脑堆笛卡尔积」。四个应对手段按推荐度排序。① 只列有意义的组合——很多组合在业务上根本不存在(Safari 不跑在 Windows 上、免费用户没有企业功能),显式写出实际组合而不是让框架相乘。② Pairwise(成对组合)——这是工程上的标准做法,理论依据是大多数缺陷由「两个因素的交互」触发,三个及以上因素同时交互才触发的 bug 很罕见;用
allpairspy之类的库生成覆盖所有「两两组合」的最小集合,能把 36 个用例降到 9 个左右而缺陷发现能力损失很小。③ 分层执行——核心组合打上默认标记每次 CI 都跑,边缘组合标记成@pytest.mark.slow只在 nightly 或发版前跑。④ 换成 property-based testing——当你发现自己在「枚举大量输入验证同一个性质」时,用 Hypothesis 描述「输入的规律 + 应该满足的属性」比手工列举更彻底,它还能自动收缩到最小反例。 - 追问:
indirect=True和直接在 fixture 里用params有什么区别? 两者最终都是「参数经过 fixture 加工」,区别在谁决定参数集。fixture(params=[...])是 fixture 自己定义参数集——所有使用这个 fixture 的测试都会跑一遍全部参数,参数集对使用者是透明的(测试函数完全不知道自己被跑了几遍)。适合「这个 fixture 天然有多个变体,所有用它的测试都该覆盖」,比如「所有数据库测试都要在 SQLite 和 PostgreSQL 上跑」。indirect=True是测试函数自己决定参数集——fixture 只提供「加工逻辑」,具体跑哪些值由每个测试的parametrize装饰器指定。适合「不同的测试需要不同的参数子集」,比如「大部分测试只需要 SQLite,只有这几个兼容性测试需要跑所有数据库」。实践上还有个组合技:fixture(params=[...])定义默认参数集,个别测试用indirect覆盖。选择标准很简单:参数集是 fixture 的固有属性 → 用params;是测试的选择 → 用indirect。
八、加强记忆
@pytest.mark.parametrize 把「一个测试函数 × N 组数据」变成 N 个独立的测试用例——这是它和「测试里写 for 循环」的本质区别:for 循环第一组断言失败就整个终止、后面的数据根本没验证,报告也只说函数失败;参数化则每组独立,一组失败其他照常跑完,报告精确显示 test_x[某场景] FAILED,还能单独重跑。pytest 在收集阶段就为每组参数生成一个 test item,这些 item 和普通测试函数完全平等,所以每组都会重新执行 function 作用域的 fixture。四个进阶技巧:① ids= 值得每次都写——默认对复杂对象会退化成 test_x[user0] 这种毫无信息量的名字,写好 ids 后报告本身就是一份规格说明(可以用列表、lambda、pytest.param(id=)、或 conftest 里的 pytest_make_parametrize_id 钩子);② pytest.param(..., marks=...) 给单组加 xfail/skip/slow 标记——已知失败的用例要用 xfail 标记而不是注释掉(注释掉永远没人改回来,而 xfail 在修好后会变成 XPASS 提醒你);③ 多个 parametrize 装饰器产生笛卡尔积(4 个维度就 36 个用例),注意离函数最近的装饰器先应用、所以它的参数出现在 id 前面,应对办法是只显式列出有意义的组合、用 Pairwise/正交表(原理是大多数 bug 由两个因素的组合触发,能把 36 降到 9)、分层执行(核心每次跑、边缘 nightly 跑)、或改用 Hypothesis;④ indirect=True 让参数先传给 fixture 加工(URL 变 engine),适合需要 setup/teardown 或多个测试共享加工逻辑的场景。parametrize 和 fixture(params=...) 的区别是影响范围:前者只影响标记的测试(这个测试的输入数据),后者影响所有依赖它的测试(测试环境的变体,比如同一套测试跑多种数据库),两者都参与笛卡尔积。最严重的反模式是在参数里做有副作用的事(数据库查询、random、datetime.now())——因为参数在收集阶段就求值,此时 fixture 还没执行、表可能还没建,而且每次运行的用例集不同会让 --lf 失效、CI 结果无法比较。其他反模式:测试体里有 if 分支说明它其实是两个测试、期望值用被测逻辑算出来(assert add(a,b) == a+b)会让测试永远不失败、参数超过 4~5 个不可读(用 dataclass 打包)。实用命令:pytest --collect-only -q 先看会生成哪些用例。