前言
unittest是 Python 标准库自带的测试框架,不需要安装任何第三方包。它的设计借鉴了 Java 的 JUnit,所以你会看到TestCase、setUp、tearDown这类命名。虽然现在很多人转向 pytest,但unittest依然值得学:一是零依赖,二是大量存量项目用它,三是理解它的结构后,看别的测试框架会轻松很多。
这篇是实操向:从写第一个TestCase开始,讲到assertEqual系列断言、setUp/tearDown的生命周期、assertRaises怎么验证异常、unittest.mock怎么打桩,最后说清测试目录该怎么组织、怎么用一条命令跑起来。
文中所有 API 名称与参数以 Python 官方文档unittest与unittest.mock页面为准。示例以 Python 3 为基准。
一、第一个 TestCase
核心结构是:继承unittest.TestCase,把每个测试写成一个以test_开头的方法。
# 适用于 Python 3.8+
import unittest
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("除数不能为 0")
return a / b
class TestCalculator(unittest.TestCase):
def test_add(self):
self.assertEqual(add(2, 3), 5)
def test_divide(self):
self.assertEqual(divide(10, 2), 5)
def test_divide_by_zero(self):
with self.assertRaises(ValueError):
divide(1, 0)
if __name__ == "__main__":
unittest.main()几点解释:
- 方法名必须以
test开头,否则测试加载器不会收集它。这是最常见的「写了测试却一个都没跑」的原因。 unittest.main()会自动发现本模块里的测试并运行,方便单文件直接python xxx.py执行。- 断言失败时,
unittest会打印期望值与实际值,并指出是哪个方法、哪一行。
二、常用断言方法
TestCase提供了一整套以assert开头的方法。不要用裸assert语句,因为assert在-O优化模式下会被整体移除,测试就形同虚设。用断言方法还能得到更好的失败信息。
| 方法 | 用途 | 备注 |
|---|
assertEqual(a, b) | 判断相等 | 最常用,底层用== |
assertNotEqual(a, b) | 判断不等 |
assertTrue(x)/assertFalse(x) | 判断真假 |
assertIs(a, b)/assertIsNot(a, b) | 判断同一对象 | 比assertEqual严格 |
assertIsNone(x) | 判断是None |
assertIn(a, b)/assertNotIn(a, b) | 成员判断 |
assertRaises(exc) | 断言抛异常 | 上下文管理器用法 |
assertAlmostEqual(a, b) | 浮点近似相等 | 避免浮点直接比较 |
assertCountEqual(a, b) | 两序列元素相同(不计顺序) |
一个补充说明:旧的assertEquals、assertNotEquals是assertEqual、assertNotEqual的别名,官方已把它们标记为废弃(deprecated),新代码应当用不带s的版本。
浮点数比较要特别注意,0.1 + 0.2 == 0.3在二进制浮点下不成立,所以应该用assertAlmostEqual:
# 适用于 Python 3.8+
import unittest
class TestFloat(unittest.TestCase):
def test_float_sum(self):
self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)
def test_raises_with_message(self):
with self.assertRaises(KeyError) as ctx:
{}["missing"]
self.assertIn("missing", str(ctx.exception))三、setUp 与 tearDown:测试夹具
测试之间应当相互独立,一个测试的状态不能泄漏到下一个。setUp()在每个测试方法之前运行,tearDown()在每个测试方法之后运行——用它们准备和清理夹具(fixture)。
# 适用于 Python 3.8+
import unittest
class Calculator:
def __init__(self):
self.history = []
def add(self, a, b):
result = a + b
self.history.append(result)
return result
class TestCalculator(unittest.TestCase):
def setUp(self):
# 每个测试都拿到一个全新的实例,互不污染
self.calc = Calculator()
def tearDown(self):
# 每个测试结束后清理(这里没有外部资源,仅演示位置)
self.calc = None
def test_add_records_history(self):
self.calc.add(1, 2)
self.assertEqual(self.calc.history, [3])
def test_history_starts_empty(self):
# 上一个测试加过的结果不会出现在这里
self.assertEqual(self.calc.history, [])生命周期规则要记准:
setUp()抛异常,该测试方法不会执行,tearDown()也不会执行;- 如果
setUp()成功,则无论测试方法是否失败,tearDown()都会执行; - 更粗粒度还有
setUpClass()/tearDownClass()(用@classmethod装饰,整个类跑一次)和setUpModule()/tearDownModule()(整个模块跑一次),适合建立昂贵的、所有测试共享的资源,比如临时数据库。
四、用 mock 隔离外部依赖
单元测试只应测「被测单元」,不该真的去连数据库、发 HTTP 请求。unittest.mock用来把这些外部依赖替换成可控的假对象。最常用的是patch,它既可以当装饰器,也可以当上下文管理器。
# 适用于 Python 3.8+
# 被测模块 weather.py 里写:
# import requests
# def get_temp(city):
# resp = requests.get(f"https://api.example.com/{city}")
# return resp.json()["temp"]
import unittest
from unittest.mock import patch, MagicMock
import weather # 上面那个被测模块
class TestGetTemp(unittest.TestCase):
@patch("weather.requests") # patch 被测模块里的名字,不是全局 requests
def test_get_temp(self, mock_requests):
mock_requests.get.return_value.json.return_value = {"temp": 21}
self.assertEqual(weather.get_temp("shanghai"), 21)
mock_requests.get.assert_called_once_with("https://api.example.com/shanghai")
def test_with_magicmock_argument(self):
# 另一种做法:把假的 client 当参数注入,不碰请求库
client = MagicMock()
client.get.return_value.json.return_value = {"name": "Ada"}
resp = client.get("/users/1")
self.assertEqual(resp.json()["name"], "Ada")关键要点:
- patch 的位置要 patch「使用它的地方」,而不是「定义它的地方」。上面被测模块
weather.py里import requests后调用requests.get,就应该 patchweather.requests这个名字,而不是全局的requests。 MagicMock会自动生成属性和方法,所以client.get().json()这种链式调用不需要手工打桩。assert_called_once_with(...)、assert_called_with(...)用来验证调用参数,是「行为测试」的常用手段。- 想模拟异常,给 mock 设
side_effect。
五、测试目录怎么组织,怎么跑起来
推荐把源码和测试分开放,测试文件名以test_开头,这样unittest的自动发现能识别:
project/
├── myservice/
│ ├── __init__.py
│ └── calculator.py
└── tests/
├── __init__.py
├── test_calculator.py
└── test_api.py运行方式有三种:
# 1. 跑单个文件
python -m unittest tests.test_calculator
# 2. 自动发现当前目录下所有 test*.py
python -m unittest discover
# 3. 指定起始目录与匹配模式
python -m unittest discover -s tests -p "test_*.py" -v注意discover默认匹配的是test*.py(test开头),不是*_test.py。如果你的文件命名成calculator_test.py,默认发现不了——要么改文件名,要么用-p指定模式。另外,测试目录里放一个__init__.py能让「按包名运行」在多数配置下更稳妥。
常见坑点
- 测试方法不以
test开头
❌def check_add(self): ...
✅def test_add(self): ...
- 用裸 assert 而不是断言方法
❌assert add(1, 1) == 2(-O下会被优化掉)
✅self.assertEqual(add(1, 1), 2)
- 测试之间共享可变状态
❌ 在类属性里建一个列表,多个测试往里塞数据
✅ 在setUp()里每个测试新建独立实例
- 浮点数直接
assertEqual
❌self.assertEqual(0.1 + 0.2, 0.3)(不相等)
✅self.assertAlmostEqual(0.1 + 0.2, 0.3)
- patch 错了目标
❌ patch 库定义处,结果被测模块用了自己的引用,没被替换
✅ patch 被测模块里「引用它的那个名字」
- 测试里做真实的网络 / 数据库访问
❌ 测试依赖外部服务是否在线,慢且不稳
✅ 用mock隔离外部依赖
- 文件名不匹配发现规则
❌calculator_test.py用默认discover跑不到
✅ 改成test_calculator.py,或用-p指定模式
- 依赖
tearDown在setUp失败时执行
❌setUp抛异常还想靠tearDown清理
✅ 记住setUp失败时tearDown不会执行,清理逻辑要能容忍
总结
| 概念 | 作用 | 关键约束 |
|---|
TestCase | 测试容器 | 方法名以test开头 |
setUp/tearDown | 每个测试前后 | setUp失败则tearDown不跑 |
setUpClass/tearDownClass | 每类一次 | 需@classmethod |
| 断言方法 | 验证结果 | 别用裸assert |
mock.patch | 隔离依赖 | patch 使用处而非定义处 |
discover | 批量运行 | 默认匹配test*.py |
unittest的骨架说穿了就四块:继承TestCase、test_命名、setUp准备、断言验证。把这四块用熟练,再学 pytest 只是换个更顺手的皮。