理解单元测试
单元测试是对软件中最小的可测单元(函数、类方法)进行正确性验证的过程,是保障代码质量的第一道防线,能帮助开发者在项目前期发现 bug,降低修复成本。
类似简单的一个代码函数,对一个函数进行验证。
为什么要学单元测试
新手常有的疑问:我都 print 调试了,为什么要学这个?
想象这个场景:你写了个函数,手动把十几个场景挨个跑一遍,跑漏一个就上线背锅,下次改代码又得全部重跑一遍。单元测试就是把这些"手动跑一遍"固化成代码,一键回归,漏不掉、也忘不掉。
测试左移的理念
把测试提前到开发阶段,越早发现问题,修复成本越低。单元测试就是测试左移最直接的落地手段。
unittest 是什么
unittest 是 Python 自带的单元测试框架,其设计灵感源于 Java 的 JUnit 框架。无需安装三方库,具有稳定可靠、功能完备的特点。
unittest 能做什么
- 自动化验证:代替手工调试,一键回归测试
- 文档作用:测试用例本身就是需求和代码逻辑最佳的实例文档
- 设计驱动:编写测试用例的过程有助于优化代码结构,使其更具可测性
- 持续集成:CI/CD 流水线中不可或缺的一环
unittest 核心组件
- TestCase
- 是 unittest 里面最基本的单元,用于定义单个测试用例
- 必须继承
unittest.TestCase基类 - 测试方法必须以
test_开头,如果不是这样 unittest 测试框架不会识别并运行测试用例
- TestSuite
- 测试套件,容器的作用,用来组织多个测试用例或测试类
- TestRunner
- 执行测试并输出结果的组件
- TestFixture
- 定义测试用例执行前的前置处理和后置清理工作
项目结构
新手常问 test 文件放哪、怎么命名。推荐这样组织:
text
project/
├── src/
│ └── calc.py
└── tests/
├── __init__.py
├── test_calc.py
└── test_data.json
约定:
- 测试文件命名
test_*.py,unittest discover默认按这个规则找 tests/目录下加一个__init__.py,避免导入路径问题- 被测代码放
src/,测试代码放tests/,分离清楚
写 demo
python
# test_demo.py
import unittest
class TestAdd(unittest.TestCase):
def test_add_1(self):
self.assertEqual(1 + 1, 2)
def test_add_2(self):
self.assertEqual(2 + 2, 4)
def test_add_3(self):
self.assertEqual(3 + 3, 7) # 故意失败
if __name__ == "__main__":
unittest.main()
如果是命令行运行,需要写一个 main 函数(unittest.main())。
命令行运行:
bash
python test_demo.py
# 或
python -m unittest test_demo.py
常用断言方法
新手必背清单,assertEqual 之外还有这些:
| 断言方法 | 含义 | 示例 |
|---|---|---|
assertEqual(a, b) |
a == b | assertEqual(1 + 1, 2) |
assertNotEqual(a, b) |
a != b | assertNotEqual(1, 2) |
assertTrue(x) |
x 为 True | assertTrue(1 < 2) |
assertFalse(x) |
x 为 False | assertFalse(1 > 2) |
assertIn(a, b) |
a 在 b 中 | assertIn(1, [1, 2, 3]) |
assertNotIn(a, b) |
a 不在 b 中 | assertNotIn(4, [1, 2, 3]) |
assertIsNone(x) |
x 是 None | assertIsNone(None) |
assertIsNotNone(x) |
x 不是 None | assertIsNotNone(0) |
assertIsInstance(a, cls) |
a 是 cls 实例 | assertIsInstance("a", str) |
assertAlmostEqual(a, b) |
浮点数近似相等 | assertAlmostEqual(0.1 + 0.2, 0.3, places=7) |
assertRaises(Exc) |
抛出指定异常 | 见下方示例 |
assertRaises 两种写法:
python
import unittest
class TestRaises(unittest.TestCase):
def test_raises_context(self):
# 写法一:上下文管理器
with self.assertRaises(ZeroDivisionError):
1 / 0
def test_raises_callable(self):
# 写法二:传入可调用对象
self.assertRaises(ZeroDivisionError, lambda: 1 / 0)
if __name__ == "__main__":
unittest.main()
浮点数别用 assertEqual,0.1 + 0.2 != 0.3,坑很多,用 assertAlmostEqual。
执行结果分析
.:测试通过F:测试失败(Failure),断言失败E:测试错误(Error),代码本身异常S:测试跳过(Skip)
跑一遍上面的 demo,真实输出大概长这样:
text
F..
======================================================================
FAIL: test_add_3 (test_demo.TestAdd.test_add_3)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_demo.py", line 13, in test_add_3
self.assertEqual(3 + 3, 7)
AssertionError: 6 != 7
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=1)
逐行解读:
- 第一行
F..:三个用例,第一个失败,后两个通过 FAIL: test_add_3 (test_demo.TestAdd.test_add_3):哪个类、哪个方法失败Traceback:失败位置,精确到文件和行号AssertionError: 6 != 7:断言实际值 vs 期望值,一看就知道差在哪Ran 3 tests in 0.001s:跑了几个用例、耗时FAILED (failures=1):总结论,failures是断言失败数,errors是代码异常数
UNITTEST 框架自动寻找 TestCase 子类并运行。
执行顺序是按方法名的 ASCII 码值大小排序,不是代码书写顺序。
测试用例独立性原则
新手最容易犯的错:用例 A 依赖用例 B 留下的数据。
必须遵守:
- 每个用例能单独跑
- 乱序跑结果不变
- 重复跑结果不变
- 用例之间不共享全局状态、不依赖执行顺序
反面例子:
python
class TestBad(unittest.TestCase):
data = []
def test_01_add(self):
self.data.append(1) # 污染类属性
def test_02_check(self):
self.assertEqual(len(self.data), 0) # 单跑 test_02 能过,一起跑就挂
数据准备放在 setUp 里,用完在 tearDown 清掉,这是最简单可靠的做法。
Unittest TestFixture
方法级
方法级前置处理:setUp
方法级后置处理:tearDown
python
import unittest
class TestDemo(unittest.TestCase):
def setUp(self):
print("这是方法级的前置处理")
def tearDown(self):
print("这是方法级后置处理")
def test_case_1(self):
print("测试用例 1")
def test_case_2(self):
print("测试用例 2")
- 每个测试方法执行前后都会各执行一次
- 断言失败或用例报错,tearDown 依然会执行,适合做清理工作
类级
还有类级的前后置处理,需要加装饰器 @classmethod。
python
import unittest
class TestDemo(unittest.TestCase):
@classmethod
def setUpClass(cls):
print("类级前置:整个类执行前执行一次")
@classmethod
def tearDownClass(cls):
print("类级后置:整个类执行后执行一次")
def test_case_1(self):
print("测试用例 1")
def test_case_2(self):
print("测试用例 2")
setUpClass、tearDownClass整个类前后各执行一次- 必须加
@classmethod装饰器,忘了直接报错
模块级
模块级(这个 py 文件)所有用例执行之前和所有用例执行之后执行一次。
注意:unittest 标准库不像 pytest 那样有 setup_module(),unittest 是在模块里定义 setUpModule() 和 tearDownModule() 函数(函数级,不是类方法)。
python
import unittest
def setUpModule():
print("模块级前置:整个模块执行前一次")
def tearDownModule():
print("模块级后置:整个模块执行后一次")
class TestA(unittest.TestCase):
def test_a_1(self):
print("test_a_1")
def test_a_2(self):
print("test_a_2")
class TestB(unittest.TestCase):
def test_b_1(self):
print("test_b_1")
if __name__ == "__main__":
unittest.main()
前后置等级对比
| 级别 | 前置 | 后置 | 作用范围 | 备注 |
|---|---|---|---|---|
| 方法级 | setUp(self) |
tearDown(self) |
每个测试方法 | 类内定义 |
| 类级 | setUpClass(cls) |
tearDownClass(cls) |
整个测试类 | 需加 @classmethod |
| 模块级 | setUpModule() |
tearDownModule() |
整个模块 | 模块内函数定义 |
TestSuite
如果想多文件的测试用例全部一起运行呢?使用 TestSuite。
python
import unittest
from test_module_a import TestA
from test_module_b import TestB
if __name__ == "__main__":
# 1. 创建测试套件
suite = unittest.TestSuite()
# 2. 添加测试用例(可以是类,也可以是单个方法)
suite.addTest(TestA("test_a_1"))
suite.addTest(TestB("test_b_1"))
# 也可以用 addTests 批量添加
# suite.addTests([TestA("test_a_1"), TestB("test_b_1")])
# 3. 创建执行器并执行
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)
自动发现测试用例(多文件推荐用 discover):
python
import unittest
if __name__ == "__main__":
# 从当前目录开始查找 test 开头的 py 文件
suite = unittest.defaultTestLoader.discover(start_dir=".", pattern="test_*.py")
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)
verbosity 说明:
verbosity=0:静默,只输出结果摘要verbosity=1:默认,点号显示verbosity=2:详细,显示每个用例名称和执行结果
跳过测试
python
import unittest
class TestDemo(unittest.TestCase):
@unittest.skipIf(1 > 0, "条件成立,跳过")
def test_skip_if(self):
print("这条不会执行")
@unittest.skip("无条件跳过")
def test_skip(self):
print("这条也不会执行")
@unittest.skipUnless(False, "条件为 False 跳过")
def test_skip_unless(self):
print("这条也不会执行")
def test_dynamic_skip(self):
if True:
self.skipTest("方法内动态跳过")
print("这条也不会执行")
参数说明:
condition:判断条件,判断表达式,boolreason:跳过原因,str 类型
常用装饰器:
@unittest.skip(reason):无条件跳过@unittest.skipIf(condition, reason):条件为 True 时跳过@unittest.skipUnless(condition, reason):条件为 False 时跳过self.skipTest(reason):方法内动态跳过
数据驱动(ddt)
数据驱动的目的是将测试数据和用例逻辑进行分离,提高代码的可重用性。
安装:
bash
pip install ddt
支持导入多种类型数据:有列表、json、yaml 等。
python
import unittest
from ddt import ddt, data, unpack, file_data
@ddt # 装饰器标记这个测试类使用数据驱动
class TestDDT(unittest.TestCase):
@data(1, 2, 3) # 单个参数
def test_single(self, value):
self.assertGreater(value, 0)
@data((1, 2), (3, 4), (5, 6)) # 多个参数
@unpack
def test_multi(self, a, b):
self.assertLess(a, b)
@file_data("test_data.json") # 从文件读取
def test_from_file(self, **kwargs):
print(kwargs)
if __name__ == "__main__":
unittest.main()
@ddt:装饰器标记这个测试类使用的数据驱动@data:直接传入数据@unpack:拆解元组 / 列表,用于多参数@file_data:从 json、yaml 文件读取数据
json 文件完整示例
test_data.json:
json
[
{"username": "admin", "password": "123456"},
{"username": "guest", "password": "guest"}
]
配套代码:
python
import unittest
from ddt import ddt, file_data
@ddt
class TestLogin(unittest.TestCase):
@file_data("test_data.json")
def test_login(self, username, password):
self.assertTrue(len(username) > 0)
self.assertTrue(len(password) > 0)
if __name__ == "__main__":
unittest.main()
注意:file_data 加载 json 里的每个对象时,键名会直接变成方法参数名,所以 json 的 key 必须和函数参数对得上。
yaml 文件示例
需要额外装 PyYAML:
bash
pip install pyyaml
test_data.yaml:
yaml
- username: admin
password: "123456"
- username: guest
password: guest
用法和 json 一样:
python
@file_data("test_data.yaml")
def test_login(self, username, password):
self.assertTrue(len(username) > 0)
json 和 yaml 格式我还不太熟,需要额外熟悉一下。
并发测试(进阶)
进阶内容,初学可跳过。
unittest 本身不直接支持多进程并发,常见方案:
- 用
concurrent.futures手动并发跑多个 TestSuite - 用
pytest-xdist搭配 pytest 运行:pytest -n 4
简单示例,用 concurrent.futures 并发跑不同测试类:
python
import unittest
from concurrent.futures import ThreadPoolExecutor
class TestA(unittest.TestCase):
def test_a_1(self):
self.assertEqual(1 + 1, 2)
class TestB(unittest.TestCase):
def test_b_1(self):
self.assertEqual(2 + 2, 4)
def run_case(test_class):
suite = unittest.defaultTestLoader.loadTestsFromTestCase(test_class)
runner = unittest.TextTestRunner(verbosity=2)
return runner.run(suite)
if __name__ == "__main__":
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(run_case, TestA),
executor.submit(run_case, TestB),
]
for f in futures:
f.result()
注意点:
- 并发执行时,不同用例之间不能共享全局状态,否则结果不稳定
- 用
ThreadPoolExecutor只是多线程并发,适合 I/O 密集;CPU 密集用ProcessPoolExecutor - 更推荐用
pytest-xdist,命令一行pytest -n 4即可多进程并发
常见坑
- 文件名不是
test_*.py,discover找不到,用例一个都不跑 - 测试类没继承
unittest.TestCase,用例静默不执行 - 方法名不是
test_开头,框架直接忽略,不报错 setUpClass忘了@classmethod,直接报错- 浮点数用
assertEqual比较,0.1 + 0.2 != 0.3,用assertAlmostEqual - 用例之间共享全局变量,单跑能过,一起跑挂,排查半天
- 忘了写
if __name__ == "__main__": unittest.main(),命令行跑没反应
注意点
- 测试方法必须以
test_开头,否则框架不识别 - 执行顺序按方法名 ASCII 码排序,不是代码书写顺序
setUp/tearDown每个方法前后各执行一次;setUpClass/tearDownClass整个类前后各一次,需加@classmethod- 模块级用
setUpModule()/tearDownModule()函数,不是类方法 - 断言失败或用例报错,tearDown 仍会执行
ddt需要额外安装:pip install ddt- yaml 数据驱动需要额外安装:
pip install pyyaml - 并发执行要用 pytest-xdist 或 concurrent.futures,注意用例之间不能共享状态
unittest 掌握之后,可以去看 pytest。写法更简洁,不用继承类,直接用 assert,还有 fixture、参数化、插件生态,是目前更主流的 Python 测试框架。