跳到主要内容

单元测试

单元测试用自动化代码验证一个较小的程序单元是否符合预期。这个单元通常是函数、方法或类。好的单元测试应该运行快、结果稳定、失败原因清楚,并且彼此独立。

本文使用 Python 3.10 及以上版本,以标准库 unittest 为主。学完后,你应该能够:

  • 按“准备、执行、断言”组织测试;
  • 使用常见断言验证结果、异常和浮点数;
  • 通过测试夹具复用准备与清理逻辑;
  • 使用子测试覆盖多组输入;
  • 用 unittest.mock 隔离时间、网络等外部依赖;
  • 从命令行运行单个测试或自动发现整个测试套件;
  • 理解测试隔离、边界覆盖和代码覆盖率的作用与限制。

第一个单元测试​

假设项目中有一个价格计算模块:

price.py
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)

为它创建测试文件:

test_price.py
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 中的函数通过客户端读取用户:

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() 临时替换:

notifier.py
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 是否只用于外部边界,并修补在正确的查找位置?
  • 测试失败时,输出能否快速说明哪条规则被破坏?
  • 是否避免把覆盖率当作质量目标?
  • 修复缺陷时,是否先增加了能够复现它的回归测试?

单元测试的价值不只是“证明当前代码能运行”,而是把接口约定写成可以反复执行的例子。测试越清晰,未来修改代码时得到的反馈就越可信。