上接:[《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 提供了两个杀手级特性来解决这个问题:
读完本篇,你就能把"复制粘贴的测试"重构为简洁、可维护、可分组的专业测试套件。
一、痛点:复制粘贴的测试
先看一段"真实但不优雅"的代码:
# 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 最简用法
把上面的三个用例合三为一:
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:
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 参数拆解
@pytest.mark.parametrize("参数名1, 参数名2", [ # ← 参数名字符串,逗号分隔
(值1, 值2), # ← 第一组
(值1, 值2), # ← 第二组
])
| 第一个参数(字符串) | 用逗号分隔的形参名,会注入到测试函数参数里 |
| 第二个参数(列表) | 一组或多组实参元组 |
| 元组长度 | 必须与形参名数量一致 |
⚠️ 常见错误:形参名写 "a, b" 但元组只给一个值 (3,) —— 会报 ValueError: not enough values。
三、参数化进阶
3.1 给每组数据起名字(ids)
默认用例名是 [1-2-3] 这种,可读性差。用 ids 改善:
@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
运行显示:
test_add[正数] PASSED
test_add[异号] PASSED
test_add[零值] PASSED
✅ 强烈推荐:用例报告里一眼看懂场景,排查更快。
3.2 参数用 pytest.param + mark
想给某组参数单独打标记(比如标记某组为"预期失败"):
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 叠加 = 所有组合:
@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-a、1-b、2-a、2-b。
⚠️ 组合爆炸要小心:3 组各 10 个参数 = 1000 个用例,运行会很慢。
3.4 参数化 + Fixture 结合
参数作为 Fixture 的输入,更灵活:
@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 速览
| @pytest.mark.skip | 无条件跳过 |
| @pytest.mark.skipif | 条件满足时跳过 |
| @pytest.mark.xfail | 预期失败(失败不算报错) |
| @pytest.mark.parametrize | 参数化(上节已讲) |
| @pytest.mark.usefixtures | 声明使用的 fixture |
示例:
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):
[pytest]
markers =
smoke: 冒烟测试,核心链路
regression: 回归测试
slow: 运行较慢的集成测试
api: 接口相关用例
7.2 使用 mark
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 运行
# 只跑冒烟
pytest -m smoke
# 跑冒烟 + 回归(OR)
pytest -m "smoke or regression"
# 跑回归但不跑慢的(AND + NOT)
pytest -m "regression and not slow"
✅ 这让你的 CI 流水线可以这样设计:
- 提交代码 → 跑 -m smoke(快,分钟级)
- 合并到主干 → 跑全量回归
八、Mark 进阶技巧
8.1 类级别 / 模块级别 mark
# 整个类的用例都带 mark
@pytest.mark.api
class TestUserAPI:
def test_get(self): ...
def test_post(self): ...
# 整个模块统一打 mark(写在文件顶部)
pytestmark = pytest.mark.regression
8.2 动态添加 mark
def test_dynamic(request):
if some_condition:
request.node.add_marker(pytest.mark.slow)
九、参数化 + Mark 组合实战
把两者结合,才是真实项目常见写法:
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/ 结构,本篇新增配置:
pytest-demo/
├── src/
│ └── calculator.py
├── tests/
│ ├── conftest.py
│ └── test_calc.py
├── pytest.ini
└── requirements.txt
pytest.ini 更新版:
[pytest]
testpaths = ["tests"]
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v –tb=short
markers =
smoke: 冒烟测试
regression: 回归测试
slow: 慢速测试
api: 接口相关
十一、完整运行效果示例
$ 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.ini 的 markers 里注册 |
| 参数元组长度不匹配 | 形参与实参数量不一致 | 检查 parametrize 的字符串与元组 |
| 组合爆炸跑太慢 | 多个 parametrize 叠加 | 精简数据或用 fixture 动态生成 |
| -m 没筛到用例 | mark 名拼写错误 | pytest –markers 查看已注册 mark |
| ids 重复 | 多组数据用了相同 id | 保证 ids 唯一 |
十四、小结
✅ 参数化:一组数据驱动多个用例,消灭复制粘贴
✅ ids:给每组数据起名,报告更易读
✅ Mark:给用例打标签,实现按需分组执行
✅ 参数化 + Mark:真实项目的标准组合
✅ 务必在 pytest.ini 注册自定义 mark,避免告警
十五、思考题(欢迎评论区讨论)
实战练习:动手试试
在你本地第 2 篇的项目基础上:
完成后欢迎在评论区贴出你的 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 筛选命令
pytest -m smoke # 只跑冒烟
pytest -m "smoke or regression" # 并集
pytest -m "regression and not slow" # 排除
pytest –markers # 查看所有已注册 mark
网硕互联帮助中心





评论前必须登录!
注册