bigywater.tech
Python · 98

pytest 参数化:一组测试如何覆盖几十种输入

把同一个断言换十组输入,最省事的写法是 for 循环;pytest 的写法是 parametrize。它们在失败时的表现、用例的命名、组合的方式上差别很大。这篇拿一个 slugify 函数,一项一项验证。

10 组 → 10 个用例
每组参数一个独立用例,各自通过或失败
2 vs 1
parametrize 报告了 2 个失败,for 循环只暴露第一个
3 × 4 = 12
叠加两个 parametrize 得到笛卡尔积

验证一:一组参数一个用例

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 这样的形式,想要好读的名字就要自己指定(见验证三)。

验证二:for 循环写法会遮住后面的失败

实测 · 本机执行输出
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ï)从来没有被检查,报告里也只有一个失败。修完一个再跑,才会撞上下一个。

验证三:用例 id

默认规则与自定义
实测 · 本机执行输出
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,不影响其他两个通过。

验证四:叠加 parametrize 是笛卡尔积

实测 · 本机执行输出
  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,所以「几十组」参数几乎没有感知,但「几万组」就要考虑是不是把它们放进循环。用例是否值得独立,取决于你想要失败定位的精度还是运行速度。

分步演示:10 个用例逐个运行

每一行都是 pytest -v 的真实输出
parametrize 生成的用例
当前用例

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,pytest 9.1.1(装在临时虚拟环境,-p no:cacheprovider)
    • 没有验证:参数化 fixture(params=)与间接参数化(indirect=True)、pytest-xdist 并行时的分配、用例数量达到几十万时的收集内存
    京ICP备2026067502号-1京公网安备11010502063487号