第4章:pytest固件(Fixture)——从入门到精通

说实话,我刚接触pytest那会儿,最让我头疼的就是Fixture。当时我还在想,这不就是setup和teardown换个马甲吗?后来踩了不少坑才明白——Fixture才是pytest的灵魂。今天咱们就把它彻底搞明白。

4.1 什么是Fixture?为什么需要它?

Fixture,说白了就是测试的"前置条件"和"后置清理"。比如你要测试一个数据库操作,总得先连上数据库吧?测试完了总得断开连接吧?这就是Fixture干的事。

我习惯把Fixture理解成"测试的脚手架"。没有它,你的测试代码会变得又臭又长。举个例子:

# 没有Fixture的写法
def test_database():
    db = connect_to_database()
    result = db.query("SELECT * FROM users")
    db.close()
    assert len(result) > 0

# 每个测试都要重复写连接和关闭
def test_database_2():
    db = connect_to_database()
    result = db.query("SELECT * FROM orders")
    db.close()
    assert len(result) > 0

你看,重复代码满天飞。用Fixture就清爽多了:

import pytest

@pytest.fixture
def db():
    # setup:连接数据库
    db = connect_to_database()
    yield db  # 把db对象交给测试函数
    # teardown:关闭数据库
    db.close()

def test_database(db):
    result = db.query("SELECT * FROM users")
    assert len(result) > 0

def test_database_2(db):
    result = db.query("SELECT * FROM orders")
    assert len(result) > 0

嗯,这才是优雅的写法。代码量减半,可读性翻倍。

4.2 Fixture的作用域:控制生命周期

Fixture的作用域决定了它什么时候创建、什么时候销毁。我刚开始做项目时,所有Fixture都用默认作用域,结果跑1000个测试花了半小时——每个测试都重新连接数据库,你说慢不慢?

pytest支持四种作用域:

作用域 说明 适用场景
function 每个测试函数执行一次(默认) 临时文件、独立数据
class 每个测试类执行一次 类级别的共享资源
module 每个模块执行一次 模块级别的配置加载
session 整个测试会话只执行一次 数据库连接、全局配置

用法很简单,加个参数就行:

@pytest.fixture(scope="session")
def db_connection():
    """整个测试过程只连接一次数据库"""
    print("连接数据库...")
    conn = create_connection()
    yield conn
    print("关闭数据库连接...")
    conn.close()

@ pytest.fixture(scope="function")
def test_data(db_connection):
    """每个测试独立的数据"""
    data = generate_test_data()
    yield data
    clear_test_data(data)
我的经验:session级别的Fixture一定要慎用。我曾经把一个session级别的Fixture用在有状态的服务上,结果测试之间互相影响,排查了整整一天。记住:共享状态是万恶之源。

4.3 yield实现teardown:优雅的清理机制

你可能会问:为什么用yield而不是return?其实这就是pytest的巧妙之处。yield之前的代码是setup,yield之后的代码是teardown。一个函数搞定两件事。

我曾经见过有人这么写:

@pytest.fixture
def temp_file():
    file = create_temp_file()
    return file

def cleanup_temp_file():
    delete_temp_file()

然后每个测试都要手动调用cleanup。这不是给自己找麻烦吗?用yield多省心:

@pytest.fixture
def temp_file():
    file = create_temp_file()
    yield file  # 测试执行到这里
    delete_temp_file()  # 测试结束后自动清理

def test_write_file(temp_file):
    temp_file.write("hello")
    assert temp_file.read() == "hello"
    # 测试结束,自动删除临时文件
注意:如果yield之前的代码抛异常,yield之后的teardown代码不会执行。我曾经因为这个bug导致测试环境残留了大量临时文件。解决方案是用try/finally包裹:
@pytest.fixture
def safe_temp_file():
    try:
        file = create_temp_file()
        yield file
    finally:
        delete_temp_file()  # 无论如何都会执行

4.4 autouse:自动生效的Fixture

有些Fixture,你希望每个测试都自动使用,比如打印日志、设置环境变量。这时候autouse就派上用场了。

我记得有一次,测试环境需要设置一个全局的时区。如果每个测试都手动引入Fixture,那代码就太丑了。用autouse完美解决:

@pytest.fixture(autouse=True)
def set_timezone():
    """自动设置测试时区"""
    old_tz = os.environ.get('TZ')
    os.environ['TZ'] = 'Asia/Shanghai'
    time.tzset()
    yield
    if old_tz:
        os.environ['TZ'] = old_tz
    else:
        del os.environ['TZ']
    time.tzset()

def test_time():
    # 不需要显式传入set_timezone
    assert time.tzname[0] == 'CST'
避坑指南:autouse虽好,但不要滥用。我曾经在一个项目里加了5个autouse的Fixture,结果新同事接手时完全搞不清测试的依赖关系。建议只对"基础设施"级别的Fixture使用autouse。

4.5 Fixture参数化:一次定义,多次使用

这是Fixture最强大的功能之一。说白了,就是让同一个Fixture返回不同的数据,测试函数自动执行多次。

我做过一个支付系统的测试,需要测试不同金额、不同币种的场景。用参数化Fixture,代码量减少了80%:

@pytest.fixture(params=[
    {"amount": 100, "currency": "CNY"},
    {"amount": 50, "currency": "USD"},
    {"amount": 200, "currency": "EUR"},
])
def payment_data(request):
    """参数化的支付数据"""
    return request.param

def test_payment(payment_data):
    """自动执行3次,每次传入不同的数据"""
    result = process_payment(payment_data)
    assert result["status"] == "success"

你想想看,如果没有参数化,你得写3个几乎一模一样的测试函数。现在一个函数搞定,而且新增测试数据只需要在params里加一行。

更高级的用法是组合参数化:

@pytest.fixture(params=["local", "staging", "production"])
def environment(request):
    return request.param

@ pytest.fixture(params=["mysql", "postgresql", "sqlite"])
def database(request):
    return request.param

def test_database_connection(environment, database):
    """测试3x3=9种组合"""
    conn = connect(environment, database)
    assert conn.is_connected()
小技巧:参数化Fixture的测试报告会显示每个参数的值,方便定位失败原因。比如测试失败时会显示"test_database_connection[local-mysql]",一目了然。

4.6 实战:一个完整的Fixture示例

说了这么多,咱们来个综合案例。假设你要测试一个用户管理系统:

import pytest
import tempfile
import json

# session级别:整个测试只创建一次
@pytest.fixture(scope="session")
def app_config():
    return {
        "db_url": "sqlite:///test.db",
        "debug": True,
        "secret_key": "test-key"
    }

# module级别:每个模块创建一次临时数据库
@pytest.fixture(scope="module")
def database(app_config):
    db = create_database(app_config["db_url"])
    db.create_tables()
    yield db
    db.drop_tables()
    db.close()

# function级别:每个测试创建独立用户
@pytest.fixture
def test_user(database):
    user = database.create_user(
        username="test_user",
        email="test@example.com"
    )
    yield user
    database.delete_user(user.id)

# autouse:自动记录测试日志
@pytest.fixture(autouse=True)
def log_test():
    print(f"开始测试: {request.node.name}")
    yield
    print(f"测试结束: {request.node.name}")

# 参数化:测试不同角色
@pytest.fixture(params=["admin", "user", "guest"])
def user_role(request):
    return request.param

def test_user_permission(test_user, user_role):
    """测试不同角色的权限"""
    test_user.role = user_role
    permissions = test_user.get_permissions()
    assert len(permissions) > 0

def test_user_email(test_user):
    """测试用户邮箱"""
    assert "@" in test_user.email

这个例子涵盖了Fixture的所有核心用法。你仔细看看,每个Fixture各司其职,测试代码干净利落。这就是我为什么说Fixture是pytest的灵魂。

好了,关于Fixture的核心内容就这些。下一章咱们聊聊pytest的断言和异常处理,那又是另一番天地了。