单元测试
单元测试用自动化代码验证一个较小的程序单元是否符合预期。这个单元通常是函数、方法或类。好的单元测试应该运行快、结果稳定、失败原因清楚,并且彼此独立。
本文使用 Python 3.10 及以上版本,以标准库 unittest 为主。学完后,你应该能够:
- 按“准备、执行、断言”组织测试;
- 使用常见断言验证结果、异常和浮点数;
- 通过测试夹具复用准备与清理逻辑;
- 使用子测试覆盖多组输入;
- 用
unittest.mock隔离时间、网络等外部依赖; - 从命令行运行单个测试或自动发现整个测试套件;
- 理解测试隔离、边界覆盖和代码覆盖率的作用与限制。
第一个单元测试
假设项目中有一个价格计算模块:
def final_price(price: float, discount: float = 0) -> float:
if price < 0:
raise ValueError("price 不能为负数")
if not 0 <= discount <= 1:
raise ValueError("discount 必须在 0 到 1 之间")
return price * (1 - discount)
为它创建测试文件:
import unittest
from price import final_price
class FinalPriceTests(unittest.TestCase):
def test_applies_discount(self):
result = final_price(100, 0.2)
self.assertEqual(result, 80)
if __name__ == "__main__":
unittest.main()
运行测试:
python -m unittest test_price.py
输出中的 OK 表示所有测试通过;FAIL 表示断言没有满足;ERROR 表示测试执行时发生了预期之外的异常。
推荐使用 python -m unittest 而不是直接执行测试文件。这样运行单个文件、测试发现和持续集成可以采用同一种入口,也能明确使用当前 Python 环境中的 unittest。
测试是怎样被识别的
在 unittest 中:
- 测试类通常继承
unittest.TestCase; - 测试方法名必须以
test开头; - 每个测试方法描述一种具体行为;
- 使用
self.assert...系列方法判断结果是否正确。
测试名称应该说明场景和期望,而不是只写编号:
def test_returns_original_price_when_discount_is_zero(self):
...
def test_rejects_negative_price(self):
...
当测试失败时,名称本身就能帮助定位是哪条规则被破坏。
使用“准备、执行、断言”结构
一个测试通常分为三段,也称为 Arrange、Act、Assert:
def test_applies_discount(self):
# 准备:构造输入和依赖
price = 100
discount = 0.2
# 执行:只调用一次待测行为
result = final_price(price, discount)
# 断言:验证可观察结果
self.assertEqual(result, 80)
这种结构不是强制语法,但能让测试意图更清楚。简单测试不必机械地写三条注释;只要三个阶段容易辨认即可。
一个测试应尽量只验证一种行为。它可以有多个相关断言,例如同时验证返回对象的名称和状态,但不要在一个测试中混入多个互不相关的业务场景。
常用断言
直接使用 Python 的 assert 也能让测试失败,但 TestCase 提供的专用断言通常会给出更清晰的差异信息。
| 断言 | 验证内容 |
|---|---|
assertEqual(a, b) | a == b |
assertNotEqual(a, b) | a != b |
assertTrue(value) | 值为真 |
assertFalse(value) | 值为假 |
assertIs(a, b) | 两者是同一个对象 |
assertIsNone(value) | 值是 None |
assertIn(member, container) | 成员位于容器中 |
assertIsInstance(value, expected_type) | 值属于指定类型 |
assertGreater(a, b) | a > b |
assertSequenceEqual(a, b) | 两个序列相等 |
assertDictEqual(a, b) | 两个字典相等 |
assertCountEqual(a, b) | 元素及出现次数相同,忽略顺序 |
assertRegex(text, pattern) | 文本匹配正则表达式 |
相等与对象身份不同
assertEqual() 比较值,assertIs() 比较对象身份:
def test_builds_a_copy(self):
original = [1, 2, 3]
result = list(original)
self.assertEqual(result, original)
self.assertIsNot(result, original)
不要用 assertIs() 比较普通数字或字符串的值。Python 可能复用某些对象,导致测试偶然通过。
浮点数比较
二进制浮点数存在表示误差,不宜直接判断许多计算结果是否完全相等:
def test_adds_decimal_values(self):
result = 0.1 + 0.2
self.assertAlmostEqual(result, 0.3)
assertAlmostEqual() 默认按小数位比较,也可以显式设置 places,或通过 delta 指定允许的绝对误差:
self.assertAlmostEqual(result, expected, delta=0.001)
不能同时指定 places 和 delta。误差范围应来自业务精度要求,不要为了让测试通过而随意放宽。
验证异常
使用上下文管理器可以确认异常类型和消息:
def test_rejects_negative_price(self):
with self.assertRaisesRegex(ValueError, "不能为负数"):
final_price(-1)
只需要验证类型时使用 assertRaises():
with self.assertRaises(ValueError):
final_price(100, 1.5)
如果还要检查异常对象的属性,可以使用 as:
with self.assertRaises(ValueError) as context:
final_price(-1)
self.assertEqual(str(context.exception), "price 不能为负数")
只把预期抛出异常的那一行放进上下文管理器,否则前面不相关的代码抛出相同异常时,测试也可能错误地通过。
验证日志和警告
可以验证指定日志记录器是否产生了预期日志:
with self.assertLogs("shop.payment", level="WARNING") as captured:
retry_payment()
self.assertIn("准备重试", captured.output[0])
验证警告可使用 assertWarns() 或 assertWarnsRegex()。测试日志和警告时应关注对调用方有意义的内容,不要让测试依赖整条格式化文本中的时间等易变信息。
选择有价值的测试用例
不要只测试最顺利的示例。对一个输入范围,至少考虑:
- 正常值:最常见的合法输入;
- 边界值:零、空容器、最小值、最大值、刚好等于限制;
- 边界两侧:刚好合法与刚好非法;
- 无效值:类型错误、格式错误、超出范围;
- 特殊值:重复元素、Unicode、负数、
None,视接口规则而定。
对 discount 来说,0 和 1 是边界,-0.01 和 1.01 是边界外输入:
class FinalPriceBoundaryTests(unittest.TestCase):
def test_zero_discount_keeps_original_price(self):
self.assertEqual(final_price(100, 0), 100)
def test_full_discount_returns_zero(self):
self.assertEqual(final_price(100, 1), 0)
def test_rejects_discount_below_zero(self):
with self.assertRaises(ValueError):
final_price(100, -0.01)
def test_rejects_discount_above_one(self):
with self.assertRaises(ValueError):
final_price(100, 1.01)
测试应以接口约定为依据。不要凭想象要求函数接受 None,也不要为了覆盖分支而测试调用方永远无法观察的实现细节。
使用子测试覆盖多组输入
多组输入遵循同一规则时,可以使用 subTest(),减少重复代码,同时让每组数据独立报告结果:
class FinalPriceTests(unittest.TestCase):
def test_calculates_expected_prices(self):
cases = [
(100, 0, 100),
(100, 0.2, 80),
(100, 1, 0),
]
for price, discount, expected in cases:
with self.subTest(price=price, discount=discount):
self.assertEqual(final_price(price, discount), expected)
如果各个场景需要完全不同的准备工作或表达不同业务规则,分别写成有意义的测试方法会更清楚。
使用测试夹具准备和清理环境
测试夹具(fixture)是测试运行所需的固定环境,例如临时目录、对象实例或数据库连接。
setUp() 与 tearDown()
setUp() 在每个测试方法前执行,tearDown() 在每个测试方法后执行:
class CartTests(unittest.TestCase):
def setUp(self):
self.cart = Cart()
self.cart.add("book", price=50)
def tearDown(self):
self.cart.close()
def test_contains_added_item(self):
self.assertIn("book", self.cart.names())
def test_calculates_total(self):
self.assertEqual(self.cart.total(), 50)
每个测试都会得到重新创建的 self.cart,因此一个测试的修改不应污染另一个测试。
如果 setUp() 执行失败,tearDown() 不会执行。对于必须清理的资源,可以在成功创建后立即注册清理函数:
def setUp(self):
self.connection = open_connection()
self.addCleanup(self.connection.close)
已注册的清理函数会在测试结束时执行,即使后续的 setUp() 或测试本身失败。
类级夹具
setUpClass() 和 tearDownClass() 在整个测试类前后各执行一次,并且必须声明为类方法:
class CatalogTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.catalog = load_read_only_catalog()
@classmethod
def tearDownClass(cls):
cls.catalog.close()
类级夹具适合创建昂贵且只读的资源。可变资源在测试间共享会造成顺序依赖,应优先使用每个测试独立的 setUp()。
临时文件和目录
测试不要读写固定的真实路径。标准库 tempfile 会创建隔离的临时空间并自动清理:
from pathlib import Path
from tempfile import TemporaryDirectory
def test_saves_report(self):
with TemporaryDirectory() as directory:
path = Path(directory) / "report.txt"
save_report(path, "完成")
self.assertEqual(path.read_text(encoding="utf-8"), "完成")
这样测试不会覆盖用户文件,也不会依赖某个机器上预先存在的目录。
测试要彼此独立
独立测试应满足:
- 单独运行时能够通过;
- 改变测试顺序后结果不变;
- 不依赖另一个测试先创建的数据;
- 不依赖真实时间、随机网络响应或开发者机器配置;
- 自己创建的数据由自己清理。
下面的写法存在顺序依赖:
class UserTests(unittest.TestCase):
def test_1_creates_user(self):
global user
user = create_user("小林")
def test_2_activates_user(self):
activate(user) # 依赖上一个测试先运行
应让两个测试各自构造所需用户。测试文件名和方法名的字母排序只是当前默认执行顺序的一部分,不能当作测试间通信机制。
使用 Mock 隔离外部依赖
Mock 是可配置的测试替身,可以记录自己被怎样调用。它适合隔离慢、不稳定、难控制或有副作用的边界,例如网络、邮件、系统时间和支付接口。
假设 service.py 中的函数通过客户端读取用户:
def display_name(client, user_id: int) -> str:
user = client.fetch_user(user_id)
return user["name"].strip()
可以直接传入 Mock:
from unittest.mock import Mock
class DisplayNameTests(unittest.TestCase):
def test_returns_trimmed_name(self):
client = Mock()
client.fetch_user.return_value = {"name": " 小林 "}
result = display_name(client, 42)
self.assertEqual(result, "小林")
client.fetch_user.assert_called_once_with(42)
这里既验证了返回结果,也验证了关键协作行为。不要断言每个内部调用,否则重构实现但行为不变时,测试也会无意义地失败。
使用 patch() 替换依赖
如果待测代码在模块中直接引用某个对象,可以用 patch() 临时替换:
from email_client import send_email
def welcome(user):
send_email(user.email, "欢迎加入")
测试时应修补待测模块查找该名称的位置:
from unittest.mock import patch
from notifier import welcome
class WelcomeTests(unittest.TestCase):
@patch("notifier.send_email")
def test_sends_welcome_email(self, mock_send_email):
user = User(email="user@example.com")
welcome(user)
mock_send_email.assert_called_once_with(
"user@example.com",
"欢迎加入",
)
这里修补的是 notifier.send_email,不是最初定义它的 email_client.send_email,因为 welcome() 运行时从 notifier 命名空间查找该名称。
限制 Mock 的接口
普通 Mock 接受任意属性,拼错方法名也可能不立即失败。可以通过 spec 约束接口:
client = Mock(spec=UserClient)
需要更严格地匹配真实调用签名时,可使用 unittest.mock.create_autospec()。不过,Mock 无法证明真实服务一定兼容;关键集成仍需要少量集成测试验证。
配置异常与连续返回值
side_effect 可以让调用抛出异常:
client.fetch_user.side_effect = TimeoutError("请求超时")
也可以为连续调用提供不同结果:
client.fetch_user.side_effect = [TimeoutError(), {"name": "小林"}]
这适合测试重试逻辑。不要 Mock 自己要验证的核心计算,否则测试只是在验证预先配置的答案。
跳过测试和标记预期失败
某些测试只适用于特定环境,可以显式跳过:
import sys
@unittest.skipUnless(sys.platform == "win32", "仅适用于 Windows")
def test_windows_path(self):
...
常用装饰器包括:
@unittest.skip("原因"):始终跳过;@unittest.skipIf(condition, "原因"):条件为真时跳过;@unittest.skipUnless(condition, "原因"):条件为真时才运行;@unittest.expectedFailure:已知会失败,修复后应移除。
跳过不是隐藏失败的工具。原因应明确、可追踪,并定期清理。expectedFailure 修复后如果测试意外通过,会报告为 unexpected success,不应长期当作正常状态。
组织测试目录
小型项目可以把测试放在源码旁边;项目变大后,常见结构如下:
project/
├── src/
│ └── shop/
│ ├── __init__.py
│ └── price.py
└── tests/
├── __init__.py
├── test_price.py
└── test_cart.py
测试目录是否需要 __init__.py 取决于项目结构与导入方式;保持测试在项目约定的环境中可导入即可。不要在测试中临时修改 sys.path 来掩盖安装或目录配置问题。
自动发现测试
在项目根目录运行:
python -m unittest discover
默认从当前目录开始,寻找符合 test*.py 的文件。显式指定测试目录和文件模式:
python -m unittest discover -s tests -p "test_*.py"
运行指定测试
通过点号路径逐步缩小范围:
# 一个测试模块
python -m unittest tests.test_price
# 一个测试类
python -m unittest tests.test_price.FinalPriceTests
# 一个测试方法
python -m unittest tests.test_price.FinalPriceTests.test_applies_discount
常用选项:
# 显示每个测试名称
python -m unittest -v
# 在第一次失败或错误时停止
python -m unittest -f
# 只运行名称中包含 discount 的测试
python -m unittest -k discount
可以把多个测试名称放在同一条命令后运行。实际项目还应在持续集成环境中执行完整测试套件,避免只在开发者电脑上验证。
理解失败输出
测试失败时,先区分两类情况:
- FAIL:测试运行完成,但断言不成立;
- ERROR:测试准备或执行期间抛出了未处理异常。
输出通常包含测试名称、traceback、期望值和实际值。先从 traceback 底部向上找到项目代码,再判断是实现错误、测试期望错误,还是测试环境没有正确准备。
不要不加分析地把期望值改成当前实际值。期望结果应来自业务规则、接口文档或经过确认的示例。
代码覆盖率不是测试质量
代码覆盖率描述测试运行时执行了哪些代码,常见指标包括:
- 语句覆盖率:哪些语句执行过;
- 分支覆盖率:条件的不同结果是否执行过。
unittest 本身不计算覆盖率,项目可以选用第三方工具测量。覆盖率能发现完全没有测试的区域,但不能证明断言正确,也不能证明需求完整:
result = final_price(100, 0.2)
# 没有断言,也能执行代码并提高语句覆盖率
合理顺序是:先根据风险和行为设计测试,再用覆盖率寻找遗漏。不要为了数字好看而测试无意义的实现细节。
单元测试与其他测试的区别
| 类型 | 主要目标 | 特点 |
|---|---|---|
| 单元测试 | 验证一个小单元的行为 | 快、隔离、定位失败容易 |
| 集成测试 | 验证多个组件或真实依赖能否协作 | 更接近真实环境,但更慢 |
| 端到端测试 | 从用户入口验证完整业务流程 | 覆盖范围大,维护成本较高 |
| 文档测试 | 验证文档中的交互式示例仍然正确 | 示例兼作文档与可执行检查 |
这些测试互相补充。大量快速单元测试保护细节,少量集成和端到端测试确认真实组件能够连接起来。关于可执行文档示例,参见文档测试。
常见误区
测试实现细节
如果公开行为是“返回折后价”,通常不必检查内部调用了几个私有方法。过度绑定实现会让安全重构也导致大量测试失败。
在测试里复制业务算法
expected = price * (1 - discount)
self.assertEqual(final_price(price, discount), expected)
如果测试和实现复制了同一个错误公式,两者可能一起出错。使用容易人工确认的具体示例,或者让期望值来自独立规则。
依赖真实网络和当前时间
真实网络会超时,当前时间不断变化。单元测试应通过参数注入或 Mock 控制这些依赖;真实连接留给集成测试。
一个测试包含完整业务流程
测试越长,失败原因越难定位。把不同规则拆成小测试,并用少量集成测试连接它们。
为私有方法逐个补测试
私有实现通常应通过公开行为间接验证。如果一个私有函数拥有复杂、独立且重要的规则,可以考虑提取成职责清楚的可测试单元,而不是让测试深度依赖类的内部结构。
实践检查清单
- 测试名称是否说明场景和预期行为?
- 是否清楚区分了准备、执行和断言?
- 是否覆盖正常值、边界值和错误输入?
- 异常测试是否只包围预期失败的调用?
- 测试能否单独运行,并且不依赖执行顺序?
- 文件、数据库和全局状态是否可靠清理?
- Mock 是否只用于外部边界,并修补在正确的查找位置?
- 测试失败时,输出能否快速说明哪条规则被破坏?
- 是否避免把覆盖率当作质量目标?
- 修复缺陷时,是否先增加了能够复现它的回归测试?
单元测试的价值不只是“证明当前代码能运行”,而是把接口约定写成可以反复执行的例子。测试越清晰,未来修改代码时得到的反馈就越可信。