Skip to content

第 15 章 · 参数化高级技巧

本章目标:掌握 indirect 参数化、多重参数化叠加、自定义测试 ID 与动态生成参数这四个进阶武器,并学会在「穷举式参数化」与「属性基测试」之间做选型。

15.1 indirect:让参数先经过 fixture 加工

第 5 章学过,@pytest.mark.parametrize 会把参数值直接传给测试函数。但很多场景下,参数不是"直接用的值",而是构造资源的配方:比如同一个测试要分别跑在 SQLite 和 PostgreSQL 上,参数只是数据库名,真正传给测试的应该是建好的连接对象。

这时给 indirect=True,pytest 会把参数值交给同名的 fixture 处理,fixture 通过 request.param 拿到原始参数,加工后返回:

python
# 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"]。三个关键点:

  1. fixture 的名字必须与 parametrize("db_conn", ...) 中一致;
  2. fixture 内用 request.param 读取参数;
  3. indirect 也可以只对部分参数生效:indirect=["db_conn"](多参数时)。

为什么不直接在测试里 if/else

把"资源怎么造"留在 fixture、"行为怎么验"留在测试里,正是第 6 章 DI 思想的延续:同一份断言代码可以无痛切换底层实现。

15.2 叠加多个 parametrize:笛卡尔积

一个函数上叠两个 parametrize 时,参数组合是笛卡尔积

python
# 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:

python
# 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,遇到复杂对象会变成 conn0conn1 这种没有信息量的名字。两种解法:

python
# 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 表达式做精准筛选:

bash
$ pytest -k "guest"           # 只跑 ID 含 guest 的用例
$ pytest --collect-only -q    # 预览全部生成的用例 ID

注意

ids 列表长度必须与参数个数一致,否则 pytest 报错;idfn 返回 None 表示"这个值用默认 ID",这是它比字符串列表灵活的地方。

15.4 对类和模块整体参数化

parametrize 是普通 marker,可以直接贴在测试类上,类里所有方法都获得参数:

python
# 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 钩子:

python
# 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 属性基测试:怎么选

维度parametrizeHypothesis(第 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. 想根据命令行选项在"收集阶段"动态决定某测试的参数范围,应该使用?

🛠️ 动手实践

  1. 为一个 send_email(to, subject) 函数写间接参数化测试:to 参数经 fixture 校验格式后才注入,非法地址的用例用 pytest.param + 自定义异常断言处理。
  2. 把第 5 章某个参数化测试改造为带 idfn 的版本,并用 --collect-only -q 对比改造前后的用例 ID。
  3. 编写 pytest_generate_tests:当命令行含 --full-matrix 时输出 3×3 全组合,否则只跑对角线上的 3 个组合。

下一章我们把今天见到的钩子正式升级成自定义插件