4、pytest固件(Fixture):作用域、yield与内置fixture

说到pytest,我最想跟你聊的就是Fixture。说实话,刚接触pytest那会儿,我总觉得Fixture不就是个setup和teardown吗?后来在项目中踩了几次坑才发现——这东西远比我想象的强大。

Fixture说白了就是测试的「前置条件」和「后置清理」。你想想看,每次测试前都要创建数据库连接、准备测试数据、启动某个服务……这些重复劳动,Fixture全帮你搞定了。

4.1 Fixture的作用域:别让数据「串门」

Fixture的作用域,我习惯用「生命周期」来理解。它决定了fixture在什么时候创建、什么时候销毁。

作用域 含义 适用场景
function 每个测试函数执行前后 临时文件、数据库事务
class 每个测试类执行前后 类级别的共享资源
module 每个测试模块执行前后 模块级别的配置加载
session 整个测试会话执行前后 全局配置、数据库连接池

默认是function作用域。我个人建议:能用function就别用session。为什么?因为作用域越大,测试之间的耦合就越强。我在项目中遇到过一个问题——两个测试用例共享了一个session级别的数据库连接,结果一个用例改了数据,另一个用例就挂了。排查了半天,最后发现是作用域搞的鬼。

核心原则:作用域越小,测试越独立。优先使用function,除非你有明确的共享需求。

来看个例子:

import pytest

@pytest.fixture(scope="function")
def temp_file():
    # 每个测试函数都会创建一个新文件
    file = open("test.txt", "w")
    yield file
    file.close()

def test_write(temp_file):
    temp_file.write("hello")
    assert temp_file.tell() == 5

def test_read(temp_file):
    # 这里temp_file是全新的,跟上一个测试没关系
    assert temp_file.tell() == 0

嗯,这里要注意:如果你用了scope="session",fixture只会执行一次。所有测试用例共享同一个实例。听起来很省事,但风险也大——一个用例改了状态,其他用例全受影响。

4.2 yield实现teardown:优雅的清理方式

以前写unittest的时候,setup和teardown是分开写的。pytest的yield方式,把前置和后置写在一起,代码更紧凑。我个人特别喜欢这种写法——逻辑一目了然。

@pytest.fixture
def db_connection():
    # setup:创建连接
    conn = create_database_connection()
    print("数据库连接已建立")
    
    yield conn  # 测试执行到这里
    
    # teardown:关闭连接
    conn.close()
    print("数据库连接已关闭")

yield前面的代码是setup,yield后面的代码是teardown。测试执行时,会先执行到yield,把conn交给测试函数,测试跑完后,再回来执行后面的清理代码。

小技巧:如果teardown过程中可能抛出异常,可以用try/finally包裹yield部分。这样即使测试失败,清理代码也能执行。

我曾经犯过一个错误——在teardown里忘记关闭文件句柄,结果跑了几百个测试后,系统报「打开文件过多」。后来用yield fixture重构,把关闭操作放在yield后面,再也没出过这个问题。

@pytest.fixture
def safe_resource():
    resource = acquire_resource()
    try:
        yield resource
    finally:
        # 无论如何都会执行清理
        release_resource(resource)

4.3 内置fixture:tmpdir和capsys

pytest自带了一些非常实用的fixture。我重点讲两个:tmpdircapsys。这两个我在项目中几乎天天用。

4.3.1 tmpdir:临时目录管理

测试中经常需要读写文件。如果每个测试都手动创建、删除临时目录,那代码会变得很臃肿。tmpdir帮你自动搞定这一切。

def test_file_operations(tmpdir):
    # tmpdir是一个py.path.local对象
    file_path = tmpdir.join("data.txt")
    file_path.write("测试数据")
    
    content = file_path.read()
    assert content == "测试数据"
    
    # 测试结束后,tmpdir自动删除

每个测试函数都会获得一个独立的临时目录。测试之间互不干扰。我建议你养成习惯——所有文件操作相关的测试,都用tmpdir。别偷懒写死路径,否则CI环境上跑一次就崩一次。

注意:tmpdir是函数作用域的。如果你在class或module作用域里用,记得显式指定scope。不过说实话,我很少这么干——临时目录嘛,用完就扔最省心。

4.3.2 capsys:捕获标准输出

有时候你需要测试函数打印的内容是否正确。capsys就是干这个的。它能捕获stdoutstderr的输出。

def greet(name):
    print(f"你好,{name}!")

def test_greet(capsys):
    greet("小明")
    captured = capsys.readouterr()
    assert captured.out == "你好,小明!\n"
    assert captured.err == ""

capsys.readouterr()返回一个namedtuple,包含outerr两个字段。调用一次后,缓冲区会被清空。如果你需要多次捕获,可以多次调用。

我记得有一次,一个同事写的日志模块在测试时总是不输出内容。排查了半天,发现是日志级别设置错了。用capsys一抓,立马现原形。嗯,这种问题用肉眼根本看不出来。

实用场景:测试命令行工具、验证日志输出、检查错误信息。说白了,只要你的代码跟「打印」有关,capsys就是你的好帮手。

4.4 综合实战:一个完整的例子

把上面几个知识点串起来,写一个完整的测试场景:

import pytest
import os

@pytest.fixture(scope="function")
def data_file(tmpdir):
    """创建一个测试数据文件"""
    file = tmpdir.join("test_data.csv")
    file.write("id,name\n1,Alice\n2,Bob")
    yield file
    # 清理工作由tmpdir自动完成

def test_read_csv(data_file, capsys):
    """测试读取CSV文件并打印"""
    with open(data_file, "r") as f:
        for line in f:
            print(line.strip())
    
    captured = capsys.readouterr()
    assert "Alice" in captured.out
    assert "Bob" in captured.out

def test_file_exists(data_file):
    """测试文件是否存在"""
    assert os.path.exists(data_file)
    # 测试结束后,tmpdir自动清理

这个例子展示了:

  • fixture组合使用——tmpdir作为基础fixture,data_file依赖它
  • yield实现teardown——虽然这里没显式写清理,但tmpdir自动做了
  • capsys捕获输出——验证打印内容是否正确

你想想看,如果没有这些fixture,你得手动创建临时目录、手动删除文件、手动重定向标准输出……代码量至少翻一倍。而且容易出错。

我的建议:刚开始用pytest时,先把内置fixture用熟。tmpdir、capsys、monkeypatch这几个,覆盖了大部分日常测试场景。等用顺手了,再自己写自定义fixture。

最后说一句:Fixture不是银弹。别为了用fixture而用fixture。如果一个测试只需要一个简单的参数,直接传参就行,没必要包装成fixture。保持简单,才是好代码。