前言
pytest是第三方测试框架,需要单独安装(pip install pytest),标准库里没有它。它用到的「装饰器」其实分成两类,混在一起讲就很容易乱:
@pytest.fixture—— 定义夹具(fixture),也就是测试用的准备数据、临时资源。@pytest.mark.xxx——标记(mark),例如parametrize、skip、skipif、xfail。官方文档把这些统称为 marks。
有一个和普通装饰器很不一样的认知必须提前建立:这些装饰器不是「用包装函数把测试函数包起来」那套运行时逻辑。它们主要是给 pytest 的收集阶段(collection)留下信息,pytest 在运行测试前扫描模块、读取这些标记,再决定怎么建夹具、怎么展开参数、要不要跳过。所以你不要按处理普通装饰器的思路给它们加functools.wraps,也不需要理解它们返回什么。
下面所有 API 名称都按 pytest 官方文档核对过,但具体行为请以你所用版本的官方文档为准。本文示例按 pytest 7 以上、Python 3.8 以上书写。顺带一提,现在的 pytest 早就不支持 Python 2 了,而 Python 2 本身也已在 2020 年 1 月 1 日停止维护——看到print语句式的老测试代码,别照抄。
一、@pytest.fixture:夹具
夹具的核心用法是:测试函数只要把夹具名写成参数,pytest 就会先调用夹具并把返回值传进来。
# 适用于 Python 3.8+
import pytest
@pytest.fixture
def sample_data():
return [1, 2, 3]
def test_sum(sample_data):
assert sum(sample_data) == 6用yield做前后置清理——yield之前是准备,之后是清理,即使测试失败清理也会执行:
# 适用于 Python 3.8+
import pytest
@pytest.fixture
def temp_list():
data = []
print("准备")
yield data
print("清理")
data.clear()
def test_append(temp_list):
temp_list.append(1)
assert len(temp_list) == 1控制作用域。@pytest.fixture的scope决定夹具多久重建一次,默认是function(每个测试函数一次):
| scope 取值 | 生效范围 | 典型用途 |
|---|
"function" | 每个测试函数一次(默认) | 需要隔离的可变状态 |
"class" | 每个测试类一次 | 类级共享资源 |
"module" | 每个模块一次 | 模块级只读数据 |
"session" | 整个测试会话一次 | 数据库连接、浏览器实例 |
较新版本的 pytest 还提供"package"作用域,用到时请查对应版本文档。
# 适用于 Python 3.8+
import pytest
@pytest.fixture(scope="session")
def config():
return {"host": "localhost", "port": 8000}autouse=True让夹具自动对所有能看见它的测试生效,不必写进参数:
# 适用于 Python 3.8+
import pytest
@pytest.fixture(autouse=True)
def reset_counter():
state = {"n": 0}
yield state
state["n"] = 0参数化夹具:给夹具传params,pytest 会为每个参数值各跑一遍用它的测试,测试里用request.param取值:
# 适用于 Python 3.8+
import pytest
@pytest.fixture(params=[1, 2, 3])
def number(request):
return request.param
def test_positive(number):
assert number > 0这条测试会被收集成三个测试用例,分别对应1、2、3。
夹具写多了以后,把它们放进项目根目录或测试目录下的conftest.py,同一目录及子目录中的测试都能自动使用,不需要import。
二、@pytest.mark.parametrize:参数化
这是最常用的标记,让同一个测试逻辑跑多组输入。官方签名里第一个参数argnames接受逗号分隔的字符串,或字符串列表;第二个argvalues是值的列表。
# 适用于 Python 3.8+
import pytest
@pytest.mark.parametrize("a, b, expected", [
(1, 1, 2),
(2, 3, 5),
(-1, 1, 0),
])
def test_add(a, b, expected):
assert a + b == expected一次参数化三个参数,pytest 会按顺序把元组里的值分配给a、b、expected。想给每组用例起个可读的名字,用ids:
# 适用于 Python 3.8+
import pytest
@pytest.mark.parametrize("text, ok", [
("ann", True),
("1ann", False),
], ids=["valid", "starts-with-digit"])
def test_name(text, ok):
assert text.isidentifier() is ok@pytest.mark.parametrize可以叠加多次,效果是参数组合相乘;也可以把参数化标记写在测试类上,作用于类里全部方法。
三、跳过与预期失败
# 适用于 Python 3.8+
import sys
import pytest
@pytest.mark.skip(reason="功能尚未实现")
def test_not_ready():
assert False
@pytest.mark.skipif(sys.version_info < (3, 10), reason="需要 Python 3.10 及以上")
def test_new_syntax():
assert True
@pytest.mark.xfail(reason="已知缺陷,等待修复")
def test_known_bug():
assert 1 == 2三者的差别要说清楚:
skip:无条件跳过,直接不跑。skipif(condition, reason=...):condition为真时跳过,常用来按平台或 Python 版本决定。xfail:预期失败。测试照跑,失败算「符合预期」(标记为 xfailed),意外通过会记成 xpass。
四、自定义标记与注册
pytest.mark.slow这类自定义标记不会自动被识别,没注册过会给出警告。正确做法是在pytest.ini里登记:
[pytest]
markers =
slow: 标记运行较慢的测试
integration: 标记集成测试登记之后就能用命令行筛选:
pytest -m slow # 只跑 slow 标记的测试
pytest -m "not slow" # 跳过 slow 标记也可以把标记加在类或整个模块上(模块级需要用pytestmark变量)。
五、usefixtures:只借夹具不带参数
有时你只想「让夹具生效」,并不需要它返回的东西(尤其是autouse之外还需要显式触发的场景),就用@pytest.mark.usefixtures:
# 适用于 Python 3.8+
import pytest
@pytest.fixture
def prepare_dir(tmp_path):
target = tmp_path / "data"
target.mkdir()
return target
@pytest.mark.usefixtures("prepare_dir")
def test_something(tmp_path):
assert (tmp_path / "data").is_dir()注意usefixtures不把夹具的返回值传进来,测试函数要用返回值还是得写成参数。
六、一个完整的实战文件
# 适用于 Python 3.8+
import pytest
class Calculator:
def __init__(self, precision=2):
self.precision = precision
def divide(self, a, b):
if b == 0:
raise ZeroDivisionError("除数不能为零")
return round(a / b, self.precision)
@pytest.fixture
def calc():
return Calculator()
@pytest.mark.parametrize("a, b, expected", [
(10, 2, 5.0),
(1, 3, 0.33),
])
def test_divide_ok(calc, a, b, expected):
assert calc.divide(a, b) == expected
def test_divide_by_zero(calc):
with pytest.raises(ZeroDivisionError):
calc.divide(1, 0)
@pytest.mark.skipif(False, reason="示例:条件不成立时不跳过")
def test_precision():
assert Calculator(precision=4).divide(1, 3) == 0.3333pytest.raises是断言「一定会抛这个异常」的标准写法,比try/except手写更简洁也更可靠。
常见坑点
- 以为 pytest 装饰器是普通包装装饰器
❌ 给@pytest.fixture里的函数加functools.wraps想「保留元信息」 ✅ 它们由 pytest 在收集阶段解析,按官方文档的用法直接写即可
- 夹具名和参数名不一致
❌ 夹具叫db_conn,测试参数写成db✅ 参数名必须和夹具名完全相同,pytest 就是靠名字注入的
- 夹具里的清理写在
return之后
❌return data然后指望后面的data.clear()会执行 ✅ 需要清理就用yield:yield data,清理代码写在yield之后
scope和可变状态一起用
❌scope="session"的夹具返回可变列表,测试之间互相污染 ✅ 需要隔离的状态用默认的function作用域
- 自定义 mark 没注册
❌ 直接写@pytest.mark.slow,跑测试时满屏警告 ✅ 在pytest.ini的[pytest]里加markers登记
usefixtures当参数用
❌@pytest.mark.usefixtures("tmp")然后在函数体里用tmp✅ 要用返回值就必须写成函数参数;usefixtures只负责触发
parametrize的 argnames 与元组长度不匹配
❌ 声明"a, b"却传(1, 2, 3),收集时报错 ✅ 保证每组值的个数与参数名个数一致
- 用
skip掩盖真实失败
❌ 测试挂了就加@pytest.mark.skip让它变绿 ✅ 已知缺陷用xfail并写清reason,修好后能被发现
总结
| 装饰器 | 分类 | 作用 | 关键参数 |
|---|
@pytest.fixture | 夹具 | 注入准备数据/资源 | scope、params、autouse |
@pytest.mark.parametrize | 标记 | 同一逻辑跑多组数据 | argnames、argvalues、ids |
@pytest.mark.skip | 标记 | 无条件跳过 | reason |
@pytest.mark.skipif | 标记 | 条件跳过 | condition、reason |
@pytest.mark.xfail | 标记 | 预期失败 | reason、raises |
@pytest.mark.usefixtures | 标记 | 只触发夹具不带值 | 夹具名 |
把握两条就够:@pytest.fixture管「依赖从哪来」,@pytest.mark.*管「这个测试怎么跑」。夹具靠参数名注入,参数化靠argnames和argvalues对齐,跳过与预期失败要分清是「不跑」还是「跑但允许失败」——剩下都是查官方文档能确认的细节。