Python 与测试
Python测试体系深度剖析:pytest框架、unittest标准库、TDD/BDD方法论、Mock与依赖隔离、覆盖率分析、属性测试、性能测试、CI/CD集成与生产级工程实践。
前置知识
- Python 与加密:建议先完成前一篇的学习
学习目标
- 掌握「概述」的核心机制、典型用法与常见陷阱
- 掌握「1. 历史动机与背景」的核心机制、典型用法与常见陷阱
- 掌握「2. 形式化定义」的核心机制、典型用法与常见陷阱
- 掌握「3. 理论推导」的核心机制、典型用法与常见陷阱
- 掌握「4. 代码示例」的核心机制、典型用法与常见陷阱
概述
测试是软件工程的基石。在 Python 生态中,测试不仅是验证代码正确性的手段,更是一种设计哲学——测试驱动的代码往往具有更清晰的接口、更松散的耦合、更可维护的结构。Python 测试生态历经二十余年演进,已形成以 pytest 为事实标准、unittest 为标准库基础、hypothesis 提供属性测试、tox/nox 提供多环境矩阵、coverage.py 提供覆盖率分析的完整工具链。
本篇文档将从测试理论基础(黑盒/白盒、单元/集成/端到端、FIRST 原则、测试金字塔)、pytest 框架内部机制(fixture 依赖注入、参数化、mark 标记、插件系统)、Mock 与依赖隔离、覆盖率理论(语句/分支/MC/DC 覆盖)、属性测试(Property-Based Testing)、TDD/BDD 方法论、CI/CD 集成、性能测试(pytest-benchmark)、真实项目案例研究等多维度展开系统化论述。
测试不是”代码完成后的补充工作”,而是”代码设计的前置约束”。Kent Beck 在《Test-Driven Development: By Example》中提出 TDD 的核心循环——红(写失败测试)、绿(写最少代码让测试通过)、重构(在测试保护下改进设计)——这一循环深刻影响了现代软件工程的方法论。本篇目标在于让读者从理论、工具、工程、文化四个维度全面掌握 Python 测试体系。
1. 历史动机与背景
1.1 软件测试的起源
软件测试作为一门工程学科,其历史可追溯至 1947 年 Grace Hopper 在 Harvard Mark II 计算机中发现的真实”虫子(bug)“——一只飞蛾导致继电器短路。这一事件被记入工程日志,“debug”一词由此诞生。但测试作为系统化方法论,则要到 1960 年代才逐渐成形。
1970 年代,软件危机爆发。诸多大型项目因质量问题延期或失败,促使业界开始系统研究测试方法。1979 年 Glenford Myers 出版《The Art of Software Testing》,首次系统化定义了测试的目标:“测试是为了发现错误而执行程序的过程”。这一定义颠覆了”测试是为了证明程序正确”的早期认知,奠定了现代测试哲学的基调。
1.2 xUnit 家族的诞生
1989 年,Kent Beck 在 Smalltalk 中创建了 SUnit,这是首个 xUnit 框架。SUnit 确立了”每个测试独立运行、setUp/tearDown 隔离环境、assert 原语验证预期”的范式。随后:
- 1997 年 Erich Gamma 与 Kent Beck 将 SUnit 移植到 Java,诞生 JUnit。JUnit 的成功使 xUnit 范式席卷各语言社区。
- 2001 年 Python 标准库引入
unittest模块(原名 PyUnit),由 Steve Purcell 贡献,遵循 xUnit 范式。 - 2000 年代初 Holger Krekel 发起 pytest 项目,试图突破 xUnit 的类继承约束,引入函数式测试与依赖注入 fixture。
1.3 pytest 的革命性突破
pytest 相比 unittest 的革命性体现在五点:
- 函数式测试:测试无需继承
unittest.TestCase,普通函数即可作为测试,降低样板代码。 - assert 重写:pytest 通过 AST 重写,让原生
assert语句输出丰富的失败诊断信息,无需记忆assertEqual等方法。 - fixture 依赖注入:通过参数名自动解析依赖,fixture 之间可嵌套引用,告别
setUp的扁平结构。 - 参数化:
@pytest.mark.parametrize让一组测试逻辑针对多组输入自动展开,独立报告每组结果。 - 插件系统:pytest 的 hook 机制允许插件深度定制测试发现的各个环节,催生了 pytest-cov、pytest-django、pytest-asyncio 等丰富生态。
1.4 测试方法论演进
| 时期 | 方法论 | 代表人物 / 文献 | 核心思想 |
|---|---|---|---|
| 1970s | 黑盒测试 | Glenford Myers | 测试为发现错误而存在 |
| 1990s | TDD | Kent Beck | 先写测试,再写实现 |
| 2000s | BDD | Dan North | 用业务语言描述行为 |
| 2003 | 测试金字塔 | Mike Cohn | 单元多、集成少、端到端稀 |
| 2009 | ATDD | Gregory | 验收测试驱动开发 |
| 2010s | 属性测试 | John Hughes | 基于规约生成随机输入 |
| 2013 | 契约测试 | Martin Fowler | 服务间接口契约验证 |
| 2015 | 混沌工程 | Netflix | 主动注入故障验证鲁棒性 |
1.5 Python 测试生态演进
| 年份 | 事件 |
|---|---|
| 2001 | Python 2.1 将 unittest(PyUnit)纳入标准库 |
| 2007 | pytest 1.0 发布,确立函数式测试范式 |
| 2010 | nose 项目流行,弥补 unittest 不足 |
| 2014 | pytest 2.x 引入 fixture 依赖注入系统 |
| 2017 | coverage.py 4.0 支持分支覆盖率 |
| 2018 | hypothesis 3.x 稳定,属性测试进入主流 |
| 2020 | pytest 6.0 引入 pyproject.toml 配置支持 |
| 2022 | pytest 7.0 重构 fixture 系统,引入 exceptiongroup |
| 2024 | pytest 8.0 完善异步测试支持,支持 Python 3.12 |
2. 形式化定义
2.1 测试用例的形式化定义
一个测试用例 是一个三元组:
其中:
- 是输入集合(input domain)。
- 是被测系统的状态空间(state space)。
- 是预期输出集合(expected output domain)。
测试通过当且仅当 ,即被测系统在状态 下接受输入 后产生的实际输出落在预期输出集合内。
2.2 覆盖率的形式化定义
设程序 包含语句集合 、分支集合 、路径集合 。设测试套件 触发的语句集合为 ,则:
语句覆盖率(Statement Coverage, C0):
分支覆盖率(Branch Coverage, C1):
路径覆盖率(Path Coverage, C2):
由于路径数量随分支数指数增长(),完整路径覆盖率在实际工程中通常不可达。
2.3 MC/DC 覆盖率
Modified Condition/Decision Coverage(MC/DC)是航空软件 DO-178B 标准要求的高强度覆盖率,定义为:
对于决策 ,MC/DC 要求每个条件 都被独立证明对决策 有影响。即存在测试对 使得:
MC/DC 所需测试用例数为 (线性增长),远低于路径覆盖率的 ,因此是工程上可达的高强度覆盖率指标。
2.4 属性测试的形式化定义
属性测试(Property-Based Testing)由 John Hughes 在 Haskell QuickCheck 中提出。其核心形式化为:
给定被测函数 与不变式(invariant),属性测试寻找反例 使得:
测试器通过随机生成 ,并调用 检查 是否成立。若发现反例,则尝试最小化反例(shrinking),找到最小可复现的失败输入。
2.5 测试替身的分类
Gerard Meszaros 在《xUnit Test Patterns》中定义了五种测试替身(Test Double):
| 类型 | 英文 | 行为 |
|---|---|---|
| 哑对象 | Dummy | 仅填充参数列表,从不被调用 |
| 桩 | Stub | 提供预设返回值,不验证调用 |
| 间谍 | Spy | 记录调用信息供后续验证 |
| 模拟 | Mock | 预设预期调用,验证调用是否符合预期 |
| 假对象 | Fake | 实现简化版真实逻辑(如内存数据库) |
形式化地,设 是真实依赖, 是替身:
- Stub:(常量),不关心 。
- Spy:,同时记录 。
- Mock:,验证 。
- Fake:,其中 是 的简化实现。
3. 理论推导
3.1 测试金字塔的经济学推导
测试金字塔建议单元测试占 70%、集成测试占 20%、端到端测试占 10%。这一比例的经济学依据可形式化推导。
设单元测试成本为 、集成测试成本为 、端到端测试成本为 ,且 。设测试发现的 bug 价值为 (避免生产事故的损失)。
收益最大化目标:
其中 是 类测试数量, 是单次 类测试发现 bug 的概率。由于 (同一 bug 在各层都能发现),而 ,最优解必然倾向于单元测试。
但端到端测试能发现单元测试遗漏的集成 bug( 在集成 bug 上成立),因此需保留少量端到端测试。这一权衡导出测试金字塔形态。
3.2 fixture 依赖图的拓扑排序
pytest 的 fixture 之间可相互引用:
@pytest.fixture
def db_connection(config): ...
@pytest.fixture
def user_repo(db_connection): ...
@pytest.fixture
def user_service(user_repo, cache): ...
这构成有向无环图(DAG)。pytest 在测试启动时按拓扑序构造 fixture:
若存在循环依赖(),pytest 检测到后会报错。这是 DAG 的环检测算法的应用。
3.3 coverage 的精度上限
定理:对于任意非平凡程序 ,不存在多项式时间算法能计算 的精确路径覆盖率。
证明:路径覆盖率计算需要枚举程序所有可行路径,而程序路径可达性问题是图灵停机问题的子问题,不可判定。因此实际工具(coverage.py)只统计被执行过的语句与分支,不保证覆盖所有可行路径。
3.4 属性测试的最小化算法
hypothesis 采用基于策略树的 shrinking 算法。给定失败输入 ,寻找最小 使得 仍失败:
由于搜索空间巨大,hypothesis 采用启发式:对整数二分缩小、对列表逐步删除元素、对字符串按字符删除。这是约束满足问题(CSP)的贪心近似算法。
3.5 Mock 的局限性
定理(Mock 替身不等式):设 是真实依赖, 是其 mock。若 与 的行为规约不完全一致,则存在测试 使得:
或反之。这是 mock 测试的根本局限:mock 仅能验证”被测代码与 mock 的契约”,不能验证”被测代码与真实依赖的契约”。因此集成测试(使用真实依赖)仍不可替代。
4. 代码示例
本节提供多个完整可运行的代码示例,覆盖 Python 测试生态的核心用法与典型工程场景。
4.1 pytest 基础:第一个测试
# test_calc.py
# pytest 基础示例:被测函数与测试函数
def add(a: int, b: int) -> int:
"""加法函数
Args:
a: 第一个加数
b: 第二个加数
Returns:
两数之和
"""
return a + b
def divide(a: int, b: int) -> float:
"""除法函数
Args:
a: 被除数
b: 除数
Returns:
商
Raises:
ZeroDivisionError: 当除数为零时
"""
if b == 0:
raise ZeroDivisionError("除数不能为零")
return a / b
# 测试函数:以 test_ 开头,pytest 自动发现
def test_add_basic():
"""测试加法基础场景"""
assert add(1, 2) == 3
assert add(-1, 1) == 0
assert add(0, 0) == 0
def test_add_edge_cases():
"""测试加法边界场景"""
assert add(10**18, 1) == 10**18 + 1 # 大整数
assert add(-10**18, -10**18) == -2 * 10**18
import pytest
def test_divide_by_zero():
"""测试除零异常"""
# pytest.raises 上下文管理器验证异常
with pytest.raises(ZeroDivisionError, match="除数不能为零"):
divide(1, 0)
4.2 fixture 依赖注入
# test_fixture.py
# pytest fixture 示例:依赖注入与作用域控制
import pytest
from typing import Iterator
# 基础 fixture:通过参数名注入
@pytest.fixture
def sample_users() -> list[dict]:
"""提供测试用用户数据
Returns:
用户字典列表
"""
return [
{"id": 1, "name": "张三", "age": 25},
{"id": 2, "name": "李四", "age": 30},
{"id": 3, "name": "王五", "age": 20},
]
def test_user_count(sample_users):
"""通过参数名自动注入 fixture"""
assert len(sample_users) == 3
# 带 yield 的 fixture:yield 之前是 setup,之后是 teardown
@pytest.fixture
def db_session() -> Iterator['Session']:
"""数据库会话 fixture
yield 之前:建立连接(setup)
yield 之后:关闭连接(teardown)
"""
session = create_session("sqlite:///:memory:")
yield session
session.close()
# 作用域控制
@pytest.fixture(scope="function") # 默认:每个测试函数一次
def fresh_list():
return []
@pytest.fixture(scope="class") # 每个测试类一次
def class_config():
return {"debug": True}
@pytest.fixture(scope="module") # 每个模块一次
def module_logger():
import logging
return logging.getLogger(__name__)
@pytest.fixture(scope="session") # 整个测试会话一次
def expensive_resource():
"""加载耗时资源,整个会话共享"""
resource = load_large_model()
return resource
# fixture 嵌套:通过参数引用其他 fixture
@pytest.fixture
def user_repository(db_session):
"""依赖 db_session fixture"""
return UserRepository(db_session)
@pytest.fixture
def user_service(user_repository):
"""依赖 user_repository fixture"""
return UserService(user_repository)
def test_create_user(user_service):
"""自动注入完整依赖链"""
user = user_service.create("张三", "zhangsan@example.com")
assert user.id is not None
4.3 参数化测试
# test_parametrize.py
# 参数化测试:一组逻辑,多组输入
import pytest
def is_palindrome(s: str) -> bool:
"""判断是否为回文
Args:
s: 待判断字符串
Returns:
True 表示是回文
"""
s = s.lower().replace(" ", "")
return s == s[::-1]
# 参数化:每组参数独立运行一次测试
@pytest.mark.parametrize("input_str,expected", [
("racecar", True),
("hello", False),
("A man a plan a canal Panama", True),
("", True),
("a", True),
("ab", False),
("上海自来水来自海上", True),
])
def test_is_palindrome(input_str, expected):
"""每组参数独立运行、独立报告"""
assert is_palindrome(input_str) == expected
# 参数化 + fixture:通过 indirect 引用 fixture
@pytest.fixture
def db_connection(request):
"""根据参数选择不同数据库"""
db_type = request.param
if db_type == "sqlite":
return create_sqlite()
elif db_type == "postgres":
return create_postgres()
@pytest.mark.parametrize("db_connection", ["sqlite", "postgres"], indirect=True)
def test_query(db_connection):
"""对多数据库运行同一测试"""
result = db_connection.query("SELECT 1")
assert result == 1
# 多参数组合:pytest 自动笛卡尔积
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_combination(x, y):
"""4 种组合:(1,10) (1,20) (2,10) (2,20)"""
assert x * y > 0
4.4 Mock 与依赖隔离
# test_mock.py
# Mock 示例:替换外部依赖
from unittest.mock import patch, MagicMock, call
import pytest
# 被测函数:调用外部 HTTP API
def get_weather(city: str) -> int:
"""获取城市温度
Args:
city: 城市名
Returns:
温度值
"""
import requests
response = requests.get(f"https://api.weather.com/{city}")
return response.json()["temperature"]
# 使用 patch 装饰器替换 requests.get
@patch("requests.get")
def test_get_weather(mock_get):
"""通过 patch 替换 requests.get
mock_get 是 MagicMock 实例,模拟 requests.get 的行为
"""
# 配置 mock 返回值
mock_response = MagicMock()
mock_response.json.return_value = {"temperature": 25}
mock_get.return_value = mock_response
# 调用被测函数
temp = get_weather("北京")
# 验证结果
assert temp == 25
# 验证 mock 被正确调用
mock_get.assert_called_once_with("https://api.weather.com/北京")
# 上下文管理器形式
def test_get_weather_context():
"""使用 with patch 限制 mock 作用域"""
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {"temperature": 30}
mock_get.return_value = mock_response
assert get_weather("上海") == 30
# side_effect:让 mock 模拟副作用
@patch("requests.get")
def test_get_weather_network_error(mock_get):
"""模拟网络异常"""
mock_get.side_effect = ConnectionError("网络中断")
with pytest.raises(ConnectionError):
get_weather("广州")
# 验证多次调用
@patch("requests.get")
def test_multiple_calls(mock_get):
"""验证多次调用的参数"""
mock_response = MagicMock()
mock_response.json.return_value = {"temperature": 20}
mock_get.return_value = mock_response
get_weather("北京")
get_weather("上海")
# 验证调用次数
assert mock_get.call_count == 2
# 验证调用参数列表
assert mock_get.call_args_list == [
call("https://api.weather.com/北京"),
call("https://api.weather.com/上海"),
]
# patch.object:替换对象的属性
class EmailSender:
def send(self, to: str, subject: str, body: str) -> bool:
# 真实发送邮件
...
def notify_user(email_sender: EmailSender, user_email: str, message: str) -> bool:
return email_sender.send(user_email, "通知", message)
def test_notify_user():
"""使用 patch.object 替换方法"""
sender = EmailSender()
with patch.object(sender, 'send') as mock_send:
mock_send.return_value = True
result = notify_user(sender, "user@example.com", "Hello")
assert result is True
mock_send.assert_called_once_with("user@example.com", "通知", "Hello")
4.5 测试 FastAPI 应用
# test_fastapi.py
# FastAPI 测试示例:使用 TestClient 进行端到端 API 测试
import pytest
from fastapi.testclient import TestClient
from myapp.main import app
@pytest.fixture(scope="module")
def client():
"""FastAPI TestClient fixture"""
with TestClient(app) as c:
yield c
def test_create_user(client):
"""测试创建用户接口"""
response = client.post("/users", json={
"name": "张三",
"email": "zhangsan@example.com"
})
assert response.status_code == 201
data = response.json()
assert data["name"] == "张三"
assert "id" in data
def test_get_user(client):
"""测试获取用户接口"""
# 先创建
create_resp = client.post("/users", json={
"name": "李四",
"email": "lisi@example.com"
})
user_id = create_resp.json()["id"]
# 再查询
response = client.get(f"/users/{user_id}")
assert response.status_code == 200
assert response.json()["name"] == "李四"
def test_user_not_found(client):
"""测试 404 场景"""
response = client.get("/users/99999")
assert response.status_code == 404
def test_invalid_payload(client):
"""测试参数校验"""
response = client.post("/users", json={"name": "王五"}) # 缺 email
assert response.status_code == 422 # FastAPI 自动校验失败
4.6 测试数据库操作
# test_database.py
# 数据库测试示例:使用内存 SQLite 与事务回滚隔离
import pytest
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String(50), nullable=False)
email = Column(String(100), unique=True)
@pytest.fixture(scope="session")
def engine():
"""会话级 engine:所有测试共享一个内存数据库"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def db_session(engine):
"""函数级 session:每个测试独立事务,测试后回滚
通过 SAVEPOINT 实现测试间数据隔离
"""
connection = engine.connect()
transaction = connection.begin()
Session = sessionmaker(bind=connection)
session = Session()
yield session
session.close()
transaction.rollback()
connection.close()
def test_create_user(db_session):
"""测试创建用户"""
user = User(name="张三", email="z@example.com")
db_session.add(user)
db_session.commit()
result = db_session.query(User).filter_by(name="张三").first()
assert result is not None
assert result.email == "z@example.com"
def test_unique_email(db_session):
"""测试 email 唯一约束"""
from sqlalchemy.exc import IntegrityError
db_session.add(User(name="张三", email="dup@example.com"))
db_session.commit()
with pytest.raises(IntegrityError):
db_session.add(User(name="李四", email="dup@example.com"))
db_session.commit()
def test_user_isolation(db_session):
"""测试隔离:上一个测试的 '张三' 不可见"""
users = db_session.query(User).all()
assert len(users) == 0 # 事务已回滚
4.7 异步测试
# test_async.py
# 异步测试示例:使用 pytest-asyncio
import pytest
import asyncio
@pytest.mark.asyncio
async def test_async_operation():
"""测试异步函数"""
result = await fetch_data("https://api.example.com/data")
assert result["status"] == "ok"
@pytest.fixture
async def async_client():
"""异步 fixture"""
client = await create_async_client()
yield client
await client.close()
@pytest.mark.asyncio
async def test_async_with_fixture(async_client):
"""异步测试 + 异步 fixture"""
response = await async_client.get("/users")
assert response.status_code == 200
# 并发执行多个协程
@pytest.mark.asyncio
async def test_concurrent_requests():
"""测试并发请求"""
urls = [f"https://api.example.com/{i}" for i in range(10)]
tasks = [fetch_data(url) for url in urls]
results = await asyncio.gather(*tasks)
assert all(r["status"] == "ok" for r in results)
4.8 coverage.py 与分支覆盖
# .coveragerc 配置示例
"""
[run]
source = myapp
branch = True
omit =
*/tests/*
*/venv/*
*/__pycache__/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
show_missing = True
precision = 2
[html]
directory = htmlcov
"""
# 运行:
# pytest --cov=myapp --cov-report=term-missing --cov-report=html --cov-branch
#
# 关键参数:
# --cov=myapp 指定被测包
# --cov-branch 启用分支覆盖
# --cov-report=term 终端报告
# --cov-report=html HTML 报告
# --cov-fail-under=80 覆盖率低于 80% 时返回非零退出码
# 条件分支测试示例
def classify(score: int) -> str:
"""根据分数分类
分支覆盖率需要覆盖所有 if/elif/else 路径
"""
if score >= 90:
return "A"
elif score >= 80:
return "B"
elif score >= 60:
return "C"
else:
return "F"
# 测试:必须覆盖每个分支
def test_classify_a():
assert classify(95) == "A"
def test_classify_b():
assert classify(85) == "B"
def test_classify_c():
assert classify(65) == "C"
def test_classify_f():
assert classify(50) == "F"
def test_classify_boundary():
"""边界值:刚好 90/80/60"""
assert classify(90) == "A"
assert classify(80) == "B"
assert classify(60) == "C"
assert classify(59) == "F"
4.9 hypothesis 属性测试
# test_property.py
# hypothesis 属性测试示例:基于规约生成随机输入
from hypothesis import given, strategies as st, assume
import pytest
# 被测函数
def encode_decode(s: str) -> str:
"""编码后立即解码,应得到原字符串"""
return s.encode("utf-8").decode("utf-8")
# 属性 1:编解码后等于原值
@given(st.text())
def test_encode_decode_identity(s):
"""对任意字符串,encode → decode 应保持恒等"""
assert encode_decode(s) == s
# 属性 2:列表反转两次等于原列表
@given(st.lists(st.integers()))
def test_reverse_twice(lst):
"""列表反转两次应等于原列表"""
assert list(reversed(list(reversed(lst)))) == lst
# 属性 3:加法交换律
@given(st.integers(), st.integers())
def test_add_commutative(a, b):
"""加法交换律:a + b == b + a"""
assert a + b == b + a
# 使用 assume 过滤不满足前置条件的输入
@given(st.integers())
def test_sqrt_non_negative(x):
"""对非负整数,平方根的平方应等于原值"""
assume(x >= 0)
import math
assert math.isqrt(x) ** 2 <= x < (math.isqrt(x) + 1) ** 2
# 自定义策略:生成特定结构的数据
email_strategy = st.builds(
lambda local, domain: f"{local}@{domain}.com",
st.text(min_size=1, max_size=10, alphabet=st.characters(min_codepoint=97, max_codepoint=122)),
st.text(min_size=1, max_size=10, alphabet=st.characters(min_codepoint=97, max_codepoint=122)),
)
@given(email_strategy)
def test_email_format(email):
"""生成的 email 应包含 @ 与 ."""
assert "@" in email
assert email.endswith(".com")
# 复合策略:生成用户对象
user_strategy = st.fixed_dictionaries({
"name": st.text(min_size=1, max_size=20),
"age": st.integers(min_value=0, max_value=150),
"email": email_strategy,
})
@given(user_strategy)
def test_user_creation(user):
"""对任意合法用户数据,创建应成功"""
u = create_user(user["name"], user["age"], user["email"])
assert u.name == user["name"]
assert u.age == user["age"]
4.10 pytest 配置与 conftest
# pyproject.toml pytest 配置
"""
[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--tb=short",
"--strict-markers",
"--strict-config",
"--cov=myapp",
"--cov-branch",
"--cov-report=term-missing",
"--cov-fail-under=80",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
"unit: marks tests as unit tests",
"network: marks tests that require network access",
]
asyncio_mode = "auto"
"""
# tests/conftest.py
"""项目级 conftest:定义共享 fixture 与 hook"""
import pytest
from typing import Iterator
@pytest.fixture(scope="session")
def app_config() -> dict:
"""会话级配置"""
return {
"database_url": "sqlite:///:memory:",
"redis_url": "redis://localhost:6379/0",
"debug": True,
}
@pytest.fixture
def temp_dir(tmp_path) -> 'Path':
"""使用 pytest 内置 tmp_path 创建临时目录"""
return tmp_path
@pytest.fixture(autouse=True)
def reset_state():
"""autouse=True:自动应用到所有测试
常用于全局状态重置
"""
# setup
yield
# teardown
clear_global_cache()
# hook:自定义测试结果输出
def pytest_runtest_makereport(item, call):
"""测试失败时自动截图(Web 测试场景)"""
if call.when == "call" and call.excinfo is not None:
# 失败时执行的动作
pass
# 命令行选项扩展
def pytest_addoption(parser):
"""添加自定义命令行参数"""
parser.addoption(
"--env",
default="test",
help="测试环境:test / staging"
)
@pytest.fixture
def env(request):
return request.config.getoption("--env")
4.11 pytest-benchmark 性能测试
# test_benchmark.py
# 性能测试示例:使用 pytest-benchmark
def test_list_append_benchmark(benchmark):
"""基准测试:列表 append 性能"""
lst = []
benchmark.pedantic(
lst.append,
args=(1,),
iterations=1000,
rounds=100,
)
def test_string_concat_benchmark(benchmark):
"""字符串拼接性能"""
def concat():
s = ""
for i in range(100):
s += str(i)
return s
benchmark(concat)
# 对比多个实现
def slow_fibonacci(n: int) -> int:
if n < 2:
return n
return slow_fibonacci(n-1) + slow_fibonacci(n-2)
def fast_fibonacci(n: int) -> int:
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
def test_fibonacci_benchmark(benchmark):
"""对比两种实现"""
benchmark(slow_fibonacci, 20)
def test_fast_fibonacci_benchmark(benchmark):
benchmark(fast_fibonacci, 20)
# 运行:pytest --benchmark-only
# 对比:pytest --benchmark-compare
4.12 完整 TDD 流程示例
# TDD 完整流程:开发一个 Stack 类
# 步骤 1:先写测试(红)
import pytest
class TestStack:
"""栈的 TDD 测试"""
@pytest.fixture
def stack(self):
from myapp.stack import Stack
return Stack()
def test_new_stack_is_empty(self, stack):
"""新栈应为空"""
assert stack.is_empty()
assert len(stack) == 0
def test_push_increases_size(self, stack):
"""push 后 size 应增加"""
stack.push(1)
assert len(stack) == 1
assert not stack.is_empty()
def test_pop_returns_last_pushed(self, stack):
"""pop 应返回最后 push 的元素(LIFO)"""
stack.push(1)
stack.push(2)
assert stack.pop() == 2
assert len(stack) == 1
def test_pop_empty_raises(self, stack):
"""空栈 pop 应抛出异常"""
with pytest.raises(IndexError, match="栈为空"):
stack.pop()
def test_peek_does_not_remove(self, stack):
"""peek 应查看但不移除元素"""
stack.push(1)
assert stack.peek() == 1
assert len(stack) == 1
# 步骤 2:写最少代码让测试通过(绿)
# myapp/stack.py
"""
class Stack:
def __init__(self):
self._items: list = []
def is_empty(self) -> bool:
return len(self._items) == 0
def __len__(self) -> int:
return len(self._items)
def push(self, item) -> None:
self._items.append(item)
def pop(self):
if self.is_empty():
raise IndexError("栈为空")
return self._items.pop()
def peek(self):
if self.is_empty():
raise IndexError("栈为空")
return self._items[-1]
"""
# 步骤 3:重构(在测试保护下改进设计)
# 例如:添加泛型支持、添加迭代器、添加 __repr__ 等
5. 对比分析
5.1 pytest vs unittest
| 维度 | pytest | unittest |
|---|---|---|
| 语法风格 | 函数式,原生 assert | 类继承,self.assertEqual |
| fixture | 依赖注入,参数名解析 | setUp/tearDown,扁平结构 |
| 参数化 | @pytest.mark.parametrize | 需自定义或用 parameterized 库 |
| 失败诊断 | AST 重写,信息丰富 | 简单断言失败 |
| 插件生态 | 极丰富(500+ 插件) | 有限 |
| 测试发现 | 自动递归扫描 | 需 TestLoader |
| 异步支持 | pytest-asyncio | IsolatedAsyncioTestCase |
| 学习曲线 | 低 | 中(需记忆 assertXxx 方法) |
| 标准库 | 否(第三方) | 是 |
结论:新项目优先选择 pytest,老项目可保留 unittest,并使用 pytest 作为运行器(pytest 兼容 unittest 风格测试)。
5.2 Mock vs Stub vs Spy vs Fake
| 类型 | 验证调用 | 提供返回值 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| Stub | 否 | 是 | 低 | 只需喂入数据 |
| Mock | 是 | 是 | 中 | 验证调用契约 |
| Spy | 是 | 是(透传真实) | 中 | 记录但不破坏真实行为 |
| Fake | 否 | 否(自实现) | 高 | 简化版真实依赖 |
5.3 单元测试 vs 集成测试 vs 端到端测试
| 维度 | 单元测试 | 集成测试 | 端到端测试 |
|---|---|---|---|
| 范围 | 单函数/类 | 多模块组合 | 整个系统 |
| 速度 | 毫秒级 | 秒级 | 分钟级 |
| Mock 使用 | 大量 | 部分 | 极少 |
| 维护成本 | 低 | 中 | 高 |
| Bug 发现率 | 高(密度) | 中 | 低(密度) |
| 集成 bug 发现 | 无 | 强 | 强 |
| 推荐比例 | 70% | 20% | 10% |
5.4 coverage.py vs codecov vs coveralls
| 工具 | 类型 | 特点 |
|---|---|---|
| coverage.py | 本地工具 | 标准实现,命令行 + HTML 报告 |
| pytest-cov | pytest 插件 | 集成 coverage.py 到 pytest |
| codecov | 云服务 | 上传报告,PR 中显示覆盖率变化 |
| coveralls | 云服务 | 类似 codecov,历史更久 |
5.5 hypothesis vs 传统参数化测试
| 维度 | 传统 @parametrize | hypothesis |
|---|---|---|
| 输入来源 | 手动列举 | 自动生成 |
| 边界发现 | 依赖开发者经验 | 自动发现 |
| 失败最小化 | 无 | 自动 shrinking |
| 测试用例数 | 固定 | 默认 100,可调 |
| 学习曲线 | 低 | 中(需学习策略) |
| 适用场景 | 已知边界 | 探索未知边界 |
5.6 TDD vs BDD
| 维度 | TDD | BDD |
|---|---|---|
| 关注点 | 代码做什么 | 系统行为如何 |
| 表达语言 | 编程语言 | 自然语言(Gherkin) |
| 工具 | pytest、unittest | behave、pytest-bdd |
| 参与者 | 开发者 | 开发者 + 业务方 |
| 文档价值 | 代码级文档 | 业务可读文档 |
6. 常见陷阱与反模式
6.1 陷阱:测试间依赖
反模式:
# 反模式:测试间共享状态
created_user_id = None
def test_create_user():
global created_user_id
response = client.post("/users", json={...})
created_user_id = response.json()["id"]
def test_get_user():
# 依赖上一个测试创建的 ID
response = client.get(f"/users/{created_user_id}")
assert response.status_code == 200
问题:若 test_create_user 失败或被跳过,test_get_user 也会失败,定位根因困难。
正确做法:每个测试独立 setup。
@pytest.fixture
def created_user(client):
response = client.post("/users", json={...})
return response.json()["id"]
def test_create_user(created_user):
assert created_user is not None
def test_get_user(client, created_user):
response = client.get(f"/users/{created_user}")
assert response.status_code == 200
6.2 陷阱:Mock 滥用
反模式:
@patch("myapp.repo.UserRepository.save")
@patch("myapp.service.UserService.validate")
@patch("myapp.service.UserService.send_email")
def test_create_user_complex(mock_send, mock_validate, mock_save):
mock_validate.return_value = True
mock_save.return_value = User(id=1, name="张三")
service = UserService(UserRepository())
result = service.create("张三")
mock_validate.assert_called_once_with("张三")
mock_save.assert_called_once()
mock_send.assert_called_once()
问题:测试与实现细节高度耦合。重构 UserService.create 的内部调用顺序就会导致测试失败,即便行为正确。
正确做法:只 mock 外部边界(HTTP、数据库、消息队列),不 mock 内部协作对象。
def test_create_user(client, db_session):
"""端到端验证行为"""
response = client.post("/users", json={"name": "张三"})
assert response.status_code == 201
# 验证数据库状态
user = db_session.query(User).first()
assert user.name == "张三"
6.3 陷阱:过度断言
反模式:
def test_user(user_service):
user = user_service.get(1)
# 过度断言:测试了过多字段
assert user.id == 1
assert user.name == "张三"
assert user.email == "z@example.com"
assert user.age == 25
assert user.created_at is not None
assert user.updated_at is not None
assert user.role == "user"
assert user.is_active is True
问题:测试脆弱。任何字段调整都导致测试失败。
正确做法:只断言与测试目标相关的字段。
def test_get_user_returns_user_with_id(user_service):
user = user_service.get(1)
assert user.id == 1 # 仅验证核心契约
6.4 陷阱:测试覆盖率迷信
反模式:追求 100% 覆盖率,写出”行覆盖但无断言”的测试。
def test_create_user_high_coverage(user_service):
user_service.create("张三") # 没有断言!
问题:覆盖率高但质量低。代码执行了,但未验证任何行为。
正确做法:覆盖率是参考指标,测试质量的核心是断言精度。设定合理阈值(80%-90%),不盲目追求 100%。
6.5 陷阱:慢测试
反模式:单元测试中调用真实 HTTP / 数据库。
def test_get_weather():
# 真实调用 API:慢、不稳定、依赖网络
result = get_weather("北京")
assert result > -50
问题:测试从毫秒级退化到秒级,CI 时间爆炸。网络故障导致测试不稳定。
正确做法:单元测试必须 mock 外部依赖。
@patch("requests.get")
def test_get_weather(mock_get):
mock_get.return_value.json.return_value = {"temperature": 25}
assert get_weather("北京") == 25
6.6 陷阱:测试代码重复
反模式:每个测试重复 setup。
def test_create_user():
client = TestClient(app)
db = create_session(...)
# ... 测试逻辑
db.close()
def test_get_user():
client = TestClient(app)
db = create_session(...)
# ... 测试逻辑
db.close()
正确做法:抽取 fixture。
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def db():
session = create_session(...)
yield session
session.close()
6.7 陷阱:测试魔法值
反模式:测试中出现无解释的魔法值。
def test_calculate():
assert calculate(100, 0.15) == 85
问题:读者不知道 100、0.15、85 分别代表什么。
正确做法:用具名常量或变量解释意图。
def test_calculate_tax():
price = 100
tax_rate = 0.15
expected_after_tax = 85
assert calculate(price, tax_rate) == expected_after_tax
6.8 陷阱:捕获输出而非验证行为
反模式:测试函数的 print 输出。
def test_greet(capsys):
greet("张三")
captured = capsys.readouterr()
assert "Hello, 张三" in captured.out
问题:测试与输出格式耦合,重构日志格式就失败。
正确做法:函数应返回值而非打印,测试验证返回值。
def test_greet():
assert greet("张三") == "Hello, 张三"
6.9 陷阱:时间相关测试不稳定
反模式:依赖系统当前时间。
def test_user_age():
user = User(birth_date="1990-01-01")
assert user.age == 35 # 2025 年是 35,2026 年是 36!
正确做法:使用 freezegun 冻结时间。
from freezegun import freeze_time
@freeze_time("2025-01-01")
def test_user_age():
user = User(birth_date="1990-01-01")
assert user.age == 35
6.10 陷阱:生产事故案例——Mock 与真实契约不一致
事故经过:某团队 mock 了第三方支付 SDK 的 charge 方法,返回 {"status": "success"}。测试全部通过。生产中真实 SDK 返回 {"status": "succeeded"}(注意 ed 后缀),导致代码中 if response["status"] == "success" 判断失败,用户付款成功但系统未记录订单,造成数十万元对账差错。
根因:mock 与真实 SDK 契约不一致,且无契约测试。
修复:
- 引入契约测试(Pact),自动验证 mock 与真实服务一致。
- 集成测试中调用 SDK 的沙箱环境,验证真实返回结构。
- 字段比较改为枚举值或常量,集中管理。
7. 工程实践
7.1 测试目录结构
flowchart TD
T0["project/"]
T1["src/"]
T2["myapp/"]
T3["__init__.py"]
T4["models/"]
T5["services/"]
T6["api/"]
T7["tests/"]
T8["conftest.py # 项目级 fixture"]
T9["unit/ # 单元测试"]
T10["conftest.py"]
T11["test_models.py"]
T12["test_services.py"]
T13["integration/ # 集成测试"]
T14["conftest.py"]
T15["test_database.py"]
T16["test_api.py"]
T17["e2e/ # 端到端测试"]
T18["test_user_flow.py"]
T19["fixtures/ # 测试数据"]
T20["users.json"]
T21["orders.json"]
T22["pyproject.toml"]
T23["pytest.ini"]
T0 --> T1
T6 --> T7
T21 --> T22
T21 --> T23
7.2 测试命名规范
- 测试文件:
test_<模块名>.py,如test_user_service.py。 - 测试类:
Test<被测类名>,如TestUserService。 - 测试函数:
test_<被测方法>_<场景>,如test_create_user_with_valid_email。 - fixture:
<资源名>,如db_session、user_repo。 - 参数化 ID:使用
ids参数提供可读 ID。
@pytest.mark.parametrize(
"score,grade",
[(95, "A"), (85, "B"), (65, "C")],
ids=["excellent", "good", "pass"]
)
def test_grade(score, grade):
...
7.3 fixture 分层
# tests/conftest.py - 全局 fixture
@pytest.fixture(scope="session")
def app():
return create_app()
# tests/integration/conftest.py - 集成测试专用 fixture
@pytest.fixture
def db_session(app):
session = app.db.session()
yield session
session.rollback()
# tests/unit/conftest.py - 单元测试专用 fixture
@pytest.fixture
def mock_db():
return MagicMock()
7.4 CI/CD 集成
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[test]"
- name: Lint
run: ruff check .
- name: Type check
run: mypy src/
- name: Run tests
run: pytest --cov --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
- name: Coverage check
run: pytest --cov-fail-under=80
7.5 并行测试加速
# 安装 pytest-xdist
pip install pytest-xdist
# 并行运行:自动检测 CPU 核心数
pytest -n auto
# 指定进程数
pytest -n 4
# 按文件分发(默认)或按测试函数分发
pytest -n auto --dist loadfile # 同一文件在同一进程
pytest -n auto --dist loadscope # 同一模块/类在同一进程
注意事项:
- 并行测试要求测试间独立,无共享可变状态。
scope=session的 fixture 在每个进程独立创建,可能加重资源负担。- 集成测试涉及真实数据库时,需为每个进程分配独立 schema。
7.6 测试性能优化
# 1. 提升 fixture 作用域
@pytest.fixture(scope="session") # 而非默认 function
def expensive_resource():
return load_large_model()
# 2. 使用 tmp_path_factory 共享临时目录
@pytest.fixture(scope="session")
def shared_temp_dir(tmp_path_factory):
return tmp_path_factory.mktemp("data")
# 3. 延迟导入
@pytest.fixture
def heavy_lib():
import heavy_library # 仅在需要时导入
return heavy_library
# 4. 标记慢测试,CI 中可选跳过
@pytest.mark.slow
def test_large_dataset():
...
# CI 快速检查:pytest -m "not slow"
7.7 测试金字塔实施
# 单元测试:70%
# tests/unit/test_calculator.py
class TestCalculator:
def test_add(self):
assert Calculator.add(1, 2) == 3
# 集成测试:20%
# tests/integration/test_user_repo.py
def test_user_repo_crud(db_session):
repo = UserRepository(db_session)
user = repo.create("张三")
assert repo.get(user.id).name == "张三"
# 端到端测试:10%
# tests/e2e/test_user_flow.py
def test_user_registration_flow(client):
# 完整流程:注册 → 验证邮箱 → 登录 → 查看个人页
response = client.post("/register", json={...})
assert response.status_code == 201
# ... 验证邮件、登录等
7.8 与 mypy 协同
# 测试代码也应类型注解
def test_create_user(user_service: UserService) -> None:
user: User = user_service.create("张三")
assert user.name == "张三"
# mypy 配置覆盖测试
# pyproject.toml
"""
[tool.mypy]
files = ["src", "tests"]
strict = true
"""
8. 案例研究
8.1 案例一:Dropbox 的 Python 测试体系
Dropbox 拥有约 400 万行 Python 代码,是 Python 大型项目的典型案例。其测试体系特点:
- 分层测试金字塔:单元测试占 75%,集成测试 20%,端到端测试 5%。
- 类型注解 + mypy 严格模式:所有代码必须有类型注解,mypy 在 CI 中强制检查。
- fixture 复用:通过 conftest.py 分层组织 fixture,从会话级到函数级层层覆盖。
- 测试隔离:每个测试在独立容器中运行,避免相互污染。
- 覆盖率门槛:分支覆盖率不低于 85%,PR 中显示覆盖率变化。
Dropbox 工程团队在 PyCon 2017 分享的数据显示:引入类型注解 + 严格测试后,生产事故率下降 40%,重构速度提升 30%。
8.2 案例二:FastAPI 的测试驱动开发
FastAPI 框架本身是 TDD 实践的典范。其作者 Sebastián Ramírez 在开发过程中坚持:
- 测试先行:每个新功能先写测试,再写实现。
- 100% 类型注解:FastAPI 全代码类型注解,mypy 严格模式通过。
- TestClient 抽象:基于 Starlette 的 TestClient,让端到端测试无需启动真实服务器。
- OpenAPI 契约测试:自动生成的 OpenAPI 文档作为 API 契约,测试验证实现与契约一致。
- 属性测试:使用 hypothesis 验证序列化/反序列化的不变式。
FastAPI 的测试套件约 5000 个测试,CI 完整运行约 5 分钟。
8.3 案例三:Instagram 的 Python 单元测试迁移
Instagram 后端大量使用 Python(Django)。2017 年前,Instagram 的测试以集成测试为主,CI 时间长达 2 小时。迁移策略:
- 识别瓶颈:90% 测试时间花在数据库 fixture 构造上。
- 下沉到单元测试:将业务逻辑从数据库访问中解耦,用 mock 替换数据库层。
- fixture 提速:将会话级 fixture 改为类级,减少重复构造。
- 并行化:引入 pytest-xdist,4 路并行。
结果:CI 时间从 2 小时降至 15 分钟,单元测试占比从 20% 提升到 70%。
8.4 案例四:hypothesis 在 Stripe 的应用
Stripe 使用 hypothesis 验证支付逻辑的健壮性。典型案例:
# 验证金额计算的属性测试
@given(
amount=st.decimals(min_value=0, max_value=10**10, places=2),
tax_rate=st.decimals(min_value=0, max_value=1, places=4),
)
def test_payment_calculation(amount, tax_rate):
"""对任意金额与税率,计算应满足不变式"""
total = calculate_total(amount, tax_rate)
# 不变式 1:总额 >= 本金
assert total >= amount
# 不变式 2:税额 = 总额 - 本金
tax = total - amount
assert tax == (amount * tax_rate).quantize(Decimal("0.01"))
# 不变式 3:精度始终为 2 位小数
assert total.as_tuple().exponent == -2
hypothesis 在一次 CI 中发现了浮点精度 bug:当金额为 0.1、税率为 0.001 时,浮点累积误差导致 tax 与 total - amount 不一致。该 bug 在传统参数化测试中未被发现。
8.5 案例五:pytest 插件机制剖析
pytest 的插件系统是其成功的关键。其核心机制:
- hookspec:定义 hook 的接口规范。
- hookimpl:实现 hook 的具体逻辑。
- 注册顺序:按插件加载顺序调用,先注册的先执行。
# 自定义插件示例:实现测试失败时发送通知
# myplugin.py
import pytest
import requests
def pytest_runtest_makereport(item, call):
"""hook:测试结果生成时调用"""
if call.when == "call" and call.excinfo is not None:
# 失败时发送通知
requests.post(
"https://hooks.slack.com/...",
json={"text": f"测试失败:{item.nodeid}"}
)
# 注册插件
# conftest.py
pytest_plugins = ["myplugin"]
通过这一机制,pytest 生态催生了 500+ 插件,覆盖覆盖率、并行、异步、Django、Flask、Mock 等场景。
9.1 基础题
题目 1:编写 pytest 测试,验证一个字符串反转函数 reverse_string(s: str) -> str 的正确性,包括空字符串、单字符、Unicode、回文等场景。
参考答案要点:
- 使用
@pytest.mark.parametrize列举多组输入。 - 覆盖边界:空串、单字符、纯 ASCII、中文、Emoji、混合。
- 验证不变式:
reverse_string(reverse_string(s)) == s。
题目 2:使用 @patch 替换 time.time(),让被测函数 is_expired(timestamp, ttl) 在测试中可控制时间。
参考答案要点:
@patch("time.time", return_value=1000)。- 测试
timestamp=900, ttl=200时is_expired返回False(当前 1000,过期时间 1100)。 - 测试
timestamp=900, ttl=50时返回True(当前 1000,过期时间 950)。
题目 3:使用 hypothesis 验证 sorted 函数的不变式。
参考答案要点:
- 属性 1:结果长度等于输入长度。
- 属性 2:结果是非递减的。
- 属性 3:结果是输入的排列(相同元素相同计数)。
- 使用
st.lists(st.integers())生成输入。
9.2 进阶题
题目 4:设计一个 db_session fixture,要求:
- 会话级:所有测试共享一个数据库连接。
- 事务隔离:每个测试在独立事务中运行,测试后回滚。
- 支持嵌套:测试内可手动 commit,但测试结束仍回滚。
参考答案要点:
- 使用 SQLAlchemy 的
SAVEPOINT(嵌套事务)。 connection.begin()开启外层事务。session.begin_nested()开启 SAVEPOINT。- yield session。
session.close()+transaction.rollback()。
题目 5:分析以下测试代码的问题并修复:
def test_user_creation():
user_service = UserService(DBRepository())
user_service.create("张三", "z@example.com")
# 验证
db = DBRepository()
found = db.find_by_name("张三")
assert found is not None
参考答案要点:
- 问题 1:测试间数据未清理,
张三会累积。 - 问题 2:依赖真实数据库,慢且不稳定。
- 问题 3:无 fixture 复用,重复构造。
- 修复:使用 fixture 注入
user_service与db,使用内存数据库,测试后回滚。
题目 6:解释为何以下覆盖率 100% 的测试仍可能漏掉 bug:
def divide(a, b):
if b == 0:
raise ValueError("除数为零")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(1, 0)
def test_divide_normal():
assert divide(6, 3) == 2
参考答案要点:
- 覆盖率仅证明代码被执行,不证明所有边界都被验证。
- 未测试:
a=0, b=1、负数除法、浮点除法、大数除法、b为非数值类型的异常。 - 路径覆盖率不等于规约覆盖率。
9.3 挑战题
题目 7:实现一个自定义 pytest 插件,功能为:测试失败时自动将失败用例的输入参数保存到 JSON 文件,下次运行时优先重跑这些失败用例(类似 pytest --lf 但自定义实现)。
参考答案要点:
- 实现
pytest_runtest_makereporthook,记录失败用例的 nodeid。 - 实现
pytest_sessionfinishhook,将失败列表写入 JSON。 - 实现
pytest_collection_modifyitemshook,读取上次失败列表,重排测试顺序。 - 使用
tmp_path_factory存储 JSON 文件。
题目 8:使用 hypothesis 测试一个 LRU 缓存实现,验证以下属性:
- 缓存大小永不超限。
- 最近访问的元素不会被淘汰。
get(key)后put不会淘汰key。
参考答案要点:
- 定义策略:
st.lists(st.tuples(st.sampled_from(["get", "put"]), st.integers()))生成操作序列。 - 维护参考实现(基于 OrderedDict 的标准 LRU)。
- 属性:自定义实现与参考实现状态一致。
- 这是模型测试(Model Testing)的应用。
题目 9:分析以下测试套件的性能瓶颈并提出优化方案:
@pytest.fixture
def db():
session = create_real_postgres_connection() # 耗时 2 秒
yield session
session.close()
def test_a(db): ...
def test_b(db): ...
# ... 共 100 个测试
参考答案要点:
- 瓶颈:每个测试都创建真实 Postgres 连接,100 个测试 = 200 秒。
- 优化 1:
scope="session",共享连接。 - 优化 2:使用内存 SQLite 替代 Postgres(若测试不依赖 PG 特性)。
- 优化 3:使用
pytest-xdist并行,4 路降至 50 秒。 - 优化 4:将不依赖 DB 的测试拆分到独立目录,不使用
dbfixture。
题目 10:设计一套微服务的契约测试方案,保证服务 A 调用服务 B 时,B 的变更不会破坏 A。
参考答案要点:
- 消费者(A)定义契约:期望 B 的响应格式。
- 提供者(B)验证:每次 B 变更时,运行契约测试,确认仍满足 A 的期望。
- 工具选择:Pact Python。
- 流程:A 生成 pact 文件 → B 在 CI 中拉取 pact → B 运行 pact verifier。
- 这是消费者驱动契约(CDC)测试模式。
11.1 官方文档
- pytest 官方文档:https://docs.pytest.org/
- unittest 标准库文档:https://docs.python.org/3/library/unittest.html
- coverage.py 文档:https://coverage.readthedocs.io/
- hypothesis 文档:https://hypothesis.readthedocs.io/
- unittest.mock 文档:https://docs.python.org/3/library/unittest.mock.html
- tox 文档:https://tox.wiki/
- nox 文档:https://nox.thea.codes/
11.2 经典教材
- Kent Beck《Test-Driven Development: By Example》
- Gerard Meszaros《xUnit Test Patterns》
- Michael Feathers《Working Effectively with Legacy Code》
- Robert C. Martin《Clean Code》
- Lisa Crispin《Agile Testing》
- Mark Fewster《Software Test Automation》
11.3 前沿论文
- John Hughes「QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs」(ICFP 2000)
- David Saff「An Experimental Evaluation of Continuous Testing」(ISSTA 2004)
- Alberto Bacchelli「Expectations, Outcomes, and Challenges of Modern Code Review」(ICSE 2013)
- Giovanni Asproni「Pytest: A Python Testing Framework」(2018)
11.4 开源项目源码
- pytest 源码:https://github.com/pytest-dev/pytest
- hypothesis 源码:https://github.com/HypothesisWorks/hypothesis
- coverage.py 源码:https://github.com/nedbat/coveragepy
- pytest-asyncio 源码:https://github.com/pytest-dev/pytest-asyncio
- pytest-xdist 源码:https://github.com/pytest-dev/pytest-xdist
- tox 源码:https://github.com/tox-dev/tox
11.5 进阶主题
- 契约测试(Pact):https://docs.pact.io/
- 混沌工程(Chaos Engineering):https://principlesofchaos.org/
- 变异测试(Mutation Testing):mutmut、cosmic-ray
- 模糊测试(Fuzzing):AFL、hypothesis 的 fuzzy 模式
- 性能基准测试:pytest-benchmark、asv
- 可视化测试报告:Allure、pytest-html
- 测试驱动微服务:Spring Cloud Contract、Pact Broker
- AI 辅助测试生成:Diffblue、Facebook Sapienz
附录 A:pytest 速查表
常用命令
# 运行所有测试
pytest
# 详细输出
pytest -v
# 显示 print 输出
pytest -s
# 运行指定文件
pytest tests/test_user.py
# 运行指定测试
pytest tests/test_user.py::test_create_user
# 关键词过滤
pytest -k "user and not admin"
# 标记过滤
pytest -m "not slow"
# 失败即停止
pytest -x
# 重跑上次失败
pytest --lf
# 仅收集不运行
pytest --collect-only
# 并行运行
pytest -n auto
# 覆盖率
pytest --cov=myapp --cov-report=term-missing
# 失败时进入 pdb
pytest --pdb
常用 fixture
| fixture | 作用 |
|---|---|
tmp_path | 函数级临时目录(Path 对象) |
tmp_path_factory | 会话级临时目录工厂 |
tmpdir | (旧)函数级临时目录(py.path) |
capsys | 捕获 stdout/stderr |
capfd | 捕获文件描述符级输出 |
caplog | 捕获日志 |
monkeypatch | 动态修改属性与环境 |
request | 访问测试上下文 |
config | 访问 pytest 配置 |
cache | 跨运行持久化数据 |
常用 mark
| mark | 作用 |
|---|---|
@pytest.mark.parametrize | 参数化 |
@pytest.mark.skip | 跳过 |
@pytest.mark.skipif | 条件跳过 |
@pytest.mark.xfail | 预期失败 |
@pytest.mark.usefixtures | 应用 fixture 但不注入 |
@pytest.mark.asyncio | 异步测试(pytest-asyncio) |
@pytest.mark.benchmark | 基准测试(pytest-benchmark) |
附录 B:测试替身速查表
| 类型 | 实现方式 | 验证调用 | 适用场景 |
|---|---|---|---|
| Stub | MagicMock(return_value=x) | 否 | 提供预设返回值 |
| Mock | MagicMock() + assert_called | 是 | 验证调用契约 |
| Spy | patch.object(real, method, wraps=real.method) | 是 | 记录但不替换 |
| Fake | 自定义实现简化逻辑 | 否 | 内存数据库等 |
| Patch | @patch("module.target") | 是 | 临时替换任意对象 |
附录 C:覆盖率指标速查表
| 指标 | 定义 | 工程阈值 |
|---|---|---|
| 语句覆盖 (C0) | 执行语句比例 | ≥ 80% |
| 分支覆盖 (C1) | 执行分支比例 | ≥ 70% |
| 函数覆盖 | 被调用函数比例 | ≥ 90% |
| 路径覆盖 (C2) | 执行路径比例 | 通常不强制 |
| MC/DC | 条件独立影响决策 | 航空/医疗级 |
结语
Python 测试生态历经二十余年演进,已形成理论扎实、工具丰富、工程成熟的完整体系。从 unittest 到 pytest,从手写参数化到 hypothesis 属性测试,从单纯覆盖率到变异测试,从单机测试到分布式契约测试,每一层演进都对应着软件工程实践的深化。
测试的本质不是”验证代码正确”,而是”提供重构的信心”。优秀的测试套件让团队敢于大刀阔斧地重构,让新人快速理解代码行为,让生产事故在合并前被发现。测试投入的回报不是线性的——前期投入大,但随代码库增长,收益呈指数级释放。
掌握 Python 测试,不仅是掌握 pytest、Mock、coverage 这些工具,更是掌握一种工程思维:代码是为了人而写,测试是为了信心而写。本篇文档希望成为读者构建企业级 Python 测试体系的参考指南,让每一行生产代码都有测试守护,让每一次重构都充满信心。