07.pytest的fixture

Pytest 中的 Fixture 详解

1. 什么是 Fixture

1.1 基本概念

Fixture(夹具) 是 pytest 中一个非常重要的概念,它提供了一种机制来为测试用例提供前置准备后置清理的功能。你可以把 fixture 理解为测试用例的"准备工作"和"收尾工作"。

1.2 形象比喻

想象一下,你要做一道菜:

没有 Fixture 的情况

  • 每次做菜前,你都要自己去买菜、洗菜、切菜
  • 每次做完菜,你都要自己洗碗、收拾厨房
  • 如果要做 10 道菜,这些准备工作要重复 10 次

有 Fixture 的情况

  • 你有一个"助手"(fixture),它会自动帮你:
    • 在开始做菜前准备好所有食材(前置准备)
    • 在完成做菜后自动清理厨房(后置清理)
  • 你只需要专注于"做菜"本身(测试逻辑)
  • 无论做多少道菜,准备工作都只需要定义一次

1.3 Fixture 的作用

Fixture 主要有以下几个作用:

  1. 提供测试数据:为测试用例准备需要的数据
  2. 初始化资源:创建数据库连接、打开文件、启动服务等
  3. 清理资源:关闭连接、删除临时文件、停止服务等
  4. 共享代码:多个测试用例可以共享相同的准备和清理逻辑
  5. 依赖注入:将测试依赖项注入到测试函数中

1.4 为什么需要 Fixture

在实际测试中,你经常会遇到以下场景:

场景 1:数据库测试

# 没有 fixture 的情况
def test_create_user():
    # 每次都要手动连接数据库
    db = connect_database()
    # 执行测试
    result = create_user(db, "张三")
    assert result is not None
    # 每次都要手动关闭连接
    db.close()

def test_delete_user():
    # 又要重复连接数据库
    db = connect_database()
    # 执行测试
    result = delete_user(db, "张三")
    assert result is True
    # 又要重复关闭连接
    db.close()

问题

  • 代码重复:每个测试都要写连接和关闭的代码
  • 容易出错:可能忘记关闭连接
  • 维护困难:如果连接方式改变,要修改所有测试

使用 Fixture 后

@pytest.fixture
def db_connection():
    # 前置:连接数据库
    db = connect_database()
    yield db  # 提供给测试使用
    # 后置:关闭数据库
    db.close()

def test_create_user(db_connection):
    # 直接使用,不需要关心连接和关闭
    result = create_user(db_connection, "张三")
    assert result is not None

def test_delete_user(db_connection):
    # 直接使用,不需要关心连接和关闭
    result = delete_user(db_connection, "张三")
    assert result is True

优势

  • 代码复用:连接和关闭逻辑只写一次
  • 自动管理:pytest 自动处理资源的创建和清理
  • 易于维护:修改连接方式只需改一个地方

2. Fixture 的基本语法

2.1 最简单的 Fixture

2.1.1 定义 Fixture

使用 @pytest.fixture 装饰器来定义一个 fixture:

import pytest

@pytest.fixture
def my_data():
    """这是一个简单的 fixture"""
    data = "这是测试数据"
    return data

2.1.2 使用 Fixture

在测试函数中,将 fixture 的名称作为参数传入:

def test_example(my_data):
    """使用 fixture"""
    print(my_data)  # 输出:这是测试数据
    assert my_data == "这是测试数据"

2.1.3 完整示例

文件:test_simple_fixture.py

import pytest

# 定义 fixture
@pytest.fixture
def user_name():
    """返回一个用户名"""
    return "张三"

# 使用 fixture
def test_greeting(user_name):
    """测试问候语"""
    greeting = f"你好,{user_name}!"
    assert greeting == "你好,张三!"
    print(greeting)

def test_user_info(user_name):
    """测试用户信息"""
    info = f"当前用户:{user_name}"
    assert "张三" in info
    print(info)

运行测试

pytest test_simple_fixture.py -v

输出

test_simple_fixture.py::test_greeting PASSED
test_simple_fixture.py::test_user_info PASSED

2.2 Fixture 的执行流程

2.2.1 基本执行流程

import pytest

@pytest.fixture
def setup_data():
    print("【前置】准备数据")
    data = [1, 2, 3, 4, 5]
    return data
    print("这行代码不会执行,因为 return 已经结束了函数")

def test_sum(setup_data):
    print("【测试】执行测试")
    result = sum(setup_data)
    assert result == 15
    print(f"结果:{result}")

执行流程

  1. pytest 发现 test_sum 需要 setup_data fixture
  2. 执行 setup_data() 函数(前置准备)
  3. 将返回值传递给 test_sum 函数
  4. 执行 test_sum 函数(测试逻辑)
  5. 测试完成

输出

【前置】准备数据
【测试】执行测试
结果:15

2.3 使用 yield 实现后置清理

2.3.1 yield 的作用

yield 关键字可以将 fixture 分为两部分:

  • yield 之前:前置准备代码
  • yield 之后:后置清理代码
import pytest

@pytest.fixture
def file_handler():
    print("【前置】打开文件")
    file = open("test.txt", "w", encoding="utf-8")

    yield file  # 提供给测试使用

    print("【后置】关闭文件")
    file.close()

def test_write_file(file_handler):
    print("【测试】写入文件")
    file_handler.write("测试内容")
    file_handler.flush()
    assert True

执行流程

  1. 执行 file_handler fixture 的 yield 之前部分(打开文件)
  2. 将 file 对象传递给测试函数
  3. 执行测试函数(写入文件)
  4. 测试完成后,执行 yield 之后部分(关闭文件)

输出

【前置】打开文件
【测试】写入文件
【后置】关闭文件

2.3.2 yield 与 return 的区别

使用 return

@pytest.fixture
def data_with_return():
    print("准备数据")
    data = [1, 2, 3]
    return data
    print("这行不会执行")  # 永远不会执行

def test_example(data_with_return):
    assert len(data_with_return) == 3

使用 yield

@pytest.fixture
def data_with_yield():
    print("【前置】准备数据")
    data = [1, 2, 3]
    yield data
    print("【后置】清理数据")  # 测试完成后会执行

def test_example(data_with_yield):
    assert len(data_with_yield) == 3

关键区别

  • return:函数执行到 return 就结束了,后面的代码不会执行
  • yield:函数会暂停,测试执行完后继续执行 yield 后面的代码

2.3.3 实际应用示例:数据库连接

import pytest

class Database:
    def __init__(self):
        self.connected = False

    def connect(self):
        print("连接数据库...")
        self.connected = True

    def close(self):
        print("关闭数据库...")
        self.connected = False

@pytest.fixture
def db():
    """数据库 fixture"""
    database = Database()
    database.connect()  # 前置:连接数据库
    yield database      # 提供给测试使用
    database.close()    # 后置:关闭数据库

def test_query(db):
    """测试查询"""
    assert db.connected is True
    print("执行查询操作")

def test_insert(db):
    """测试插入"""
    assert db.connected is True
    print("执行插入操作")

运行结果

连接数据库...
执行查询操作
关闭数据库...
连接数据库...
执行插入操作
关闭数据库...

3. Fixture 的作用域(Scope)

3.1 什么是作用域

Fixture 的作用域决定了 fixture 在什么时候被创建和销毁。pytest 提供了 5 种作用域:

  1. function(默认):每个测试函数执行一次
  2. class:每个测试类执行一次
  3. module:每个测试模块(文件)执行一次
  4. package:每个包执行一次
  5. session:整个测试会话执行一次

3.2 function 作用域(默认)

3.2.1 基本说明

function 是默认作用域,每个测试函数都会执行一次 fixture。

import pytest

@pytest.fixture(scope="function")  # 可以省略,因为这是默认值
def counter():
    print("创建计数器")
    count = 0
    return count

def test_add_one(counter):
    counter += 1
    print(f"测试1:counter = {counter}")

def test_add_two(counter):
    counter += 2
    print(f"测试2:counter = {counter}")

执行结果

创建计数器
测试1:counter = 1
创建计数器
测试2:counter = 2

说明:每个测试函数都会创建一个新的 counter,它们之间互不影响。

3.2.2 使用场景

  • 每个测试需要独立的数据
  • 测试之间不应该相互影响
  • 需要清理的资源(如临时文件)

示例

import pytest
import os

@pytest.fixture(scope="function")
def temp_file():
    """为每个测试创建临时文件"""
    filename = "temp_test.txt"
    with open(filename, "w", encoding="utf-8") as f:
        f.write("初始内容")
    yield filename
    # 后置清理
    if os.path.exists(filename):
        os.remove(filename)
        print(f"删除临时文件:{filename}")

def test_read_file(temp_file):
    with open(temp_file, "r", encoding="utf-8") as f:
        content = f.read()
    assert "初始内容" in content

def test_write_file(temp_file):
    with open(temp_file, "w", encoding="utf-8") as f:
        f.write("新内容")
    with open(temp_file, "r", encoding="utf-8") as f:
        content = f.read()
    assert "新内容" in content

3.3 class 作用域

3.3.1 基本说明

class 作用域表示每个测试类只执行一次 fixture。

import pytest

@pytest.fixture(scope="class")
def class_setup():
    print("【类级别】初始化")
    data = {"users": []}
    yield data
    print("【类级别】清理")

class TestUserManagement:
    def test_add_user(self, class_setup):
        class_setup["users"].append("张三")
        print(f"用户列表:{class_setup['users']}")
        assert len(class_setup["users"]) == 1

    def test_add_another_user(self, class_setup):
        class_setup["users"].append("李四")
        print(f"用户列表:{class_setup['users']}")
        assert len(class_setup["users"]) == 2  # 注意:这里会是2,因为共享同一个fixture

class TestProductManagement:
    def test_add_product(self, class_setup):
        # 这是一个新的类,会创建新的fixture实例
        class_setup["products"] = ["商品1"]
        print(f"商品列表:{class_setup.get('products', [])}")

执行结果

【类级别】初始化
用户列表:['张三']
用户列表:['张三', '李四']
【类级别】清理
【类级别】初始化
商品列表:['商品1']
【类级别】清理

说明

  • TestUserManagement 类中的两个测试共享同一个 class_setup
  • TestProductManagement 类会创建新的 class_setup 实例

3.3.2 使用场景

  • 一个类中的所有测试需要共享相同的初始化
  • 类的测试需要共享状态
  • 减少重复的初始化操作

示例

import pytest

@pytest.fixture(scope="class")
def database():
    """为整个测试类创建数据库连接"""
    print("连接数据库")
    db = {"connection": "active", "tables": []}
    yield db
    print("关闭数据库连接")

class TestDatabaseOperations:
    def test_create_table(self, database):
        database["tables"].append("users")
        assert len(database["tables"]) == 1

    def test_create_another_table(self, database):
        database["tables"].append("products")
        assert len(database["tables"]) == 2  # 共享同一个database

    def test_list_tables(self, database):
        assert len(database["tables"]) == 2  # 可以看到前面创建的两个表

3.4 module 作用域

3.4.1 基本说明

module 作用域表示每个测试模块(文件)只执行一次 fixture。

import pytest

@pytest.fixture(scope="module")
def module_data():
    print("【模块级别】初始化数据")
    data = {"count": 0}
    yield data
    print("【模块级别】清理数据")

def test_increment_one(module_data):
    module_data["count"] += 1
    assert module_data["count"] == 1

def test_increment_two(module_data):
    module_data["count"] += 2
    assert module_data["count"] == 3  # 注意:是3,因为共享同一个fixture

def test_increment_three(module_data):
    module_data["count"] += 3
    assert module_data["count"] == 6  # 继续累加

执行结果

【模块级别】初始化数据
【模块级别】清理数据

说明:整个模块中的所有测试共享同一个 module_data fixture。

3.4.2 使用场景

  • 整个模块的测试需要共享资源(如数据库连接、配置文件)
  • 减少重复的初始化操作
  • 模块级别的设置和清理

示例

import pytest
import json

@pytest.fixture(scope="module")
def config():
    """加载配置文件(整个模块只需要加载一次)"""
    print("加载配置文件")
    with open("config.json", "r", encoding="utf-8") as f:
        config_data = json.load(f)
    yield config_data
    print("清理配置")

def test_api_url(config):
    assert "api_url" in config
    print(f"API地址:{config['api_url']}")

def test_database_config(config):
    assert "database" in config
    print(f"数据库配置:{config['database']}")

def test_other_config(config):
    assert "other" in config
    print(f"其他配置:{config['other']}")

3.5 session 作用域

3.5.1 基本说明

session 作用域表示整个测试会话(一次 pytest 运行)只执行一次 fixture。

文件1:test_login.py

import pytest

@pytest.fixture(scope="session")
def login_session():
    print("【会话级别】用户登录")
    user = {"username": "test_user", "logged_in": True}
    yield user
    print("【会话级别】用户登出")

def test_check_login(login_session):
    assert login_session["logged_in"] is True

def test_user_info(login_session):
    assert login_session["username"] == "test_user"

文件2:test_profile.py

import pytest

# 注意:这里不需要重新定义 fixture,可以从 conftest.py 导入
# 或者如果定义在同一个包中,可以直接使用

def test_update_profile(login_session):  # 使用同一个 fixture
    assert login_session["logged_in"] is True
    login_session["profile_updated"] = True

def test_view_profile(login_session):
    assert login_session.get("profile_updated") is True

执行结果(运行两个文件):

【会话级别】用户登录
(执行所有测试)
【会话级别】用户登出

说明:无论有多少个测试文件,login_session fixture 只会创建一次。

3.5.2 使用场景

  • 整个测试套件需要共享的资源(如全局配置、登录状态)
  • 昂贵的初始化操作(如启动服务器、连接外部服务)
  • 测试之间的状态共享

示例

import pytest

@pytest.fixture(scope="session")
def test_server():
    """启动测试服务器(整个会话只需要启动一次)"""
    print("启动测试服务器...")
    server = {"url": "http://localhost:8080", "status": "running"}
    yield server
    print("关闭测试服务器...")
    server["status"] = "stopped"

# 在多个文件中都可以使用 test_server

3.6 作用域对比总结

作用域 执行次数 使用场景 示例
function 每个测试函数一次 每个测试需要独立数据 临时文件、独立数据库连接
class 每个测试类一次 类中测试共享资源 类的初始化、共享状态
module 每个测试文件一次 文件内测试共享资源 配置文件、模块级连接
package 每个包一次 包内测试共享资源 包级配置
session 整个会话一次 全局共享资源 登录状态、全局服务器

3.7 作用域的实际应用示例

import pytest
import time

# function 级别:每个测试都需要独立的计时器
@pytest.fixture(scope="function")
def timer():
    start = time.time()
    yield start
    end = time.time()
    print(f"测试耗时:{end - start:.2f}秒")

# class 级别:整个类共享一个用户
@pytest.fixture(scope="class")
def test_user():
    print("创建测试用户")
    user = {"id": 1, "name": "测试用户"}
    yield user
    print("删除测试用户")

# module 级别:整个模块共享数据库连接
@pytest.fixture(scope="module")
def db_connection():
    print("连接数据库")
    db = {"connected": True}
    yield db
    print("断开数据库")

# session 级别:整个会话共享登录状态
@pytest.fixture(scope="session")
def auth_token():
    print("获取认证令牌")
    token = "abc123xyz"
    yield token
    print("清除认证令牌")

class TestUserOperations:
    def test_create_user(self, timer, test_user, db_connection, auth_token):
        # 所有 fixture 都可以使用
        assert test_user["name"] == "测试用户"
        assert db_connection["connected"] is True
        assert auth_token == "abc123xyz"

    def test_update_user(self, timer, test_user, db_connection, auth_token):
        # test_user 是共享的(class级别)
        # db_connection 是共享的(module级别)
        # auth_token 是共享的(session级别)
        # timer 是新的(function级别)
        test_user["name"] = "更新后的用户"
        assert test_user["name"] == "更新后的用户"

4. Fixture 的参数化

4.1 什么是 Fixture 参数化

Fixture 参数化允许你为同一个 fixture 提供多组不同的数据,pytest 会为每组数据运行一次测试。

4.2 基本语法

使用 @pytest.fixture(params=...) 来参数化 fixture:

import pytest

@pytest.fixture(params=["chrome", "firefox", "edge"])
def browser(request):
    """参数化的浏览器 fixture"""
    browser_name = request.param
    print(f"启动浏览器:{browser_name}")
    yield browser_name
    print(f"关闭浏览器:{browser_name}")

def test_open_page(browser):
    """这个测试会运行3次,每次使用不同的浏览器"""
    print(f"在 {browser} 中打开页面")
    assert browser in ["chrome", "firefox", "edge"]

执行结果

启动浏览器:chrome
在 chrome 中打开页面
关闭浏览器:chrome
启动浏览器:firefox
在 firefox 中打开页面
关闭浏览器:firefox
启动浏览器:edge
在 edge 中打开页面
关闭浏览器:edge

4.3 request.param 详解

request 是 pytest 提供的一个内置 fixture,它包含了当前测试请求的信息。request.param 就是当前参数的值。

import pytest

@pytest.fixture(params=[1, 2, 3, 4, 5])
def number(request):
    """参数化的数字 fixture"""
    num = request.param
    print(f"当前参数值:{num}")
    return num

def test_square(number):
    """测试平方"""
    result = number ** 2
    print(f"{number} 的平方是 {result}")
    assert result == number * number

4.4 参数化的实际应用

4.4.1 测试不同的数据库

import pytest

@pytest.fixture(params=["mysql", "postgresql", "sqlite"])
def database(request):
    """参数化的数据库 fixture"""
    db_type = request.param
    print(f"连接 {db_type} 数据库")

    # 模拟数据库连接
    db = {
        "type": db_type,
        "connected": True
    }

    yield db

    print(f"断开 {db_type} 数据库连接")
    db["connected"] = False

def test_query(database):
    """测试查询操作"""
    assert database["connected"] is True
    print(f"在 {database['type']} 中执行查询")

def test_insert(database):
    """测试插入操作"""
    assert database["connected"] is True
    print(f"在 {database['type']} 中执行插入")

说明:每个测试函数都会在三种数据库上各运行一次,总共运行 6 次(2个测试 × 3个数据库)。

4.4.2 测试不同的用户角色

import pytest

@pytest.fixture(params=[
    {"role": "admin", "permissions": ["read", "write", "delete"]},
    {"role": "user", "permissions": ["read"]},
    {"role": "guest", "permissions": []}
])
def user_role(request):
    """参数化的用户角色 fixture"""
    role_data = request.param
    print(f"创建 {role_data['role']} 用户")
    yield role_data
    print(f"清理 {role_data['role']} 用户")

def test_read_permission(user_role):
    """测试读取权限"""
    if "read" in user_role["permissions"]:
        assert True
        print(f"{user_role['role']} 有读取权限")
    else:
        assert False, f"{user_role['role']} 没有读取权限"

def test_write_permission(user_role):
    """测试写入权限"""
    has_write = "write" in user_role["permissions"]
    if user_role["role"] == "admin":
        assert has_write is True
    else:
        assert has_write is False

4.4.3 组合参数化

你可以让测试函数使用多个参数化的 fixture,pytest 会生成所有可能的组合:

import pytest

@pytest.fixture(params=["GET", "POST", "PUT"])
def http_method(request):
    """HTTP 方法参数化"""
    return request.param

@pytest.fixture(params=["/api/users", "/api/products", "/api/orders"])
def api_endpoint(request):
    """API 端点参数化"""
    return request.param

def test_api_request(http_method, api_endpoint):
    """测试 API 请求(会运行 3×3=9 次)"""
    print(f"发送 {http_method} 请求到 {api_endpoint}")
    assert http_method in ["GET", "POST", "PUT"]
    assert api_endpoint.startswith("/api/")

说明:这个测试会运行 9 次(3种方法 × 3个端点)。


5. Fixture 的依赖和嵌套

5.1 Fixture 依赖其他 Fixture

Fixture 可以依赖其他 fixture,只需要在 fixture 函数的参数中声明即可。

5.1.1 基本依赖

import pytest

@pytest.fixture
def database_url():
    """提供数据库URL"""
    return "mysql://localhost:3306/testdb"

@pytest.fixture
def database_connection(database_url):
    """依赖 database_url fixture"""
    print(f"使用 {database_url} 连接数据库")
    connection = {"url": database_url, "connected": True}
    yield connection
    print("关闭数据库连接")
    connection["connected"] = False

def test_query(database_connection):
    """使用依赖链"""
    assert database_connection["connected"] is True
    assert "mysql://" in database_connection["url"]

执行流程

  1. pytest 发现 test_query 需要 database_connection
  2. pytest 发现 database_connection 需要 database_url
  3. 先执行 database_url() fixture
  4. 将结果传递给 database_connection() fixture
  5. 将结果传递给 test_query() 测试函数

5.1.2 多层依赖

import pytest

@pytest.fixture
def config():
    """配置 fixture"""
    return {"host": "localhost", "port": 8080}

@pytest.fixture
def server_url(config):
    """服务器URL fixture(依赖 config)"""
    return f"http://{config['host']}:{config['port']}"

@pytest.fixture
def api_client(server_url):
    """API客户端 fixture(依赖 server_url)"""
    print(f"创建API客户端,连接到 {server_url}")
    client = {"url": server_url, "authenticated": True}
    yield client
    print("关闭API客户端")
    client["authenticated"] = False

def test_api_call(api_client):
    """测试API调用"""
    assert api_client["authenticated"] is True
    assert "http://" in api_client["url"]

依赖链configserver_urlapi_clienttest_api_call

5.2 使用 autouse 自动使用 Fixture

5.2.1 什么是 autouse

autouse=True 表示 fixture 会自动执行,不需要在测试函数中声明。

import pytest

@pytest.fixture(autouse=True)
def setup_and_teardown():
    """自动执行的 fixture"""
    print("【自动】测试开始前的准备")
    yield
    print("【自动】测试结束后的清理")

def test_one():
    """不需要声明 fixture,但会自动执行"""
    print("执行测试1")
    assert True

def test_two():
    """不需要声明 fixture,但会自动执行"""
    print("执行测试2")
    assert True

执行结果

【自动】测试开始前的准备
执行测试1
【自动】测试结束后的清理
【自动】测试开始前的准备
执行测试2
【自动】测试结束后的清理

5.2.2 autouse 的使用场景

场景1:全局日志设置

import pytest
import logging

@pytest.fixture(autouse=True)
def setup_logging():
    """自动设置日志"""
    logging.basicConfig(level=logging.INFO)
    print("日志系统已初始化")
    yield
    print("日志系统已关闭")

def test_with_logging():
    """所有测试都会自动有日志功能"""
    logging.info("这是一条日志信息")
    assert True

场景2:测试环境检查

import pytest
import os

@pytest.fixture(autouse=True)
def check_test_environment():
    """自动检查测试环境"""
    assert os.getenv("TEST_ENV") is not None, "测试环境未设置"
    print("测试环境检查通过")
    yield
    print("测试环境检查完成")

def test_something():
    """自动进行环境检查"""
    assert True

场景3:清理临时文件

import pytest
import os
import tempfile

@pytest.fixture(autouse=True, scope="function")
def cleanup_temp_files():
    """自动清理临时文件"""
    yield
    # 测试结束后清理
    temp_dir = tempfile.gettempdir()
    for file in os.listdir(temp_dir):
        if file.startswith("test_"):
            file_path = os.path.join(temp_dir, file)
            if os.path.isfile(file_path):
                os.remove(file_path)
                print(f"删除临时文件:{file_path}")

5.3 Fixture 的嵌套使用

5.3.1 基本嵌套

import pytest

@pytest.fixture
def outer_fixture():
    print("外层 fixture 开始")
    outer_data = "外层数据"
    yield outer_data
    print("外层 fixture 结束")

@pytest.fixture
def inner_fixture(outer_fixture):
    print("内层 fixture 开始")
    inner_data = f"{outer_fixture} + 内层数据"
    yield inner_data
    print("内层 fixture 结束")

def test_nested(inner_fixture):
    print(f"测试使用:{inner_fixture}")
    assert "外层数据" in inner_fixture
    assert "内层数据" in inner_fixture

执行结果

外层 fixture 开始
内层 fixture 开始
测试使用:外层数据 + 内层数据
内层 fixture 结束
外层 fixture 结束

5.3.2 复杂嵌套示例

import pytest

@pytest.fixture
def base_config():
    """基础配置"""
    return {"app_name": "MyApp", "version": "1.0"}

@pytest.fixture
def database_config(base_config):
    """数据库配置(依赖基础配置)"""
    config = base_config.copy()
    config["database"] = {"host": "localhost", "port": 3306}
    return config

@pytest.fixture
def api_config(database_config):
    """API配置(依赖数据库配置)"""
    config = database_config.copy()
    config["api"] = {"base_url": "http://api.example.com"}
    return config

@pytest.fixture
def full_app(api_config):
    """完整应用(依赖API配置)"""
    app = {
        "config": api_config,
        "initialized": True
    }
    yield app
    app["initialized"] = False

def test_app(full_app):
    """测试应用"""
    assert full_app["initialized"] is True
    assert full_app["config"]["app_name"] == "MyApp"
    assert "database" in full_app["config"]
    assert "api" in full_app["config"]

6. conftest.py 文件

6.1 什么是 conftest.py

conftest.py 是 pytest 的一个特殊文件,用于存放共享的 fixture。pytest 会自动发现这个文件并加载其中的 fixture。

6.2 conftest.py 的作用

  1. 共享 fixture:多个测试文件可以共享同一个 fixture
  2. 组织代码:将 fixture 从测试文件中分离出来
  3. 作用域控制:fixture 的作用域可以覆盖整个目录及其子目录

6.3 基本使用

6.3.1 创建 conftest.py

目录结构

project/
├── conftest.py          # 共享的 fixture
├── test_login.py        # 测试文件1
└── test_logout.py       # 测试文件2

conftest.py

import pytest

@pytest.fixture
def user_data():
    """共享的用户数据 fixture"""
    return {"username": "test_user", "password": "test123"}

@pytest.fixture
def login_session(user_data):
    """共享的登录会话 fixture"""
    print(f"用户 {user_data['username']} 登录")
    session = {"user": user_data, "logged_in": True}
    yield session
    print(f"用户 {user_data['username']} 登出")
    session["logged_in"] = False

test_login.py

def test_login(login_session):
    """测试登录"""
    assert login_session["logged_in"] is True
    assert login_session["user"]["username"] == "test_user"

test_logout.py

def test_logout(login_session):
    """测试登出"""
    assert login_session["logged_in"] is True
    # 执行登出操作
    login_session["logged_in"] = False
    assert login_session["logged_in"] is False

说明:两个测试文件都可以直接使用 login_session fixture,不需要导入。

6.3.2 conftest.py 的作用域

conftest.py 中的 fixture 可以被同一目录及子目录中的测试文件使用。

目录结构

project/
├── conftest.py              # 根级 conftest
├── test_a.py
├── api/
│   ├── conftest.py         # API 子目录的 conftest
│   ├── test_api1.py
│   └── test_api2.py
└── ui/
    ├── conftest.py         # UI 子目录的 conftest
    └── test_ui.py

根级 conftest.py

import pytest

@pytest.fixture(scope="session")
def global_config():
    """全局配置,所有测试都可以使用"""
    return {"app_name": "MyApp"}

api/conftest.py

import pytest

@pytest.fixture
def api_client():
    """API客户端,只有 api 目录下的测试可以使用"""
    return {"base_url": "http://api.example.com"}

test_a.py(在根目录):

def test_global(global_config):
    """可以使用根级 conftest 的 fixture"""
    assert global_config["app_name"] == "MyApp"

def test_api(api_client):
    """不能使用 api 子目录的 fixture,会报错"""
    pass  # 这会失败

api/test_api1.py

def test_with_global(global_config):
    """可以使用根级 conftest 的 fixture"""
    assert global_config["app_name"] == "MyApp"

def test_with_api(api_client):
    """可以使用 api 子目录的 conftest 的 fixture"""
    assert "api.example.com" in api_client["base_url"]

6.4 conftest.py 的实际应用

6.4.1 数据库连接

conftest.py

import pytest

@pytest.fixture(scope="session")
def database():
    """全局数据库连接"""
    print("连接数据库")
    db = {
        "host": "localhost",
        "port": 3306,
        "connected": True
    }
    yield db
    print("断开数据库连接")
    db["connected"] = False

@pytest.fixture
def db_session(database):
    """数据库会话(每个测试一个)"""
    print("开始数据库事务")
    yield database
    print("回滚数据库事务")

test_users.py

def test_create_user(db_session):
    """创建用户"""
    assert db_session["connected"] is True
    # 执行创建操作

def test_delete_user(db_session):
    """删除用户"""
    assert db_session["connected"] is True
    # 执行删除操作

6.4.2 API 测试配置

conftest.py

import pytest

@pytest.fixture(scope="session")
def api_base_url():
    """API 基础URL"""
    return "https://api.example.com"

@pytest.fixture(scope="session")
def auth_token():
    """认证令牌"""
    # 实际项目中,这里会调用登录接口获取token
    return "test_token_12345"

@pytest.fixture
def api_headers(auth_token):
    """API 请求头"""
    return {
        "Authorization": f"Bearer {auth_token}",
        "Content-Type": "application/json"
    }

test_api.py

import requests

def test_get_users(api_base_url, api_headers):
    """获取用户列表"""
    response = requests.get(
        f"{api_base_url}/users",
        headers=api_headers
    )
    assert response.status_code == 200

def test_create_user(api_base_url, api_headers):
    """创建用户"""
    data = {"name": "新用户", "email": "new@example.com"}
    response = requests.post(
        f"{api_base_url}/users",
        json=data,
        headers=api_headers
    )
    assert response.status_code == 201

7. Pytest 内置 Fixture

7.1 什么是内置 Fixture

Pytest 提供了一些内置的 fixture,可以直接使用,无需定义。这些 fixture 提供了测试中常用的功能。

7.2 常用的内置 Fixture

7.2.1 tmp_path 和 tmp_path_factory

tmp_path:为每个测试函数提供一个临时目录路径(Path 对象)。

def test_write_file(tmp_path):
    """使用临时目录"""
    # tmp_path 是一个 Path 对象
    file_path = tmp_path / "test.txt"
    file_path.write_text("测试内容", encoding="utf-8")

    # 读取文件
    content = file_path.read_text(encoding="utf-8")
    assert content == "测试内容"

    # 测试结束后,临时目录会自动删除

tmp_path_factory:用于创建临时目录,通常用于 session 或 module 级别的 fixture。

@pytest.fixture(scope="module")
def shared_temp_dir(tmp_path_factory):
    """模块级别的临时目录"""
    temp_dir = tmp_path_factory.mktemp("shared_data")
    return temp_dir

def test_one(shared_temp_dir):
    file1 = shared_temp_dir / "file1.txt"
    file1.write_text("文件1")
    assert file1.exists()

def test_two(shared_temp_dir):
    file2 = shared_temp_dir / "file2.txt"
    file2.write_text("文件2")
    assert file2.exists()
    # file1 仍然存在,因为共享同一个临时目录

7.2.2 tmpdir 和 tmpdir_factory

tmpdir:与 tmp_path 类似,但返回的是 py.path.local 对象(旧版 API,推荐使用 tmp_path)。

def test_old_way(tmpdir):
    """旧版临时目录(不推荐,但可能遇到)"""
    file_path = tmpdir.join("test.txt")
    file_path.write("测试内容")
    assert file_path.read() == "测试内容"

7.2.3 capsys 和 capsysbinary

capsys:捕获标准输出和标准错误。

def test_output(capsys):
    """测试输出"""
    print("这是标准输出")
    print("这是错误输出", file=sys.stderr)

    # 捕获输出
    captured = capsys.readouterr()
    assert "这是标准输出" in captured.out
    assert "这是错误输出" in captured.err

capsysbinary:捕获二进制输出。

def test_binary_output(capsysbinary):
    """测试二进制输出"""
    sys.stdout.buffer.write(b"binary data")
    captured = capsysbinary.readouterr()
    assert b"binary data" in captured.out

7.2.4 caplog

caplog:捕获日志输出。

import logging

def test_logging(caplog):
    """测试日志"""
    logger = logging.getLogger("my_logger")
    logger.setLevel(logging.INFO)

    logger.info("这是一条信息日志")
    logger.warning("这是一条警告日志")
    logger.error("这是一条错误日志")

    # 检查日志
    assert "这是一条信息日志" in caplog.text
    assert "这是一条警告日志" in caplog.text
    assert "这是一条错误日志" in caplog.text

    # 检查特定级别的日志
    assert len(caplog.records) == 3
    assert caplog.records[0].levelname == "INFO"

7.2.5 monkeypatch

monkeypatch:临时修改对象、函数或环境变量。

def test_monkeypatch_function(monkeypatch):
    """修改函数"""
    def mock_get_data():
        return "模拟数据"

    # 临时替换函数
    monkeypatch.setattr("module.get_data", mock_get_data)

    # 现在调用 get_data() 会返回 "模拟数据"
    result = module.get_data()
    assert result == "模拟数据"

def test_monkeypatch_env(monkeypatch):
    """修改环境变量"""
    monkeypatch.setenv("TEST_MODE", "true")
    assert os.getenv("TEST_MODE") == "true"

    # 测试结束后,环境变量会自动恢复

def test_monkeypatch_attribute(monkeypatch):
    """修改对象属性"""
    obj = SomeClass()
    monkeypatch.setattr(obj, "attribute", "新值")
    assert obj.attribute == "新值"

7.2.6 request

request:获取当前测试请求的信息。

def test_request_info(request):
    """获取测试信息"""
    # 测试函数名称
    print(f"测试函数:{request.function.__name__}")

    # 测试文件路径
    print(f"测试文件:{request.fspath}")

    # 测试类(如果有)
    if request.cls:
        print(f"测试类:{request.cls.__name__}")

    # 所有 fixture 名称
    print(f"使用的 fixture:{request.fixturenames}")

    # 获取 fixture 值
    if "my_fixture" in request.fixturenames:
        value = request.getfixturevalue("my_fixture")
        print(f"Fixture 值:{value}")

7.3 内置 Fixture 完整示例

import pytest
import sys
import logging
import os

def test_with_tmp_path(tmp_path):
    """使用临时路径"""
    file = tmp_path / "test.txt"
    file.write_text("内容")
    assert file.read_text() == "内容"

def test_with_capsys(capsys):
    """捕获输出"""
    print("输出内容")
    captured = capsys.readouterr()
    assert "输出内容" in captured.out

def test_with_caplog(caplog):
    """捕获日志"""
    logging.info("日志信息")
    assert "日志信息" in caplog.text

def test_with_monkeypatch(monkeypatch):
    """修改环境"""
    monkeypatch.setenv("TEST_VAR", "test_value")
    assert os.getenv("TEST_VAR") == "test_value"

def test_with_request(request):
    """获取测试信息"""
    assert request.function.__name__ == "test_with_request"

8. Fixture 的高级用法

8.1 使用 pytest.fixture 的参数

8.1.1 常用参数总结

@pytest.fixture(
    scope="function",      # 作用域
    params=[1, 2, 3],     # 参数化
    autouse=False,         # 是否自动使用
    ids=None,              # 参数化的ID
    name=None              # fixture 的名称
)
def my_fixture(request):
    pass

8.1.2 ids 参数:自定义参数化ID

import pytest

@pytest.fixture(
    params=["chrome", "firefox", "edge"],
    ids=["Chrome浏览器", "Firefox浏览器", "Edge浏览器"]
)
def browser(request):
    return request.param

def test_browser(browser):
    assert browser in ["chrome", "firefox", "edge"]

运行时会显示

test_browser[Chrome浏览器] PASSED
test_browser[Firefox浏览器] PASSED
test_browser[Edge浏览器] PASSED

8.1.3 name 参数:重命名 Fixture

import pytest

@pytest.fixture(name="db")
def database_connection():
    """实际函数名是 database_connection,但使用时叫 db"""
    return {"connected": True}

def test_example(db):  # 使用短名称 db
    assert db["connected"] is True

8.2 条件 Fixture

8.2.1 根据条件决定是否使用 Fixture

import pytest

@pytest.fixture
def conditional_fixture(request):
    """根据标记决定行为"""
    if "slow" in request.keywords:
        # 如果是慢速测试,使用真实数据库
        return connect_real_database()
    else:
        # 否则使用模拟数据库
        return create_mock_database()

@pytest.mark.slow
def test_slow_operation(conditional_fixture):
    """慢速测试使用真实数据库"""
    assert conditional_fixture["type"] == "real"

def test_fast_operation(conditional_fixture):
    """快速测试使用模拟数据库"""
    assert conditional_fixture["type"] == "mock"

8.3 Fixture 的最终化器(Finalizer)

除了使用 yield,还可以使用 request.addfinalizer() 来注册清理函数:

import pytest

@pytest.fixture
def resource(request):
    """使用 finalizer 的 fixture"""
    print("初始化资源")
    resource = {"data": "重要数据"}

    def cleanup():
        print("清理资源")
        resource.clear()

    # 注册清理函数
    request.addfinalizer(cleanup)
    return resource

def test_resource(resource):
    assert resource["data"] == "重要数据"
    # 测试结束后会自动调用 cleanup

yield 与 finalizer 的区别

  • yield:更简洁,推荐使用
  • finalizer:可以注册多个清理函数,更灵活
@pytest.fixture
def multi_cleanup(request):
    """多个清理函数"""
    resource1 = create_resource1()
    resource2 = create_resource2()

    def cleanup1():
        cleanup_resource1(resource1)

    def cleanup2():
        cleanup_resource2(resource2)

    request.addfinalizer(cleanup1)
    request.addfinalizer(cleanup2)

    return resource1, resource2

8.4 Fixture 工厂模式

8.4.1 什么是 Fixture 工厂

Fixture 工厂是一个返回 fixture 函数的函数,用于创建多个类似的 fixture。

import pytest

def make_user_fixture(role):
    """Fixture 工厂:创建不同角色的用户 fixture"""
    @pytest.fixture
    def user():
        return {"username": "test_user", "role": role}
    return user

# 使用工厂创建多个 fixture
admin_user = make_user_fixture("admin")
regular_user = make_user_fixture("user")
guest_user = make_user_fixture("guest")

def test_admin(admin_user):
    assert admin_user["role"] == "admin"

def test_user(regular_user):
    assert regular_user["role"] == "user"

8.4.2 更实用的 Fixture 工厂

import pytest

@pytest.fixture
def user_factory():
    """用户工厂 fixture"""
    def _create_user(role="user", username=None):
        """创建用户的内部函数"""
        if username is None:
            username = f"test_{role}"
        return {
            "username": username,
            "role": role,
            "created_at": "2024-01-01"
        }
    return _create_user

def test_create_admin(user_factory):
    """使用工厂创建管理员"""
    admin = user_factory(role="admin", username="admin_user")
    assert admin["role"] == "admin"
    assert admin["username"] == "admin_user"

def test_create_regular_user(user_factory):
    """使用工厂创建普通用户"""
    user = user_factory()  # 使用默认值
    assert user["role"] == "user"
    assert user["username"] == "test_user"

9. 实际应用示例

9.1 Web 测试示例

import pytest
from selenium import webdriver

@pytest.fixture(scope="session")
def browser():
    """浏览器 fixture(整个会话只启动一次)"""
    print("启动浏览器")
    driver = webdriver.Chrome()
    driver.maximize_window()
    yield driver
    print("关闭浏览器")
    driver.quit()

@pytest.fixture
def login(browser):
    """登录 fixture"""
    browser.get("http://example.com/login")
    browser.find_element_by_id("username").send_keys("test_user")
    browser.find_element_by_id("password").send_keys("test_pass")
    browser.find_element_by_id("login_btn").click()
    yield browser
    # 登出
    browser.find_element_by_id("logout_btn").click()

def test_homepage(browser):
    """测试首页"""
    browser.get("http://example.com")
    assert "首页" in browser.title

def test_user_profile(login):
    """测试用户资料(需要登录)"""
    login.get("http://example.com/profile")
    assert "个人资料" in login.page_source

9.2 API 测试示例

import pytest
import requests

@pytest.fixture(scope="session")
def api_base_url():
    """API 基础URL"""
    return "https://api.example.com"

@pytest.fixture(scope="session")
def auth_token(api_base_url):
    """获取认证令牌"""
    response = requests.post(
        f"{api_base_url}/auth/login",
        json={"username": "test", "password": "test123"}
    )
    token = response.json()["token"]
    yield token
    # 登出
    requests.post(f"{api_base_url}/auth/logout", headers={"Authorization": f"Bearer {token}"})

@pytest.fixture
def api_headers(auth_token):
    """API 请求头"""
    return {
        "Authorization": f"Bearer {auth_token}",
        "Content-Type": "application/json"
    }

def test_get_users(api_base_url, api_headers):
    """获取用户列表"""
    response = requests.get(f"{api_base_url}/users", headers=api_headers)
    assert response.status_code == 200
    assert isinstance(response.json(), list)

def test_create_user(api_base_url, api_headers):
    """创建用户"""
    data = {"name": "新用户", "email": "new@example.com"}
    response = requests.post(f"{api_base_url}/users", json=data, headers=api_headers)
    assert response.status_code == 200

发表评论