4. pytest固件(fixture):fixture定义与作用域、yield实现清理操作、内置fixture(tmpdir, capsys)
好,咱们今天来聊聊 pytest 里一个非常核心的概念——fixture。说实话,我刚开始用 pytest 的时候,最头疼的就是测试数据怎么准备、用完怎么清理。后来搞懂了 fixture,才发现原来测试可以写得这么优雅。
fixture 说白了就是「测试的准备工作」。比如你要测一个数据库操作,得先连上数据库、建好表、插入几条测试数据——这些就是 fixture 干的事。pytest 帮你把这些准备工作抽出来,还能自动管理生命周期。
核心思想:fixture 就是测试的「前置条件」和「后置清理」的统一管理方案。
4.1 fixture 的定义与基本用法
定义一个 fixture 非常简单,用 @pytest.fixture 装饰器就行。我习惯把 fixture 放在 conftest.py 里,这样多个测试文件都能共享。
import pytest
@pytest.fixture
def user_data():
"""准备一个测试用户数据"""
return {"name": "张三", "age": 30, "email": "zhangsan@example.com"}
def test_user_info(user_data):
assert user_data["name"] == "张三"
assert user_data["age"] == 30
你看,测试函数直接声明参数 user_data,pytest 就会自动调用 fixture 函数,把返回值传进来。这就是依赖注入——测试函数不需要自己创建数据,只管用。
我的习惯:fixture 命名用名词,清晰表达「它是什么」。比如 db_connection、test_file、logged_in_user。别用 setup_database 这种动词命名,fixture 本身不是动作,是资源。
4.2 fixture 的作用域
fixture 有个很重要的概念叫作用域(scope)。它决定了 fixture 的生命周期——是每个测试函数都重新创建,还是只创建一次共享给所有测试。
pytest 支持四种作用域:
| 作用域 | 含义 | 适用场景 |
|---|---|---|
function |
每个测试函数执行一次 | 默认值,适合独立的数据准备 |
class |
每个测试类执行一次 | 类内测试共享同一个资源 |
module |
每个测试文件执行一次 | 文件级别的共享资源,比如数据库连接 |
session |
整个测试会话只执行一次 | 全局配置、昂贵的资源初始化 |
举个例子,假设你有个数据库连接,每次创建都很慢:
@pytest.fixture(scope="session")
def db_connection():
"""整个测试过程只连接一次数据库"""
print("\n[建立数据库连接]")
conn = create_database_connection()
yield conn
print("\n[关闭数据库连接]")
conn.close()
@ pytest.fixture(scope="function")
def clean_table(db_connection):
"""每个测试前清空表,保证数据隔离"""
db_connection.execute("TRUNCATE TABLE users")
yield
# 测试结束后不需要额外清理
这里有个坑,我踩过好几次——session 级别的 fixture 是共享的,测试之间会互相影响。如果你不小心修改了 fixture 返回的可变对象,后面的测试拿到的就是被改过的数据。所以 session 级别的 fixture 最好返回不可变对象,或者只读资源。
我曾经踩过的坑:有一次我把一个列表作为 session 级别的 fixture,结果测试 A 往列表里加了个元素,测试 B 拿到的列表就多了那个元素。排查了半天才发现是 fixture 共享导致的。后来我改成 function 级别,或者用 copy.deepcopy 返回副本,问题就解决了。
4.3 yield 实现清理操作
fixture 里用 yield 关键字,可以在测试结束后执行清理代码。这个设计非常巧妙——yield 之前的代码是「前置准备」,yield 之后的代码是「后置清理」。
@pytest.fixture
def temp_file():
"""创建临时文件,测试结束后自动删除"""
# 前置准备
file_path = "/tmp/test_data.txt"
with open(file_path, "w") as f:
f.write("测试数据")
# 把资源交给测试函数
yield file_path
# 后置清理
import os
os.remove(file_path)
print(f"临时文件 {file_path} 已删除")
def test_read_temp_file(temp_file):
with open(temp_file, "r") as f:
content = f.read()
assert content == "测试数据"
你想想看,如果没有 yield,你得在每个测试函数里手动写 try...finally 来清理。有了 yield,pytest 自动帮你管理——测试通过要清理,测试失败也要清理。这就是「确定性清理」。
yield 还可以返回值,也可以不返回。如果你只需要清理操作,不需要返回值,可以这样写:
@pytest.fixture
def reset_database():
"""只做清理,不返回数据"""
print("准备数据库...")
# 做一些初始化
yield # 不需要返回值
print("清理数据库...")
# 做清理工作
小技巧:如果清理逻辑比较复杂,可以用 addfinalizer 方法注册多个清理函数。但我个人更喜欢 yield,代码更直观,一上一下结构清晰。
4.4 内置 fixture:tmpdir 和 capsys
pytest 自带了一些非常实用的内置 fixture,省去了我们自己造轮子的麻烦。我重点讲两个最常用的:tmpdir 和 capsys。
4.4.1 tmpdir:临时目录管理
测试中经常需要读写文件,但直接写硬盘会污染环境,而且不同测试之间可能冲突。tmpdir 帮你自动创建临时目录,测试结束后自动删除。
def test_file_operations(tmpdir):
# tmpdir 是一个 py.path.local 对象
# 你可以像操作普通路径一样使用它
# 创建文件
test_file = tmpdir.join("hello.txt")
test_file.write("Hello, pytest!")
# 读取文件
content = test_file.read()
assert content == "Hello, pytest!"
# 创建子目录
sub_dir = tmpdir.mkdir("subfolder")
assert sub_dir.check(dir=True)
# 测试结束后,tmpdir 及其所有内容自动删除
tmpdir 有几个特点:
- 每个测试函数获得独立的临时目录,互不干扰
- 目录路径是唯一的,不会重复
- 测试结束后自动清理,不需要手动删除
- 返回的是
py.path.local对象,支持丰富的路径操作
如果你用 Python 3.5+,还可以用 tmp_path,它返回的是标准库的 pathlib.Path 对象:
def test_with_pathlib(tmp_path):
# tmp_path 是 pathlib.Path 对象
test_file = tmp_path / "data.txt"
test_file.write_text("pathlib 更现代")
assert test_file.read_text() == "pathlib 更现代"
我的建议:新项目直接用 tmp_path,pathlib 是 Python 官方推荐的路径处理方式。老项目如果兼容 Python 2,才用 tmpdir。
4.4.2 capsys:捕获标准输出和错误
有时候你要测试的函数会打印内容到控制台,或者写日志。capsys 可以捕获这些输出,让你断言输出内容是否正确。
def greet(name):
print(f"Hello, {name}!")
return len(name)
def test_greet_output(capsys):
# 调用函数,它的 print 输出会被 capsys 捕获
result = greet("Alice")
# 捕获输出
captured = capsys.readouterr()
# 断言输出内容
assert captured.out == "Hello, Alice!\n"
assert captured.err == "" # 没有错误输出
# 函数返回值不受影响
assert result == 5
capsys 还有一个兄弟叫 capfd,它捕获的是文件描述符级别的输出,能捕获 C 扩展库的 print 输出。不过大部分场景用 capsys 就够了。
我遇到过这样一个场景:测试一个命令行工具,它会打印进度条和日志。用 capsys 捕获后,我可以断言「是否输出了特定关键词」,比如:
def test_cli_progress(capsys):
run_cli_tool()
captured = capsys.readouterr()
assert "处理完成" in captured.out
assert "错误" not in captured.out
注意:capsys.readouterr() 每次调用会清空缓冲区。如果你在同一个测试里多次捕获,记得每次调用后保存结果。我刚开始用的时候没注意,第二次调用 readouterr 拿到的是空字符串,排查了半天才发现是缓冲区被清空了。
4.5 知识体系总览
为了让你更直观地理解 fixture 的整体结构,我画了一张图:
4.6 实战建议
最后,分享几个我在项目中积累的经验:
- fixture 不要写太复杂——一个 fixture 只做一件事。如果准备数据需要连数据库、创建文件、调用 API,拆成多个 fixture,然后用依赖注入组合起来。
- conftest.py 是共享的好地方——把公共 fixture 放在 conftest.py 里,同目录下的所有测试文件都能用。但别把所有 fixture 都塞进去,按模块拆分更清晰。
- scope 选型要谨慎——默认 function 级别最安全。只有确认资源可以共享,才提升到 module 或 session。我见过有人把可变对象设成 session 级别,结果测试之间互相污染,排查起来非常痛苦。
- yield 清理一定要健壮——清理代码里不要抛异常,否则会掩盖测试本身的错误。如果清理可能失败,用 try...except 包一下,至少保证不中断测试流程。
一个小技巧:调试 fixture 的时候,可以用 pytest --setup-show 查看 fixture 的执行顺序和生命周期。这个命令会打印出每个 fixture 何时被创建、何时被清理,对理解 fixture 的行为非常有帮助。
嗯,关于 fixture 的核心内容就这些了。说白了,fixture 就是帮你把测试的「家务活」自动化——准备数据、清理环境、管理资源。用好 fixture,你的测试代码会变得干净、可维护,而且不容易出错。
可可精品资料库