Skip to content

第 5 章 · 05 参数化测试

本章目标:用 @pytest.mark.parametrize 把"同一逻辑、多组数据"的测试压缩成一份代码,并掌握多参数、笛卡尔积、自定义用例 ID 和 pytest.param 这些进阶写法。

5.1 为什么需要参数化

验证 is_palindrome,笨办法是复制粘贴十个测试函数;参数化的办法是一份测试逻辑 + 一张数据表

python
import pytest


@pytest.mark.parametrize(
    "text, expected",
    [
        ("level", True),
        ("hello", False),
        ("", True),
        ("A man, a plan, a canal: Panama", True),
    ],
)
def test_is_palindrome(text, expected):
    from strings import is_palindrome

    assert is_palindrome(text) == expected

运行时 pytest 会为每行数据生成一个独立用例,节点 ID 形如 test_is_palindrome[level]test_is_palindrome[hello]每条数据独立通过/失败——一条挂了不影响其他数据的验证,这正是参数化优于循环 for 断言的地方:

python
def test_bad_style():
    # 反模式:循环里断言,第一条失败就中断,后面的数据根本没被测到
    for text, expected in [("level", True), ("hello", False)]:
        assert is_palindrome(text) == expected

参数值不会被复制

官方文档特别提醒:参数值按原样传入测试。如果你传了列表或字典并在用例里修改了它,修改会影响到后续用例——数据表里放可变对象时务必小心(或每条数据用工厂函数生成新对象)。

5.2 失败时的可读性:ids 与 pytest.param

默认的用例 ID 由参数值直接拼成,遇到长字符串或对象会很难看。两种改进方式:

python
@pytest.mark.parametrize(
    "text, expected",
    [
        pytest.param("level", True, id="简单回文"),
        pytest.param("hello", False, id="普通单词"),
        pytest.param("", True, id="空字符串"),
        pytest.param("A man, a plan, a canal: Panama", True, id="经典长句"),
    ],
    ids=None,  # 每条用 pytest.param 的 id 覆盖
)
def test_is_palindrome_named(text, expected):
    ...

也可以给一个 ids 列表或函数,按参数自动生成:

python
def make_id(val):
    if isinstance(val, str):
        return val[:10]
    return str(val)


@pytest.mark.parametrize("text", ["apple", "banana"], ids=make_id)
def test_lower(text):
    assert text.lower() == text

pytest.param 还能给单条数据叠加标记——这是"个别数据已知有问题"时的标准解法:

python
@pytest.mark.parametrize(
    "n, expected",
    [
        (1, 2),
        pytest.param(6 * 9, 42, marks=pytest.mark.xfail(reason="银河系搭车客梗"), id="深思考"),
        pytest.param(99, 100, marks=pytest.mark.slow),
    ],
)
def test_increment(n, expected):
    assert n + 1 == expected

5.3 多参数与笛卡尔积

parametrize 天然支持多个参数名,数据按位置对应:

python
@pytest.mark.parametrize(
    "a, b, expected",
    [
        (2, 3, 5),
        (-1, -2, -3),
        (0, 0, 0),
    ],
)
def test_add(a, b, expected):
    assert a + b == expected

把装饰器叠加起来就得到笛卡尔积——每个参数各取所有值,组合数是相乘:

python
@pytest.mark.parametrize("x", [1, 10])
@pytest.mark.parametrize("y", [2, 5])
def test_multiply(x, y):
    # 生成 4 条用例: [2-1] [2-10] [5-1] [5-10]
    assert x * y > 0

叠加时下面的装饰器变化更快。笛卡尔积适合验证"任意组合都不崩"这类性质,但组合数容易失控(3×3×3=27 条),要克制使用。

5.4 类级与模块级参数化

parametrize 标记可以放在类上,类内所有测试都会收到参数;放在模块级则赋给约定变量 pytestmark

python
@pytest.mark.parametrize("n, expected", [(1, 2), (3, 4)])
class TestIncrement:
    def test_simple(self, n, expected):
        assert n + 1 == expected

    def test_weird(self, n, expected):
        assert (n * 1) + 1 == expected
python
# 模块级参数化:本文件所有测试都收到 n/expected
import pytest

pytestmark = pytest.mark.parametrize("n, expected", [(1, 2), (3, 4)])

5.5 何时参数化,何时拆分

判断标准是失败语义

  • 多组数据验证同一个行为规则 → 参数化(失败时你只关心哪条数据错了);
  • 不同行为/不同异常路径 → 拆成独立测试函数(失败时你关心的是哪个行为坏了)。

另外 pytest 9 还提供 subtests 能力作为参数化的替代方案(适合"循环内软断言、失败不中断"的场景),可在官方文档 How to use subtests(如何使用 subtests)一节了解。

5.6 本章小结

  • @pytest.mark.parametrize("a,b", [(..), ..]) 一份逻辑 × 数据表 = N 条独立用例,优于循环断言;
  • 用例 ID 可用 pytest.param(id=...)ids=函数 定制,非 ASCII 默认会被转义;
  • pytest.param(marks=...) 给单条数据叠加 skip/xfail 等标记;
  • 叠加多个装饰器得到笛卡尔积,注意组合数爆炸;
  • 参数值按引用传入,可变对象被修改会影响后续用例。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 参数化与在测试函数里 for 循环断言相比,核心优势是?

2. 叠加两个 @pytest.mark.parametrize(分别 2 个值和 3 个值),会生成多少条用例?

3. 想给参数化数据表中的某一条数据单独打上 xfail 标记,应该?

4. 关于参数化传入可变对象(如 list),官方提醒的正确理解是?

🛠️ 动手实践

  1. parse_iso_date(text) -> datetime.date 编写参数化测试:合法输入 3 条、非法输入(抛 ValueError)2 条,非法组用 pytest.raises 并配 id
  2. 用两个叠加的 parametrize 验证 abs(x * y) 对 x∈{-1, 0, 1}、y∈{2, 3} 恒不小于 0,先在纸上写出 6 个节点 ID 再运行核对。
  3. 把第 3 章"多断言聚合"的那个 schema 测试改造成参数化版本:每条数据是一个"坏字段 + 期望错误信息"。

数据驱动已就绪,下一章开始学习 pytest 最核心的依赖注入机制:fixture。