pytest 测试框架
大约 10 分钟约 3076 字
pytest 测试框架
简介
pytest 是 Python 最流行的测试框架,以简洁的语法和强大的插件生态著称。相比 unittest,pytest 需要更少的样板代码,支持参数化测试、fixture 依赖注入和丰富的插件扩展。
pytest 的设计哲学是"让测试编写变得自然"——它利用 Python 原生的 assert 语句(而非 unittest 的 self.assertEqual),通过自动发现测试文件和函数来减少配置。从底层看,pytest 在 assert 语句中使用了 AST 重写技术,能够在断言失败时显示表达式的各个部分的值,这比标准的 AssertionError 提供了更丰富的调试信息。
特点
基础测试
编写测试
# 被测模块 calc.py
def add(a: float, b: float) -> float:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("除数不能为零")
return a / b
class Calculator:
def __init__(self):
self.history = []
def calculate(self, expression: str) -> float:
result = eval(expression) # 仅演示
self.history.append((expression, result))
return result
# 测试文件 test_calc.py
import pytest
from calc import add, divide, Calculator
def test_add():
assert add(1, 2) == 3
assert add(-1, 1) == 0
assert add(0.1, 0.2) == pytest.approx(0.3) # 浮点数比较
def test_divide():
assert divide(10, 2) == 5.0
assert divide(7, 2) == 3.5
def test_divide_by_zero():
with pytest.raises(ValueError, match="除数不能为零"):
divide(10, 0)
class TestCalculator:
def test_calculate(self):
calc = Calculator()
assert calc.calculate("2 + 3") == 5
def test_history(self):
calc = Calculator()
calc.calculate("1 + 1")
calc.calculate("2 * 3")
assert len(calc.history) == 2
assert calc.history[0] == ("1 + 1", 2)
# 运行: pytest test_calc.py -v断言详解
import pytest
# 1. 基础断言
assert x == y # 等于
assert x != y # 不等于
assert x > y # 大于
assert x in [1, 2, 3] # 包含
assert x is None # 是 None
assert x is not None # 不是 None
assert isinstance(x, int) # 类型检查
assert callable(func) # 可调用
# 2. pytest.approx —— 浮点数近似比较
assert 0.1 + 0.2 == pytest.approx(0.3)
assert 3.14159 == pytest.approx(3.14, rel=1e-2) # 相对误差
assert 100.5 == pytest.approx(100, abs=1) # 绝对误差
# 3. pytest.raises —— 异常断言
def test_exception():
with pytest.raises(ValueError) as exc_info:
raise ValueError("错误消息")
assert str(exc_info.value) == "错误消息"
assert exc_info.type is ValueError
# 4. pytest.warns —— 警告断言
import warnings
def test_warning():
with pytest.warns(UserWarning, match="deprecated"):
warnings.warn("This is deprecated", UserWarning)
# 5. 断言组合(使用 and/or)
result = {"status": "ok", "data": [1, 2, 3]}
assert result["status"] == "ok" and len(result["data"]) == 3
# 6. 自定义断言消息
assert x > 0, f"期望 x > 0,实际 x = {x}"参数化测试
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
(-5, -3, -8),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
# 多参数组合
@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply_combinations(x, y):
assert x * y == x * y # 3x2=6 种组合
# 带标签的参数化
@pytest.mark.parametrize("input_str,expected", [
pytest.param("hello", "HELLO", id="lowercase"),
pytest.param("WORLD", "WORLD", id="uppercase"),
pytest.param("", "", id="empty"),
])
def test_upper(input_str, expected):
assert input_str.upper() == expected参数化进阶
import pytest
# 1. 参数化中的异常测试
@pytest.mark.parametrize("input_val,exception_type,error_msg", [
(0, ValueError, "除数不能为零"),
("abc", TypeError, "unsupported operand type"),
(None, TypeError, "unsupported operand type"),
])
def test_divide_errors(input_val, exception_type, error_msg):
with pytest.raises(exception_type, match=error_msg):
divide(10, input_val)
# 2. 从外部数据源加载参数
def load_test_cases():
"""从 CSV/JSON/数据库加载测试用例"""
import json
# 实际项目中从文件加载
return [
("1 + 1", 2),
("2 * 3", 6),
("10 / 2", 5),
]
@pytest.mark.parametrize("expr,expected", load_test_cases())
def test_calculator_expressions(expr, expected):
calc = Calculator()
assert calc.calculate(expr) == expected
# 3. 参数化标记
@pytest.mark.parametrize("n,expected", [
(1, False),
(2, True),
(3, True),
(4, True),
(5, False),
])
@pytest.mark.slow
def test_is_prime(n, expected):
"""标记为 slow 的参数化测试"""
result = all(n % i != 0 for i in range(2, int(n**0.5) + 1)) if n > 1 else False
assert result == expectedFixture
依赖注入
import pytest
from typing import Generator
# 基本 Fixture
@pytest.fixture
def sample_data():
return {"name": "张三", "age": 30, "email": "zhang@example.com"}
def test_with_fixture(sample_data):
assert sample_data["name"] == "张三"
assert sample_data["age"] > 0
# 带 setup/teardown 的 Fixture
@pytest.fixture
def temp_file(tmp_path):
file_path = tmp_path / "test_data.txt"
file_path.write_text("测试数据", encoding="utf-8")
yield file_path # 返回给测试使用
# teardown(文件在 tmp_path 中自动清理)
def test_file_read(temp_file):
content = temp_file.read_text(encoding="utf-8")
assert content == "测试数据"
# 作用域控制
@pytest.fixture(scope="session")
def db_connection():
print("\n[Session] 创建数据库连接")
conn = {"connected": True}
yield conn
print("\n[Session] 关闭数据库连接")
@pytest.fixture(scope="function")
def clean_db(db_connection):
print(" [Function] 清理测试数据")
yield db_connection
print(" [Function] 清理完毕")
def test_db_query_1(clean_db):
assert clean_db["connected"] is True
def test_db_query_2(clean_db):
assert clean_db["connected"] is True
# Fixture 依赖其他 Fixture
@pytest.fixture
def user_data():
return {"name": "张三", "role": "admin"}
@pytest.fixture
def auth_token(user_data):
return f"token-for-{user_data['name']}"
def test_authenticated(auth_token):
assert "张三" in auth_tokenFixture 高级用法
import pytest
import tempfile
import os
# 1. conftest.py —— 共享 Fixture
# 在 tests/ 目录下创建 conftest.py
# 其中的 fixture 自动对所有测试可见,无需导入
#
# tests/conftest.py:
# @pytest.fixture
# def app():
# return create_app()
#
# @pytest.fixture
# def client(app):
# return app.test_client()
# 2. fixture 工厂模式
@pytest.fixture
def make_user():
"""工厂 fixture —— 每次调用创建不同的用户"""
def _make_user(name: str = "默认用户", role: str = "user"):
return {"name": name, "role": role, "created": True}
return _make_user
def test_create_multiple_users(make_user):
admin = make_user(name="管理员", role="admin")
guest = make_user(name="访客", role="guest")
assert admin["role"] == "admin"
assert guest["role"] == "guest"
# 3. fixture 参数化
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db_engine(request):
"""对不同数据库运行相同的测试"""
engine = create_test_engine(request.param)
yield engine
engine.dispose()
def test_query(db_engine):
"""这个测试会运行 3 次,每次使用不同的数据库引擎"""
result = db_engine.execute("SELECT 1")
assert result is not None
# 4. yield vs return
@pytest.fixture
def simple_return():
"""return —— 简单值,没有 teardown"""
return {"data": "test"}
@pytest.fixture
def with_teardown():
"""yield —— 有 setup 和 teardown"""
resource = acquire_resource()
yield resource
release_resource(resource)
# 5. autouse —— 自动应用
@pytest.fixture(autouse=True)
def reset_state():
"""每个测试前后自动执行"""
# setup
yield
# teardown: 重置全局状态
global_counter = 0
# 6. 内置 fixture
# tmp_path —— 临时目录(Path 对象)
# tmp_path_factory —— 临时目录工厂
# monkeypatch —— 临时修改全局变量/环境变量
@pytest.fixture
def env_setup(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///test.db")
monkeypatch.setenv("DEBUG", "true")
monkeypatch.setattr("os.environ", {"TEST": "1"})
def test_with_env(env_setup):
assert os.environ.get("DATABASE_URL") == "sqlite:///test.db"Mock 和补丁
unittest.mock
from unittest.mock import Mock, patch, MagicMock
# Mock 对象
mock_db = Mock()
mock_db.query.return_value = [{"id": 1, "name": "张三"}]
result = mock_db.query("SELECT * FROM users")
assert result == [{"id": 1, "name": "张三"}]
mock_db.query.assert_called_once_with("SELECT * FROM users")
# patch 替换
class EmailService:
def send(self, to: str, subject: str, body: str) -> bool:
# 实际发送邮件
import smtplib
# ...
return True
class UserService:
def __init__(self, db, email_service):
self.db = db
self.email = email_service
def register(self, name: str, email: str):
self.db.save({"name": name, "email": email})
self.email.send(email, "欢迎", f"欢迎 {name}")
# 测试
@patch.object(EmailService, 'send')
def test_register(mock_send):
mock_db = Mock()
service = UserService(mock_db, EmailService())
service.register("张三", "zhang@example.com")
mock_db.save.assert_called_once_with({"name": "张三", "email": "zhang@example.com"})
mock_send.assert_called_once_with("zhang@example.com", "欢迎", "欢迎 张三")Mock 进阶技巧
from unittest.mock import Mock, patch, MagicMock, call, PropertyMock
import pytest
# 1. side_effect —— 动态返回值
def get_side_effect(item_id):
data = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
return data.get(item_id)
mock_repo = Mock()
mock_repo.get.side_effect = get_side_effect
assert mock_repo.get(1) == {"name": "Alice"}
assert mock_repo.get(99) is None
# side_effect 模拟异常
mock_api = Mock()
mock_api.fetch.side_effect = [
{"status": "ok"}, # 第一次调用正常
TimeoutError("超时"), # 第二次调用超时
{"status": "ok"}, # 第三次调用正常
]
# side_effect 作为可调用对象
call_count = 0
def tracking_side_effect(*args, **kwargs):
global call_count
call_count += 1
return call_count
mock_counter = Mock()
mock_counter.increment.side_effect = tracking_side_effect
assert mock_counter.increment() == 1
assert mock_counter.increment() == 2
# 2. PropertyMock —— 模拟属性
with patch.object(SomeClass, 'some_property', new_callable=PropertyMock) as mock_prop:
mock_prop.return_value = 42
obj = SomeClass()
assert obj.some_property == 42
# 3. call_args_list —— 验证调用历史
mock_logger = Mock()
process_items([1, 2, 3], logger=mock_logger)
mock_logger.info.assert_has_calls([
call("处理项目 1"),
call("处理项目 2"),
call("处理项目 3"),
])
# 4. patch 作为上下文管理器
def test_with_context():
with patch("module.function") as mock_func:
mock_func.return_value = "mocked"
result = module.function()
assert result == "mocked"
# patch 在 with 块外自动恢复
# 5. spec 参数 —— 确保 Mock 和真实接口一致
real_service = EmailService()
mock_service = Mock(spec=real_service)
mock_service.send("a@b.com", "主题", "正文") # OK
# mock_service.nonexistent_method() # AttributeError
# 6. AsyncMock —— 模拟异步函数
from unittest.mock import AsyncMock
async def test_async():
mock_async_func = AsyncMock(return_value="async result")
result = await mock_async_func()
assert result == "async result"配置
pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--tb=short
--strict-markers
markers =
slow: 慢速测试
integration: 集成测试
unit: 单元测试
# 运行命令
# pytest # 运行所有测试
# pytest tests/test_calc.py # 指定文件
# pytest -k "add" # 按名称过滤
# pytest -m "not slow" # 排除标记
# pytest --cov=src --cov-report=html # 覆盖率
# pytest -n auto # 并行执行(需 pytest-xdist)pyproject.toml 配置
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short --strict-markers"
markers = [
"slow: 慢速测试",
"integration: 集成测试",
"unit: 单元测试",
"smoke: 冒烟测试",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
]
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 80测试组织最佳实践
# tests/
# +-- conftest.py # 共享 fixture
# +-- unit/ # 单元测试
# | +-- test_calculator.py
# | +-- test_utils.py
# +-- integration/ # 集成测试
# | +-- test_database.py
# | +-- test_api.py
# +-- e2e/ # 端到端测试
# +-- test_user_flow.py
# 测试命名约定:
# - 文件名:test_<module>.py
# - 类名:Test<Feature>
# - 方法名:test_<行为>_<条件>_<期望结果>
#
# 好的命名:
# def test_divide_by_zero_raises_value_error()
# def test_add_positive_numbers_returns_sum()
# def test_user_registration_with_duplicate_email_fails()
#
# 差的命名:
# def test_1()
# def test_add()
# def test_it_works()
# conftest.py 示例
import pytest
from typing import Generator
from app.database import SessionLocal, Base, engine
@pytest.fixture(scope="session", autouse=True)
def setup_database():
"""所有测试开始前创建表结构"""
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def db_session() -> Generator:
"""每个测试使用独立的数据库会话"""
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
@pytest.fixture
def sample_user(db_session):
"""创建测试用户"""
from app.models import User
user = User(name="测试用户", email="test@example.com")
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user优点
缺点
总结
pytest 核心:assert 断言、Fixture 依赖注入、参数化测试。fixture 用 yield 实现 setup/teardown,scope 控制生命周期。参数化用 @pytest.mark.parametrize。Mock 用 unittest.mock 的 patch 替换外部依赖。配置在 pytest.ini 中管理。常用插件:pytest-cov(覆盖率)、pytest-xdist(并行)、pytest-html(HTML 报告)。运行命令 pytest -v 查看详细输出。
关键知识点
- pytest 自动发现以 test_ 开头的文件、类和方法
- fixture 的 scope 决定生命周期:function < class < module < package < session
- yield 形式的 fixture 自动实现 teardown
- conftest.py 中的 fixture 对同目录及子目录的所有测试可见
- @patch 的路径应该是被测代码中引用的路径,而非定义路径
- pytest.approx 用于浮点数比较,避免精度问题
项目落地视角
- 统一虚拟环境、依赖锁定、格式化和日志方案。
- 把入口、配置、业务逻辑和工具函数拆开,避免单文件膨胀。
- 对网络请求、文件读写和数据处理结果做异常与样本校验。
- 明确项目入口、配置管理、依赖管理、日志和测试策略。
- CI/CD 中运行 pytest --cov,覆盖率不低于 80%
常见误区
- 把临时脚本直接当生产代码使用。
- 忽略依赖版本、编码、路径和时区差异。
- 只会写 happy path,没有补超时、重试和资源释放。
- 把 notebook 或脚本风格直接带入长期维护项目。
- @patch 的路径错误(应使用被测模块中引用的路径)
- 过度使用 Mock 导致测试与实现脱节
- fixture 作用域设置过大导致测试间数据污染
进阶路线
- 学习 pytest-asyncio 编写异步测试
- 掌握 pytest-mock 简化 Mock 操作
- 研究 hypothesis 库实现基于属性的测试
- 了解 snapshot testing(快照测试)的适用场景
适用场景
- 当你准备把《pytest 测试框架》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合脚本自动化、数据处理、Web 开发和测试工具建设。
- 当需求强调快速迭代和丰富生态时,Python 往往能快速起步。
落地建议
- 统一使用虚拟环境与依赖锁定,避免环境漂移。
- 对核心函数补类型注解、异常处理和日志,减少"脚本黑盒"。
- 一旦脚本进入生产链路,及时补测试和监控。
- 测试文件按功能模块组织,使用 conftest.py 共享 fixture
排错清单
- 先确认当前解释器、虚拟环境和依赖版本是否正确。
- 检查编码、路径、时区和第三方库行为差异。
- 排查同步阻塞、数据库连接未释放或网络请求无超时。
- 使用 pytest --lf 只运行上次失败的测试
- 使用 -vv 或 --tb=long 获取更详细的错误信息
复盘问题
- 如果把《pytest 测试框架》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《pytest 测试框架》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《pytest 测试框架》最大的收益和代价分别是什么?
