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 的整体结构,我画了一张图:

pytest Fixture 知识体系 @pytest.fixture 定义与作用域 function - 每个测试独立 class - 每个类共享 module - 每个文件共享 session - 全局共享 yield 实现清理 yield 前:前置准备 yield 返回值:测试使用 yield 后:后置清理 失败也会执行清理 内置 Fixture tmpdir / tmp_path 临时目录,自动清理 capsys / capfd 捕获 stdout/stderr 核心原则:资源管理自动化 前置准备 → 测试执行 → 后置清理,全由 pytest 管理

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,你的测试代码会变得干净、可维护,而且不容易出错。

可可精品资料库