把同一个断言换十组输入,最省事的写法是 for 循环;pytest 的写法是 parametrize。它们在失败时的表现、用例的命名、组合的方式上差别很大。这篇拿一个 slugify 函数,一项一项验证。
test_slug.py(节选) @pytest.mark.parametrize("raw, expected", [ ("Hello World", "hello-world"), ("Café Crème", "cafe-creme"), # 期望「去重音」,但 slugify 没做 ("Ünï", "uni"), ("2026年9月", "2026-9"), ...]) def test_slugify(raw, expected): assert slugify(raw) == expected
pytest test_slug.py -v --tb=line -rf
============================= test session starts ==============================
collecting ... collected 10 items
test_slug.py::test_slugify[Hello World-hello-world] PASSED [ 10%]
test_slug.py::test_slugify[ spaced out -spaced-out] PASSED [ 20%]
test_slug.py::test_slugify[Already-Slug-already-slug] PASSED [ 30%]
test_slug.py::test_slugify[Caf\xe9 Cr\xe8me-cafe-creme] FAILED [ 40%]
test_slug.py::test_slugify[-] PASSED [ 50%]
test_slug.py::test_slugify[----] PASSED [ 60%]
test_slug.py::test_slugify[A_B_C-a-b-c] PASSED [ 70%]
test_slug.py::test_slugify[a--b-a-b] PASSED [ 80%]
test_slug.py::test_slugify[\xdcn\xef-uni] FAILED [ 90%]
test_slug.py::test_slugify[2026\u5e749\u6708-2026-9] PASSED [100%]
=================================== FAILURES ===================================
E AssertionError: assert 'caf-cr-me' == 'cafe-creme'
- cafe-creme
? - ^
+ caf-cr-me
? ^
test_slug.py:17: AssertionError: assert 'caf-cr-me' == 'cafe-creme'
E AssertionError: assert 'n' == 'uni'
- uni
+ n
test_slug.py:17: AssertionError: assert 'n' == 'uni'
=========================== short test summary info ============================
FAILED test_slug.py::test_slugify[Caf\xe9 Cr\xe8me-cafe-creme] - AssertionErr...
FAILED test_slug.py::test_slugify[\xdcn\xef-uni] - AssertionError: assert 'n'...
========================= 2 failed, 8 passed in 0.07s ==========================10 组参数变成 10 个用例,每个 id 由参数值拼出来。有两组失败,它们的名字直接出现在报告里,失败差异也逐个给出('caf-cr-me' == 'cafe-creme'、'n' == 'uni')——你一眼就知道是哪个输入出了问题。注意:id 里的非 ASCII 字符被转义成了 \xe9 这样的形式,想要好读的名字就要自己指定(见验证三)。
pytest test_loop.py -v --tb=line
============================= test session starts ==============================
collecting ... collected 1 item
test_loop.py::test_slugify_loop FAILED [100%]
=================================== FAILURES ===================================
E AssertionError: ('Café Crème', 'cafe-creme')
assert 'caf-cr-me' == 'cafe-creme'
- cafe-creme
? - ^
+ caf-cr-me
? ^
test_loop.py:6: AssertionError: ('Café Crème', 'cafe-creme')
=========================== short test summary info ============================
FAILED test_loop.py::test_slugify_loop - AssertionError: ('Café Crème', 'cafe...
============================== 1 failed in 0.07s ===============================把同样的数据放进一个 for 循环,整个测试只算 1 个用例;第二组失败时循环就中断,剩下的组(包括另一个会失败的 Ünï)从来没有被检查,报告里也只有一个失败。修完一个再跑,才会撞上下一个。
test_ids.py::test_default_ids[1] test_ids.py::test_default_ids[a b] test_ids.py::test_default_ids[None] test_ids.py::test_default_ids[3.5] test_ids.py::test_default_ids[x4] test_ids.py::test_default_ids[x5] test_ids.py::test_default_ids[x6] test_ids.py::test_default_ids[x7] test_ids.py::test_default_ids[raw] test_ids.py::test_default_ids[\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f\u957f] test_ids.py::test_custom[small] test_ids.py::test_custom[big] test_ids.py::test_custom[zero] test_ids.py::test_custom[known-bug] 运行(-v): test_ids.py::test_custom[small] PASSED [ 25%] test_ids.py::test_custom[big] PASSED [ 50%] test_ids.py::test_custom[zero] SKIPPED (演示) [ 75%] test_ids.py::test_custom[known-bug] XFAIL (已知问题) [100%] ============ 2 passed, 1 skipped, 10 deselected, 1 xfailed in 0.08s ============
基本类型(int、str、None、float、bytes)直接用值做 id;元组、列表、字典、object() 这类没法生成可读 id 的,退化成「参数名 + 序号」:x4、x5、x6、x7;长字符串的 id 也会完整保留(并转义非 ASCII)。想要好读的名字,用 pytest.param(..., id="small")——同一个地方还能给单个用例打 skip、xfail 标记,上面运行结果里 zero 被跳过、known-bug 是 XFAIL,不影响其他两个通过。
3 个浏览器 × 4 个系统 → 12 个用例; 前 5 个 id:
test_matrix.py::test_matrix[mac-chrome]
test_matrix.py::test_matrix[mac-firefox]
test_matrix.py::test_matrix[mac-safari]
test_matrix.py::test_matrix[linux-chrome]
test_matrix.py::test_matrix[linux-firefox]
选择其中一个: pytest 'test_matrix.py::test_matrix[mac-firefox]' → ['=============================', 'test']
-k 'firefox and not win' → 3/12 tests collected (9 deselected) in 0.07s两个 parametrize 叠在同一个函数上:3 个浏览器 × 4 个系统 = 12 个用例。id 是各参数的 id 拼接(注意顺序:下面那个装饰器的参数在前)。可以用完整 id 直接指定单个用例,也可以用 -k "firefox and not win" 按表达式筛选(这里选出 3 个)。
E AssertionError: 想当然每次都是全新列表, 实际长度 2
assert 2 == 1
test_shared.py:6: AssertionError: 想当然每次都是全新列表, 实际长度 2
E AssertionError: 想当然每次都是全新列表, 实际长度 3
assert 3 == 1
test_shared.py:6: AssertionError: 想当然每次都是全新列表, 实际长度 3
FAILED test_shared.py::test_shared_list[box1] - AssertionError: 想当然每次都...
FAILED test_shared.py::test_shared_list[box2] - AssertionError: 想当然每次都...
========================= 2 failed, 1 passed in 0.07s ==========================三个用例传的是同一个列表对象 SHARED。第一个用例往里 append(1) 后长度是 1,通过;第二个用例拿到的还是这个列表,长度变成 2,失败;第三个变成 3。parametrize 只在收集阶段把参数对象存起来,并不会给每个用例复制一份——参数里放了可变对象,就要自己保证用例不会修改它(或者在用例里先拷贝)。
参数化 3000 例: 总耗时 1.02 s | ====================== 3000 passed, 1 deselected in 0.84s ====================== 单个用例循环: 总耗时 0.30 s | ====================== 1 passed, 3000 deselected in 0.13s ======================
3000 个 n * 2 == n + n 的断言:参数化成 3000 个用例总耗时 1.02 s(pytest 自己报告的运行时间 0.83 s,其余是收集和进程启动);放进一个用例的循环里总共 0.30 s(运行 0.13 s)。每个用例都有固定的调度成本,大约每个 0.2 ms,所以「几十组」参数几乎没有感知,但「几万组」就要考虑是不是把它们放进循环。用例是否值得独立,取决于你想要失败定位的精度还是运行速度。
-p no:cacheprovider)params=)与间接参数化(indirect=True)、pytest-xdist 并行时的分配、用例数量达到几十万时的收集内存