pytest 参数化与 Mark 标记

上接《pytest 断言详解与 Fixture 入门》

适读人群 :已掌握 Fixture 基础,想写出"一组代码、多组数据"的同学

环境要求 :Python ≥ 3.10,pytest ≥ 8.0(推荐 9.x)

预计阅读:16 分钟


前言

第 2 篇我们解决了"怎么准备测试数据"(Fixture)。但马上会遇到一个新痛点:

同一个 add 函数,我想测 (1,2)(-1,1)(0,0)(100,200)......难道要复制粘贴 20 个 test_add_xxx

答案显然是否定的。pytest 提供了两个杀手级特性来解决这个问题:

  1. 参数化(Parametrize):一组数据驱动,自动生成 N 个用例
  2. Mark 标记:给用例打标签,按需分组执行

读完本篇,你就能把"复制粘贴的测试"重构为简洁、可维护、可分组的专业测试套件。


一、痛点:复制粘贴的测试

先看一段"真实但不优雅"的代码:

python 复制代码
# tests/test_calc.py
from src.calculator import add

def test_add_positive():
    assert add(1, 2) == 3

def test_add_negative():
    assert add(-1, 1) == 0

def test_add_zero():
    assert add(0, 0) == 0

问题很明显:

  • 🔴 重复:每个用例只有数据不同,结构完全一样
  • 🔴 难扩展:新增一组边界数据就要再加一个函数
  • 🔴 难维护:逻辑一变要改 N 处

这正是参数化的用武之地。


二、参数化基础:@pytest.mark.parametrize

2.1 最简用法

把上面的三个用例合三为一:

python 复制代码
import pytest
from src.calculator import add

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

运行 pytest -v

text 复制代码
tests/test_calc.py::test_add[1-2-3] PASSED       [ 33%]
tests/test_calc.py::test_add[-1-1-0] PASSED       [ 66%]
tests/test_calc.py::test_add[0-0-0] PASSED        [100%]

========================= 3 passed in 0.08s =========================

🎯 关键观察

  • 一个函数 → 自动变成 3 个用例
  • 用例名后带 [参数] 后缀,一眼看出是哪组数据
  • 哪组失败,直接定位到具体参数组合

2.2 参数拆解

python 复制代码
@pytest.mark.parametrize("参数名1, 参数名2", [  # ← 参数名字符串,逗号分隔
    (值1, 值2),                                   # ← 第一组
    (值1, 值2),                                   # ← 第二组
])
要素 说明
第一个参数(字符串) 用逗号分隔的形参名,会注入到测试函数参数里
第二个参数(列表) 一组或多组实参元组
元组长度 必须与形参名数量一致

⚠️ 常见错误:形参名写 "a, b" 但元组只给一个值 (3,) ------ 会报 ValueError: not enough values


三、参数化进阶

3.1 给每组数据起名字(ids)

默认用例名是 [1-2-3] 这种,可读性差。用 ids 改善:

python 复制代码
@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (-1, 1, 0),
    (0, 0, 0),
], ids=["正数", "异号", "零值"])
def test_add(a, b, expected):
    assert add(a, b) == expected

运行显示:

text 复制代码
test_add[正数] PASSED
test_add[异号] PASSED
test_add[零值] PASSED

强烈推荐:用例报告里一眼看懂场景,排查更快。

3.2 参数用 pytest.param + mark

想给某组参数单独打标记(比如标记某组为"预期失败"):

python 复制代码
import pytest
from src.calculator import div

@pytest.mark.parametrize("a, b, expected", [
    pytest.param(6, 2, 3, id="正常除法"),
    pytest.param(5, 2, 2.5, id="有余数"),
    pytest.param(1, 0, None, id="除零", marks=pytest.mark.xfail),
])
def test_div(a, b, expected):
    assert div(a, b) == expected

这里 pytest.param(..., marks=...) 给单组数据加 mark,xfail 表示"预期失败",pytest 会把它单独归类。

3.3 参数组合(笛卡尔积)

多个 parametrize 叠加 = 所有组合

python 复制代码
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", ["a", "b"])
def test_combo(x, y):
    print(f"{x}-{y}")

会生成 2 × 2 = 4 个用例:1-a1-b2-a2-b

⚠️ 组合爆炸要小心:3 组各 10 个参数 = 1000 个用例,运行会很慢。

3.4 参数化 + Fixture 结合

参数作为 Fixture 的输入,更灵活:

python 复制代码
@pytest.fixture
def user_data(request):
    return request.param   # ← 接收 parametrize 传入的值

@pytest.mark.parametrize("user_data", [
    {"name": "alice", "age": 20},
    {"name": "bob", "age": 30},
], indirect=True)   # ← 关键:把参数交给同名 fixture
def test_user(user_data):
    assert user_data["name"]

indirect=True 表示"这个参数不直接传给函数,而是传给同名 fixture"。适合需要在 fixture 里做预处理(如连接数据库)的场景。


四、参数化最佳实践速查

场景 推荐做法
多组数据测同一逻辑 @parametrize + ids
某组数据预期失败 pytest.param(..., marks=pytest.mark.xfail)
全组合覆盖 多个 parametrize 叠加(注意爆炸)
参数需预处理 indirect=True + fixture
大量数据(上百组) 数据放 YAML/JSON,循环生成

五、什么是 Mark?为什么需要它?

参数化解决"数据重复 ",Mark 解决"用例分组"。

现实项目中用例成千上万:

  • 冒烟测试(smoke)只要跑核心 10 个
  • 回归测试(regression)才跑全部
  • 慢速的集成测试只想在 CI 夜间跑

👉 Mark 就是给用例贴标签,运行时按标签筛选。


六、内置 Mark 速览

Mark 作用
@pytest.mark.skip 无条件跳过
@pytest.mark.skipif 条件满足时跳过
@pytest.mark.xfail 预期失败(失败不算报错)
@pytest.mark.parametrize 参数化(上节已讲)
@pytest.mark.usefixtures 声明使用的 fixture

示例:

python 复制代码
import sys
import pytest

@pytest.mark.skip(reason="功能未实现")
def test_todo():
    ...

@pytest.mark.skipif(sys.version_info < (3, 12), reason="需要 3.12+")
def test_new_feature():
    ...

@pytest.mark.xfail
def test_known_bug():
    assert 1 == 2   # 预期失败,报 XFAIL 而不是 FAILED

七、自定义 Mark:按业务分组

7.1 定义 mark

pytest.ini注册 (pytest 9.x 强制要求,否则报 PytestUnknownMarkWarning):

ini 复制代码
[pytest]
markers =
    smoke: 冒烟测试,核心链路
    regression: 回归测试
    slow: 运行较慢的集成测试
    api: 接口相关用例

7.2 使用 mark

python 复制代码
import pytest
from src.calculator import add

@pytest.mark.smoke
def test_add_core():
    assert add(1, 1) == 2

@pytest.mark.regression
@pytest.mark.slow
def test_add_boundary():
    assert add(-999, 999) == 0

7.3 按 mark 运行

bash 复制代码
# 只跑冒烟
pytest -m smoke

# 跑冒烟 + 回归(OR)
pytest -m "smoke or regression"

# 跑回归但不跑慢的(AND + NOT)
pytest -m "regression and not slow"

✅ 这让你的 CI 流水线可以这样设计:

  • 提交代码 → 跑 -m smoke(快,分钟级)
  • 合并到主干 → 跑全量回归

八、Mark 进阶技巧

8.1 类级别 / 模块级别 mark

python 复制代码
# 整个类的用例都带 mark
@pytest.mark.api
class TestUserAPI:
    def test_get(self): ...
    def test_post(self): ...

# 整个模块统一打 mark(写在文件顶部)
pytestmark = pytest.mark.regression

8.2 动态添加 mark

python 复制代码
def test_dynamic(request):
    if some_condition:
        request.node.add_marker(pytest.mark.slow)

九、参数化 + Mark 组合实战

把两者结合,才是真实项目常见写法:

python 复制代码
import pytest
from src.calculator import add

@pytest.mark.parametrize("a, b, expected", [
    pytest.param(1, 1, 2, id="core", marks=pytest.mark.smoke),
    pytest.param(100, 200, 300, id="big", marks=pytest.mark.regression),
    pytest.param(-1, -1, -2, id="negative", marks=pytest.mark.regression),
], ids=["core", "big", "negative"])
@pytest.mark.smoke
@pytest.mark.regression
def test_add(a, b, expected):
    assert add(a, b) == expected
  • 数据组层面:每组可单独标 smoke / regression
  • 函数层面:整体再叠加标签

十、项目结构(落地推荐)

沿用前两篇的 src/ + tests/ 结构,本篇新增配置:

text 复制代码
pytest-demo/
├── src/
│   └── calculator.py
├── tests/
│   ├── conftest.py
│   └── test_calc.py
├── pytest.ini
└── requirements.txt

pytest.ini 更新版:

ini 复制代码
[pytest]
testpaths = ["tests"]
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short

markers =
    smoke: 冒烟测试
    regression: 回归测试
    slow: 慢速测试
    api: 接口相关

十一、完整运行效果示例

bash 复制代码
$ pytest -v
============================= test session starts ==============================
collected 8 items

tests/test_calc.py::test_add[正数] PASSED         [ 12%]
tests/test_calc.py::test_add[异号] PASSED         [ 25%]
tests/test_calc.py::test_add[零值] PASSED         [ 37%]
tests/test_calc.py::test_div[正常除法] PASSED      [ 50%]
tests/test_calc.py::test_div[有余数] PASSED        [ 62%]
tests/test_calc.py::test_div[除零] XFAIL           [ 75%]
tests/test_calc.py::test_add_core PASSED           [ 87%]
tests/test_calc.py::test_add_boundary PASSED       [100%]

==================== 7 passed, 1 xfailed in 0.11s ==========================

注意 XFAIL:标记为 xfail 的用例失败是符合预期的,不算报错 ------ 这正是 mark 的价值。


十二、参数化 + Mark 决策图

下面这张图帮你判断"什么时候用什么":

核心判断

  • 痛点 = "同一逻辑多组数据" → 参数化
  • 痛点 = "只想跑一部分用例" → Mark 分组
  • 两者经常叠加使用

十三、常见坑与排错

问题 原因 解决
PytestUnknownMarkWarning mark 未注册 pytest.inimarkers 里注册
参数元组长度不匹配 形参与实参数量不一致 检查 parametrize 的字符串与元组
组合爆炸跑太慢 多个 parametrize 叠加 精简数据或用 fixture 动态生成
-m 没筛到用例 mark 名拼写错误 pytest --markers 查看已注册 mark
ids 重复 多组数据用了相同 id 保证 ids 唯一

十四、小结

参数化 :一组数据驱动多个用例,消灭复制粘贴

ids :给每组数据起名,报告更易读

Mark :给用例打标签,实现按需分组执行

参数化 + Mark :真实项目的标准组合

务必在 pytest.ini 注册自定义 mark,避免告警


十五、思考题(欢迎评论区讨论)

  1. 什么场景下"参数组合笛卡尔积"会变成反模式?如何规避?
  2. indirect=True 的参数化解决了什么问题?能举个例子吗?
  3. 你所在项目的 CI 是如何划分 smoke / regression 的?

实战练习:动手试试

在你本地第 2 篇的项目基础上:

  1. calculator.pymul 函数写参数化测试 ,至少覆盖 5 组数据并用 ids 命名
  2. 把其中一组标记为 pytest.mark.xfail(比如故意写错 expected)
  3. pytest.ini 注册 smoke / regression,用 -m 分别运行两组
  4. 运行 pytest -v,观察 XFAIL 与 PASSED 的区别

完成后欢迎在评论区贴出你的 parametrize 写法,我们一起交流 👇


下一篇预告

👉 《pytest Fixture 进阶:conftest 与依赖注入》

你将学到:

  • conftest.py 的层级与作用域
  • Fixture 之间的依赖组合
  • 如何用 Fixture 实现"测试数据工厂"


附录 A:参数化速查

写法 用途
@parametrize("a", [1,2,3]) 单参数
@parametrize("a,b", [(1,2),(3,4)]) 多参数
ids=[...] 给每组起名
pytest.param(..., marks=...) 单组加 mark
多个 @parametrize 叠加 笛卡尔积
indirect=True 参数交给 fixture

附录 B:常用 mark 筛选命令

bash 复制代码
pytest -m smoke                 # 只跑冒烟
pytest -m "smoke or regression" # 并集
pytest -m "regression and not slow"  # 排除
pytest --markers                # 查看所有已注册 mark
相关推荐
2601_962296283 小时前
五种Python自动化测试框架汇总,附学习方法
自动化测试·pytest·unittest·robotframework·python框架
11路没有终点1 天前
pytest Fixture 进阶:conftest 与依赖注入
pytest
2601_962387824 天前
web自动化测试实战教程【selenium/unittest/pytest】【共193课
selenium·pytest·devops·web自动化测试·unittest
weixin_440730504 天前
使用pytest中方法控制执行步骤(test_begin.py、test_end.py,@pytest.mark.run(order=1))
开发语言·python·pytest
2601_962297254 天前
Python、Pytest、Allure、Selenium和Jenkins实现自动化测试集成实例
python·selenium·jenkins·pytest·allure
2601_962077605 天前
从零搭建Python接口自动化测试框架:pytest+requests实战指南
python·pytest·接口自动化·requests·框架搭建
weixin_440730505 天前
pytest结合allure生成html测试报告(step、story、severity、screenshot)
前端·html·pytest·allure报告
拜托多多指教5 天前
【Pytest框架学习】分层架构
python·pytest
2601_962299885 天前
python脚本如何单元测试
python·单元测试·pytest·unittest·mock对象