上接 :《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 提供了两个杀手级特性来解决这个问题:
- ✅ 参数化(Parametrize):一组数据驱动,自动生成 N 个用例
- ✅ 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-a、1-b、2-a、2-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.ini 的 markers 里注册 |
| 参数元组长度不匹配 | 形参与实参数量不一致 | 检查 parametrize 的字符串与元组 |
| 组合爆炸跑太慢 | 多个 parametrize 叠加 | 精简数据或用 fixture 动态生成 |
-m 没筛到用例 |
mark 名拼写错误 | pytest --markers 查看已注册 mark |
| ids 重复 | 多组数据用了相同 id | 保证 ids 唯一 |
十四、小结
✅ 参数化 :一组数据驱动多个用例,消灭复制粘贴
✅ ids :给每组数据起名,报告更易读
✅ Mark :给用例打标签,实现按需分组执行
✅ 参数化 + Mark :真实项目的标准组合
✅ 务必在 pytest.ini 注册自定义 mark,避免告警
十五、思考题(欢迎评论区讨论)
- 什么场景下"参数组合笛卡尔积"会变成反模式?如何规避?
indirect=True的参数化解决了什么问题?能举个例子吗?- 你所在项目的 CI 是如何划分 smoke / regression 的?
实战练习:动手试试
在你本地第 2 篇的项目基础上:
- 给
calculator.py的mul函数写参数化测试 ,至少覆盖 5 组数据并用ids命名 - 把其中一组标记为
pytest.mark.xfail(比如故意写错 expected) - 在
pytest.ini注册smoke/regression,用-m分别运行两组 - 运行
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