pytest 深度实战——fixture 高级用法、参数化、插件系统完整指南
pytest 深度实战——fixture 高级用法、参数化、插件系统完整指南
适读人群:Python 开发者 / 测试工程师 | 阅读时长:约 18 分钟 | 核心价值:掌握 pytest 的核心机制,写出专业级 Python 测试代码
从 unittest 到 pytest 的那次顿悟
我有个读者叫阿静,Python 后端工程师,在一家电商公司做 API 开发。她一直用 unittest 写测试,代码风格是这样的:
import unittest
class TestUserService(unittest.TestCase):
def setUp(self):
self.db = create_test_db()
self.user_service = UserService(self.db)
def tearDown(self):
self.db.close()
def test_create_user_success(self):
result = self.user_service.create_user("test@test.com", "password123")
self.assertTrue(result.success)
self.assertEqual(result.user.email, "test@test.com")
def test_create_user_duplicate_email(self):
self.user_service.create_user("test@test.com", "password123")
result = self.user_service.create_user("test@test.com", "other_password")
self.assertFalse(result.success)
self.assertEqual(result.error_code, "DUPLICATE_EMAIL")她看我的代码之后,第一反应是:"你的 self. 去哪了?为什么没有 TestCase?"
我给她看的是 pytest 风格:
import pytest
def test_create_user_success(user_service):
result = user_service.create_user("test@test.com", "password123")
assert result.success
assert result.user.email == "test@test.com"
def test_create_user_duplicate_email(user_service):
user_service.create_user("test@test.com", "password123")
result = user_service.create_user("test@test.com", "other_password")
assert not result.success
assert result.error_code == "DUPLICATE_EMAIL"她问:user_service 从哪里来的?
这就是 pytest fixture 的魔法。
pytest 的核心:fixture 机制
fixture 是 pytest 最核心的概念,它解决了"如何优雅地准备和清理测试前提条件"这个问题。
基本 fixture
import pytest
from myapp.database import create_test_database
from myapp.services import UserService
@pytest.fixture
def db():
"""创建测试数据库连接"""
database = create_test_database()
yield database # yield 之前是 setup,yield 之后是 teardown
database.close()
database.drop_all_tables() # 清理数据
@pytest.fixture
def user_service(db):
"""创建用户服务,依赖 db fixture"""
return UserService(db)
def test_create_user(user_service):
result = user_service.create_user("test@example.com", "Password123!")
assert result.success
assert result.user.id is not None
def test_get_user_not_found(user_service):
user = user_service.get_user_by_id("nonexistent-id")
assert user is Nonepytest 自动将 user_service fixture 注入到测试函数,并且自动处理 db fixture 的依赖。你不需要显式管理这个依赖链。
yield fixture vs. return fixture
# return fixture:不需要清理时使用
@pytest.fixture
def config():
return {"host": "localhost", "port": 5432, "env": "test"}
# yield fixture:需要清理时使用
@pytest.fixture
def temp_file(tmp_path):
file_path = tmp_path / "test_data.txt"
file_path.write_text("test data")
yield file_path
# 测试结束后删除文件(tmp_path fixture 也会自动清理,这里只是示例)
if file_path.exists():
file_path.unlink()
# 带异常处理的清理
@pytest.fixture
def redis_client():
client = redis.Redis(host="localhost", port=6379, db=15) # 用 db=15 作为测试库
client.flushdb() # 清空测试库
yield client
client.flushdb() # 测试后再清空
client.close()fixture 的 scope 管理
scope 控制 fixture 的生命周期,是性能优化的关键:
# function scope(默认):每个测试函数都会创建和销毁
@pytest.fixture(scope="function")
def user(db):
user = db.create_user(email=f"test_{uuid.uuid4()}@test.com")
yield user
db.delete_user(user.id)
# class scope:同一个测试类共享一个实例
@pytest.fixture(scope="class")
def api_client():
client = APIClient(base_url="http://localhost:8080")
client.login("admin@test.com", "Admin@123")
yield client
client.logout()
# module scope:同一个模块(文件)共享
@pytest.fixture(scope="module")
def db_with_seed_data():
db = create_test_database()
db.seed_categories()
db.seed_products(count=100)
yield db
db.drop_all_tables()
# session scope:整个测试会话共享(最重)
@pytest.fixture(scope="session")
def browser():
"""整个测试会话只启动一次浏览器"""
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
yield browser
browser.close()scope 选择原则:
| 资源类型 | 推荐 scope | 理由 |
|---|---|---|
| 随机测试数据(user、order等) | function | 每个测试独立,不互相干扰 |
| HTTP 客户端会话 | class 或 module | 创建有成本,但需要隔离 |
| 数据库连接 | session | 连接创建慢,共享连接池 |
| 浏览器实例 | session | 启动慢,共享 |
| 浏览器上下文 | function | 需要隔离 Cookie 等状态 |
参数化测试(Parametrize)
参数化让一个测试函数运行多个输入场景,是消除重复代码的利器:
import pytest
# 基本参数化
@pytest.mark.parametrize("email,password,expected_error", [
("", "Password123", "邮箱不能为空"),
("invalid-email", "Password123", "邮箱格式不正确"),
("test@test.com", "", "密码不能为空"),
("test@test.com", "123", "密码长度至少 8 位"),
("test@test.com", "abcdefgh", "密码必须包含数字"),
])
def test_login_validation(email, password, expected_error, login_page):
result = login_page.attempt_login(email, password)
assert not result.success
assert result.error_message == expected_error这 5 个场景用一个函数搞定,pytest 会生成 5 个独立的测试用例,每个用例有清晰的名称:
test_login_validation[ -Password123-邮箱不能为空]
test_login_validation[invalid-email-Password123-邮箱格式不正确]
...多层参数化
@pytest.mark.parametrize("discount_type", ["percentage", "fixed_amount"])
@pytest.mark.parametrize("order_total", [100, 500, 1000, 5000])
def test_discount_calculation(order_total, discount_type, discount_service):
"""
测试 2 种折扣类型 × 4 种订单金额 = 8 个测试用例
"""
discount = discount_service.calculate(order_total, discount_type)
assert discount >= 0
assert discount <= order_total参数化 + fixture(间接参数化)
@pytest.fixture
def user_with_role(request, db):
"""根据参数创建不同角色的用户"""
role = request.param # 从参数化获取 role
user = db.create_user(
email=f"{role}_{uuid.uuid4().hex[:8]}@test.com",
role=role
)
yield user
db.delete_user(user.id)
@pytest.mark.parametrize("user_with_role,expected_can_access", [
("admin", True),
("manager", True),
("viewer", False),
("guest", False),
], indirect=["user_with_role"])
def test_admin_panel_access(user_with_role, expected_can_access, api_client):
response = api_client.get("/admin/dashboard", user=user_with_role)
assert (response.status_code == 200) == expected_can_accessconftest.py 的正确用法
conftest.py 是 pytest 的特殊文件,其中定义的 fixture 对同目录及子目录下的所有测试文件可见,不需要显式导入。
目录结构
tests/
├── conftest.py # 全局 fixture(数据库、客户端等)
├── unit/
│ ├── conftest.py # 单元测试专用 fixture
│ └── test_user_service.py
├── integration/
│ ├── conftest.py # 集成测试专用 fixture(真实数据库)
│ └── test_order_flow.py
└── e2e/
├── conftest.py # E2E 测试专用 fixture(浏览器)
└── test_checkout.py全局 conftest.py
# tests/conftest.py
import pytest
import os
from myapp.database import Database
from myapp.config import TestConfig
@pytest.fixture(scope="session")
def config():
return TestConfig(
database_url=os.environ.get("TEST_DB_URL", "postgresql://localhost/testdb"),
api_base_url=os.environ.get("TEST_API_URL", "http://localhost:8080"),
admin_token=os.environ.get("TEST_ADMIN_TOKEN", "dev-admin-token"),
)
@pytest.fixture(scope="session")
def db(config):
database = Database(config.database_url)
database.create_all_tables()
yield database
database.drop_test_data()
database.close()
@pytest.fixture(autouse=True)
def cleanup_db_after_each_test(db):
"""每个测试后清理测试数据(autouse=True 自动应用到所有测试)"""
yield
db.rollback_test_data()E2E 专用 conftest.py
# tests/e2e/conftest.py
import pytest
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page
@pytest.fixture(scope="session")
def playwright_instance():
with sync_playwright() as p:
yield p
@pytest.fixture(scope="session")
def browser(playwright_instance):
browser = playwright_instance.chromium.launch(
headless=os.environ.get("CI", "false").lower() == "true"
)
yield browser
browser.close()
@pytest.fixture
def context(browser) -> BrowserContext:
ctx = browser.new_context(
viewport={"width": 1920, "height": 1080},
locale="zh-CN",
timezone_id="Asia/Shanghai",
)
yield ctx
ctx.close()
@pytest.fixture
def page(context) -> Page:
page = context.new_page()
yield page
page.close()
@pytest.fixture
def logged_in_page(page, config) -> Page:
"""已登录状态的页面"""
page.goto(f"{config.app_url}/login")
page.get_by_label("邮箱").fill("testuser@test.com")
page.get_by_label("密码").fill("Test@123456")
page.get_by_role("button", name="登录").click()
page.wait_for_url(f"{config.app_url}/dashboard")
yield page常用内置 fixture
pytest 内置了很多实用 fixture:
def test_file_processing(tmp_path):
"""tmp_path:提供一个临时目录,测试结束自动清理"""
input_file = tmp_path / "input.csv"
input_file.write_text("name,age\nAlice,30\nBob,25")
result = process_csv(input_file)
output_file = tmp_path / "output.json"
assert output_file.exists()
def test_with_monkeypatching(monkeypatch):
"""monkeypatch:临时修改全局状态、环境变量、模块属性"""
monkeypatch.setenv("PAYMENT_API_KEY", "test-key-12345")
monkeypatch.setattr("myapp.services.payment.TIMEOUT", 1)
# monkeypatch 会在测试结束后自动还原
result = PaymentService().charge(100)
assert result.used_key == "test-key-12345"
def test_output_capture(capsys):
"""capsys:捕获 stdout/stderr 输出"""
print("Hello, pytest!")
captured = capsys.readouterr()
assert "Hello, pytest!" in captured.out
def test_with_request_info(request):
"""request:访问当前测试的元信息"""
print(f"当前测试: {request.node.name}")
print(f"当前模块: {request.module.__name__}")踩坑实录
坑一:fixture 返回对象后被修改,影响其他测试
现象: 一个 function scope 的 fixture 返回了一个字典,测试 A 修改了这个字典,测试 B 却看到了修改后的值。
原因: fixture 对象如果是可变类型(dict、list 等),不同测试可能共享同一个对象(尤其是 module 或 session scope)。
解法:
# 错误:直接返回可变默认配置
@pytest.fixture(scope="module")
def default_config():
return {"timeout": 30, "retries": 3} # 如果测试修改这个 dict,会影响后续测试
# 正确:每次返回新对象,或使用 deepcopy
@pytest.fixture(scope="module")
def default_config():
return {"timeout": 30, "retries": 3} # module scope 下只有一个实例
@pytest.fixture # function scope,每个测试有自己的 dict
def test_config():
return {"timeout": 30, "retries": 3}坑二:session scope fixture 中使用了 function scope fixture
现象: 在 scope="session" 的 fixture 里依赖了 scope="function" 的 fixture,pytest 报错。
原因: scope 较宽的 fixture 不能依赖 scope 较窄的 fixture(生命周期不匹配)。
解法: 检查 fixture 的 scope 层级,宽 scope 只能依赖同级或更宽的 scope:session > module > class > function
坑三:autouse fixture 影响了不应该被影响的测试
现象: 在 conftest.py 里设置了 autouse=True 的 fixture,无意中让某些测试变慢或失败。
解法: 用 scope 和目录层级限制 autouse fixture 的影响范围,或者在不需要的测试上用 @pytest.mark.usefixtures() 跳过:
@pytest.mark.skip_autouse_cleanup # 自定义 marker
def test_special_case():
pass
# 在 conftest.py 中检查 marker
@pytest.fixture(autouse=True)
def cleanup(request):
if request.node.get_closest_marker("skip_autouse_cleanup"):
yield
return
yield
# 执行清理插件系统简介
pytest 有丰富的插件生态,几个必装的:
pip install pytest-xdist # 并行执行测试
pip install pytest-cov # 代码覆盖率
pip install pytest-mock # Mock 支持
pip install pytest-asyncio # 异步测试支持
pip install pytest-html # HTML 测试报告
pip install allure-pytest # Allure 报告# 并行执行
pytest -n auto # 自动检测 CPU 核数,并行执行
# 生成覆盖率报告
pytest --cov=myapp --cov-report=html
# 生成 Allure 报告数据
pytest --alluredir=allure-results
allure serve allure-results下一篇我会专门写 pytest fixture 的进阶用法:scope 管理、conftest 组织、fixture 依赖注入的最佳实践。
