← 返回题目列表

pytest 的参数化怎么用?parametrize 有哪些进阶技巧?

中等 第 19 / 27 题 更新于 2026/08/03
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 多个测试函数

parametrizefor 循环多个函数
用例数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」、「多个测试共享加工逻辑」的场景;纯数据就直接参数化。parametrizefixture(params=...) 的区别是影响范围:前者只影响标记的测试(适合这个测试的输入数据),后者影响所有依赖它的测试(适合测试环境的变体,比如同一套测试跑在多种数据库上)。request 对象还有几个实用属性:request.node.namerequest.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[空名字]" 单独重跑那一组;甚至能给某组单独加 xfailskip。唯一勉强合理用循环的场景是「断言的是集合整体的性质」,但那时候本来也不该逐个断言。
  • 误区: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 或多个测试共享加工逻辑的场景。parametrizefixture(params=...) 的区别是影响范围:前者只影响标记的测试(这个测试的输入数据),后者影响所有依赖它的测试(测试环境的变体,比如同一套测试跑多种数据库),两者都参与笛卡尔积最严重的反模式是在参数里做有副作用的事(数据库查询、randomdatetime.now())——因为参数在收集阶段就求值,此时 fixture 还没执行、表可能还没建,而且每次运行的用例集不同会让 --lf 失效、CI 结果无法比较。其他反模式:测试体里有 if 分支说明它其实是两个测试期望值用被测逻辑算出来(assert add(a,b) == a+b)会让测试永远不失败、参数超过 4~5 个不可读(用 dataclass 打包)。实用命令:pytest --collect-only -q 先看会生成哪些用例