项目标题: "Python单元测试(unittest)实战指南"
我做了快十年Python开发,前五年几乎不写测试,总觉得那是“浪费时间”,代码能跑就行。直到有一次接手一个历史项目,改一个看似无关紧要的工具函数,结果下游三个模块集体报错,我才意识到,没有测试保护的代码,就是一颗随时可能爆炸的雷。从那以后,unittest就成了我写每个项目的基本配置,就像吃饭要用筷子一样自然。这篇东西,我不打算复述官方文档,只讲你在真实项目里会怎么用、怎么踩坑、怎么写才不白写。
1. 为什么先建tests目录,再写业务代码
1.1 目录结构这事,真不是随便分的
很多人在一个文件里又写业务又写测试,文件一多就乱成一锅粥。我见过一个项目,测试文件和业务代码混在一起,文件名带_test和不带_test的规则都统一不了,最后干脆大家都不写测试了,因为跑一下测试要手动去翻文件名,成本太高。
我常用的项目结构长这样:
project_root/ ├── src/ # 业务代码 │ ├── __init__.py │ ├── calculator.py │ └── user_service.py ├── tests/ # 测试代码 │ ├── __init__.py │ ├── test_calculator.py │ ├── test_user_service.py │ └── helpers.py # 公共测试工具,不算测试用例 ├── requirements.txt └── run_tests.py # 可选,用来统一入口注意两个关键点:
- 业务代码丢进
src/,测试代码丢进tests/,两者在目录层面就分开。这样你跑测试、做代码统计、做覆盖率,指标都干净。 - 每个测试文件用
test_开头,里面每个测试函数也用test_开头。这个不是强迫症,而是unittest的默认发现机制就认这个规则。unittest discover会从指定目录递归查找以test开头的文件,你不按规则命名,写了等于没写。
1.2 命名规范决定你会不会跑测试
好多人卡在第一步不是不会写断言,而是不知道怎么运行测试文件。有次我看到一个同事在终端里敲:
python test_calculator.py结果页面报错No module named 'src',他当场懵了。
这里有个重要的背景知识:unittest跑测试的方式不是把文件当普通脚本跑,而是要通过discover机制加载。推荐统一用命令行:
python -m unittest discover -s tests -p "test_*.py" -v参数解释一下:
-s tests:指定测试代码所在目录。-p "test_*.py":匹配文件名模式,只加载符合规则的文件。-v:verbose模式,打印每个用例的结果,方便看失败详情。
这样只要你的依赖装好了,无论从哪个目录执行,都不会出现模块找不到的尴尬。因为你始终站在项目根目录跑,src/和tests/都在当前工作目录下,Python能正确导入。
提示:如果你用的是现代打包工具,可以在
pyproject.toml里声明[tool.pytest.ini_options]来做类似配置。但unittest本身不需要额外配置,一条命令行足矣。
2. 核心机制拆解:TestCase、断言与fixture生命周期
2.1 TestCase,你的测试宇宙中心
unittest里unittest.TestCase是所有测试用例的基类。你写的每一个测试类,都继承了它。这个类的意义不只是“把你的函数们装起来”,它还承载了测试框架的核心逻辑:测试方法执行顺序、断言失败怎么处理、fixture怎么组织。
一个最基础的测试类长这样:
import unittest from src.calculator import add class TestAddFunction(unittest.TestCase): def test_add_two_positive_numbers(self): self.assertEqual(add(1, 2), 3) def test_add_negative_and_positive(self): self.assertEqual(add(-1, 2), 1) def test_add_zero(self): self.assertEqual(add(0, 0), 0) if __name__ == "__main__": unittest.main()注意一个细节:测试方法名必须能清晰表达“在测什么”。test_add_two_positive_numbers一眼就知道测试两个正数相加,失败了定位也快。test_num_1这种命名,失败了你还得点开函数看,这就是给未来的自己添堵。
每个test_方法在运行时是独立执行的,方法之间不会共享状态,无论你在test_1里怎么改全局变量,test_2里看到的还是初始值(除非你故意用模块级变量)。这个隔离机制是unittest帮你保证的,你不需要自己写reset逻辑。
2.2 断言方法,不只是assertEqual
unittest的断言方法其实是一个大家族,很多人翻来覆去只用一个assertEqual,遇到其他场景就不知所措。我列几个高频使用的,先混个眼熟:
| 断言方法 | 用途 | 常用场景 |
|---|---|---|
assertEqual(a, b) | 检查两个值相等 | 函数返回值对比 |
assertNotEqual(a, b) | 检查两个值不相等 | 排除法验证 |
assertTrue(x)/assertFalse(x) | 检查布尔值 | 标志位、判断结果 |
assertIsNone(x)/assertIsNotNone(x) | 检查是否为None | 返回值有无 |
assertIn(a, b) | 检查a是否在b中 | 列表、字典键值、字符串子串 |
assertRaises(SomeException) | 检查是否抛出指定异常 | 边界输入、异常处理逻辑 |
assertAlmostEqual(a, b, places=7) | 浮点数近似相等 | 浮点运算结果,避免精度问题 |
assertCountEqual(a, b) | 两个列表包含相同元素,顺序无关 | 排序无关的集合对比 |
用assertRaises的时候要注意上下文管理器的写法,这是最容易写错的地方之一:
def test_divide_by_zero_raises_error(self): with self.assertRaises(ZeroDivisionError): divide(10, 0)注意不要写成self.assertRaises(ZeroDivisionError, divide(10, 0)),看起来好像也行了,但第二个参数会在断言建立前被执行,如果函数没抛异常,那测试会报“没触发异常”的错误,如果函数抛了异常,unittest压根没机会捕获它,测试直接崩溃,非常迷惑。
assertAlmostEqual绝对是你处理浮点数的朋友。我刚开始写测试的时候,用assertEqual(0.1 + 0.2, 0.3)直接飘红,半天才意识到浮点数底层是二进制表示的,0.1 + 0.2等于0.30000000000000004,而不是0.3。改用assertAlmostEqual(0.1 + 0.2, 0.3, places=7)就一切正常。
2.3 setUp、tearDown还是setUpClass?生命周期别用混
unittest的四个生命周期方法,经常被人搞混。我见过最狠的案例,有人在setUpClass里构造一个需要几十秒的数据库连接,导致每跑一个测试方法都等几十秒,整个测试套件跑下来要一个小时。原因就是他把setUp写成了setUpClass。
这里用一张表解释清楚:
| 方法 | 执行频率 | 适合干什么 |
|---|---|---|
setUpClass | 整个测试类只执行一次(类级别) | 创建数据库连接、启动耗时服务、加载大文件 |
tearDownClass | 整个测试类只执行一次(类级别) | 关闭连接、清理临时文件 |
setUp | 每个测试方法执行前 | 构造公共数据、初始化对象、重置状态 |
tearDown | 每个测试方法执行后 | 清理测试产生的文件、恢复环境变量 |
一个典型场景:测试一个文件处理模块,每个测试都需要一个临时文件。你可以在setUp里创建文件,在tearDown里删除。这样每个用例都是全新环境,不会互相污染。
import os import tempfile import unittest from src.file_parser import parse_file class TestFileParser(unittest.TestCase): def setUp(self): # 每个测试前创建一个临时文件 self.temp_dir = tempfile.TemporaryDirectory() self.file_path = os.path.join(self.temp_dir.name, "test.txt") with open(self.file_path, "w", encoding="utf-8") as f: f.write("hello world") def tearDown(self): # 每个测试后自动清理整个临时目录 self.temp_dir.cleanup() def test_parse_file_content(self): result = parse_file(self.file_path) self.assertEqual(result, "hello world")注意TemporaryDirectory的cleanup()方法会递归删除目录下所有文件,完全不用担心残留。不用tempfile.mktemp()那种老掉牙的方式,因为它在并发场景下有安全隐患。
还有一个细节:unittest支持skip装饰器,这个在真实项目中太常用了。
import unittest class TestFeature(unittest.TestCase): @unittest.skip("功能尚未开发完成,跳过") def test_future_feature(self): pass @unittest.skipIf(os.name != "posix", "仅支持Linux环境") def test_linux_only_feature(self): pass跳过不是偷懒,而是明确标注“我有意不测”和“我忘了测”的区别。在CI跑测试时,跳过会单独统计,不会算作失败,同时清楚告诉队友这段代码还没测。
3. 从零写第一个完整测试:参数化、批量测试与测试概率
3.1 引入subTest处理多组数据
很多人的第一个测试写得很原始,一个函数写了十个测试方法,每个方法里就一句断言。这样不是不行,但维护成本高:改了逻辑要改十个地方,看到失败了还要猜是哪一组数据出问题。
subTest子测试功能可以很好地解决“同一函数多组输入”的测试场景:
import unittest from src.calculator import divide class TestDivideFunction(unittest.TestCase): def test_divide_multiple_cases(self): cases = [ (10, 2, 5.0), (7, 2, 3.5), (-9, 3, -3.0), (0, 5, 0.0), (8, 4, 2.0), ] for dividend, divisor, expected in cases: with self.subTest(dividend=dividend, divisor=divisor): self.assertEqual(divide(dividend, divisor), expected)这个用法的精髓在于:一旦某一组数据失败,错误消息会明确告诉你“dividend=7, divisor=2”失败了,其他组照样跑完。而如果你拆成5个独立测试方法,看到的失败就不带上下文参数,你还得自己回去填参数。
我在真实项目里经常用subTest来测“同一接口不同输入”的合法与非法边界,比如测试一个用户校验函数:
def test_user_name_validation(self): cases = [ ("alice", True), ("a", False), # 太短 ("A" * 51, False), # 太长 ("bob@example.com", False), # 包含非法字符@ ("carol", True), ] for name, expected in cases: with self.subTest(name=name): self.assertEqual(validate_username(name), expected)这样一份测试,既是测试,又是功能文档,告诉后来的人“这个函数什么输入能过,什么输入不能过”。
3.2 测试前先想好你要验证什么行为
很多人写测试是真·随缘,想到哪儿写到哪儿。后来我总结了一套思路,写每个测试前先在注释里回答三个问题:
- 这个代码行为的输入是什么?
- 期望的输出是什么?
- 如果行为错了,最可能是什么原因?
第三个问题最容易被忽略。举个例子,测一个parse_config函数,如果解析失败会返回None。那测试行为应该包含“返回值不是默认配置”和“返回值是None”,而不是只写“解析成功返回正确配置”就完事。因为如果函数代码被改成“无论输入什么都返回None”,你原来的测试可能照样通过,等于白测。
好的测试用例应该“杀死”代码的错误行为,而不是“证明”代码能跑。这个差别你多写两百个测试后会深有体会。
4. Mock与外部依赖隔离:真正测你的代码,不是测数据库
4.1 为什么需要Mock:数据库、网络请求和时钟
单元测试最核心的原则是“测当前单元,不碰外部依赖”。但真实项目里一个业务函数往往要调数据库、访网络、读环境变量。你不可能在研发环境真去连一个数据库来跑测试,如果测试要等网络,那整套测试就是脆弱的——网络一抖,明明代码没问题,测试全红。
Mock就是用来解决这个问题的。unittest内置了unittest.mock模块,可以“替换”掉你代码里的依赖对象,让测试只关心你的逻辑本身。
patch是mock的核心工具。有三种用法:
from unittest.mock import patch, MagicMock # 用法一:上下文管理器 def test_get_user_with_mock(self): with patch("src.user_service.requests.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"name": "Alice"} mock_get.return_value = mock_response result = get_user(1) self.assertEqual(result["name"], "Alice") # 用法二:装饰器 @patch("src.user_service.requests.get") def test_get_user_with_decorator(self, mock_get): mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = {"name": "Alice"} result = get_user(1) self.assertEqual(result["name"], "Alice") # 用法三:手动设置和恢复 def test_get_user_manually(self): original_get = requests.get requests.get = MagicMock(return_value=MagicMock(status_code=200, json=lambda: {"name": "Alice"})) try: result = get_user(1) self.assertEqual(result["name"], "Alice") finally: requests.get = original_get我推荐优先用上下文管理器或装饰器。手动设置和恢复容易在断言失败时忘记恢复,导致测试间相互污染,非常难排查。
这里有一个重要的细节:patch打补丁的对象是src.user_service.requests.get,不是requests.get。原因是你调用get_user时,代码里的requests.get其实引用的是src.user_service模块里的requests名字。如果你去patch全局的requests.get,因为src.user_service里requests.get已经在模块导入时绑定好了,全局改了它也感知不到,测试必然失败。
这个坑我踩过不下三次,每次都是白查半天才醒悟:“哦,我patch错了路径。”记住一句话:patch的路径,永远是你代码中“使用”的那个模块路径,不是你“定义”的那个路径。
4.2 Mock的返回值设置,别忽略属性和异常分支
Mock对象的核心用法就是设置返回值、属性和副作用。很多人只记得return_value,忽略了其他两个。
设置属性:
mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"id": 1, "name": "Alice"}当你mock一个响应对象时,如果测试逻辑里有status_code属性判断,不设置它就很容易导致分支错误。比如代码写:
if response.status_code == 200: return response.json() else: raise ApiError("请求失败")如果你的Mock没设置status_code,它将返回另一个MagicMock对象,跟200永远不相等,代码直接走raise ApiError分支,测试失败时你还不明白为什么“明明正确的结果没返回”。
设置副作用:
mock_get.side_effect = TimeoutError("连接超时")这个用来模拟异常场景特别方便。你不需要真的等网络超时,直接让Mock抛错,验证你的代码在异常时是否处理正确。
还有一种更高级的用法:用side_effect让Mock为连续的调用返回不同结果。比如测试一个重试逻辑:
mock_get.side_effect = [ TimeoutError("第一次超时"), MagicMock(status_code=200, json=lambda: {"data": "ok"}), ]这样第一次调用会抛异常,第二次成功,直接测试重试机制是否正常。
4.3 patch.object与patch的区别,小心你的导入方式
当你要mock的是“某个对象内部的方法”时,patch.object比patch更语义化。比如你要mock的是UserService类的一个实例方法:
from src.user_service import UserService class TestUserService(unittest.TestCase): @patch.object(UserService, "get_user_name") def test_user_service_logic(self, mock_get_name): mock_get_name.return_value = "Alice" service = UserService() result = service.do_something() self.assertEqual(result, "Alice")这里patch.object(UserService, "get_user_name")会把get_user_name这个方法直接替换成Mock,调用service.get_user_name()时不会执行真实逻辑。
需要注意,如果你在测试类里用了patch.object,一定要确认你替换的是“实例方法”而不是“未绑定的方法”。常见错误是把UserService.get_user_name理解成普通函数,实际上它是一个方法,Mock后调用它不会自动传入self,如果你在后面还调用了service.get_user_name(),它会直接报错说“缺少self参数”。解决办法是直接用patch.object(UserService, "get_user_name"),它会自动处理这个细节。
5. 项目中的测试组织策略:分层、封装与持续集成
5.1 按测试类型分层,别把所有鸡蛋放一个篮子里
很多团队刚开始推广单元测试时就一个tests/目录,全塞进去。跑得越多越乱,最后分不清哪些是单元测试、哪些是集成测试。
我现在习惯把测试分为三层:
| 层次 | 目录 | 用途 | 执行频率 |
|---|---|---|---|
| 单元测试 | tests/unit/ | 测函数、类、方法,不碰外部依赖 | 每次提交代码时 |
| 集成测试 | tests/integration/ | 测多个模块交互,可能依赖测试数据库或中间件 | 合并请求时 |
| 端到端测试 | tests/e2e/ | 模拟用户完整操作流程 | 版本发布前 |
这样分层的好处显而易见:开发时你可以只跑tests/unit/,秒级完成,根本不用等集成测试里那些慢吞吞的数据库操作和外部API调用。
目录结构改成这样:
tests/ ├── unit/ │ ├── test_calculator.py │ └── test_validation.py ├── integration/ │ └── test_database_flow.py ├── e2e/ │ └── test_user_registration.py └── __init__.py跑不同层级的测试:
# 跑单元测试 python -m unittest discover -s tests/unit -p "test_*.py" # 跑所有测试 python -m unittest discover -s tests -p "test_*.py"5.2 测试要用真实数据,还是构造数据?都可以
真实项目里最常争论的问题就是:“测试数据要不要用真实数据?”我的建议是:能用构造数据就用构造数据,但必须贴近真实分布。
构造数据的几个来源:
- 手写固定值,比如
"user_name": "alice",适合逻辑简单、状态少的情况。 - 用
faker库生成随机数据,适合需要大量用户名、邮箱、地址等模拟字段时。 - 从真实环境导出脱敏数据子集,适合复杂业务逻辑测试。
手写固定值的坑在于:容易忽视边界。比如你测一个日期处理函数,只写一个“今天”的情况,不写“闰年2月29日”和“跨年”的情况,一旦出生产事故,往往就出在这些边界上。
我常用一个简单但有效的方法:为每个函数准备一个“边界值清单”,然后在测试里逐条覆盖:
def test_get_workday_edges(self): edges = [ ("2024-02-28", True), # 闰年2月末 ("2024-02-29", True), # 闰日 ("2024-12-31", True), # 跨年 ("2025-02-28", True), # 非闰年2月末,2月29日不存在 ("2025-03-01", True), # 非闰年3月1日 ] for date_str, expected in edges: with self.subTest(date_str=date_str): self.assertEqual(is_workday(date_str), expected)5.3 覆盖率数据,不是越高越好
很多团队会把“测试覆盖率必须达到80%”写成硬性指标。覆盖率本身是很好的参考,但别迷信。我见过有项目覆盖率高达98%,测试结果却完全不能反映生产环境的可靠性——因为那些测试大部分都在测“函数存在”,而不是“函数行为正确”。
覆盖率工具帮你看到的只是“哪些行被执行了”,看不到“那些行被正确验证了”。有两个常用覆盖率指标值得关注:
- 行覆盖率:代码中有多少行被执行过。
- 分支覆盖率:代码中的if/else、for等分支逻辑是否两端都走到。
跑覆盖率也很简单:
coverage run -m unittest discover -s tests/unit -p "test_*.py" coverage report -m关键是要关注那些没覆盖到的行和分支,再判断“这是合理的没覆盖”(比如纯配置加载代码)还是“危险的没覆盖”(比如异常处理分支从来没被实测过)。最怕的就是“覆盖率达标了,但异常处理从来没人测过”这种假安全感。
6. 常见问题与排查技巧实录
6.1 测试意外成功,或是没真正测试到东西
这是最阴险的坑。测试结果显示通过,但实际上断言压根没执行到。
一个典型例子:
def test_get_user(self): user = get_user(1) self.assertEqual(user.name, "Alice")如果get_user(1)内部抛了异常,测试就会失败,这没问题。但如果get_user(1)返回了一个默认构造的对象,恰巧也叫“Alice”,那测试就会通过,但它根本没验证“从数据库正确加载了数据”。
更常见的是断言了错误的对象:
def test_config_parsing(self): config = parse_config("valid_config.json") # 这里不小心写了两个等号,比较的是值 self.assertEqual(config["timeout"], 30)如果写成了:
self.assertEqual(config["timeout"] == 30, True)这个测试照样通过,但意义完全不同。第一个写法是“检查timeout的值是否为30”,第二个是“检查比较结果是否为True”,后者等于先比较再断言比较结果,多绕了一步,一旦config返回的timeout是字符串"30",第一个会失败,第二个也会失败,但它失败的方式不一样,可读性差得多。
排查办法很简单:故意改错代码,比如把配置文件里的timeout改成31,看测试会不会失败。如果改错了测试还通过,说明你的测试根本没测对。
6.2 测试之间的相互污染
unittest默认每个测试类里的setUp和tearDown都会独立执行,但你要小心手动改的全局变量、环境变量、文件系统。
我遇到过最诡异的一次:test_a跑完,test_b就失败,单独跑test_b又通过。查了很久,原因是test_a里监听了端口,在tearDown里没关干净,导致test_b要尝试绑定同一端口时失败。
从此我严格执行一条规则:所有测试涉及外部资源(端口、文件、环境变量),必须在tearDown里清理;涉及全局状态修改的,必须在tearDown里恢复。
环境变量恢复示例:
import os import unittest class TestEnvConfig(unittest.TestCase): def setUp(self): self.original_env = os.environ.get("APP_ENV", None) def tearDown(self): if self.original_env is None: os.environ.pop("APP_ENV", None) else: os.environ["APP_ENV"] = self.original_env def test_env_loading(self): os.environ["APP_ENV"] = "testing" config = load_config() self.assertEqual(config["env"], "testing")6.3 测试文件之间的导入混乱
这是一个非常常见的报错:ModuleNotFoundError: No module named 'src'。解决办法前面说过,始终从项目根目录跑python -m unittest。但如果你在测试文件里用了相对导入,又会引发另一个问题。
假设你的目录结构是:
src/ ├── __init__.py └── calculator.py tests/ ├── __init__.py └── test_calculator.py测试文件里如果出现这类写法:
from src.calculator import add通常可以正常跑。但如果用from .src.calculator import add,就会报“ImportError: attempted relative import beyond top-level package”。
原因是unittest的discover机制把tests/目录当作了一个包,里面再出现相对导入,就会超出顶层包边界。我的经验是:测试文件一律用绝对导入方式,也就是直接from src.calculator import add,别整花活。
如果你用了pytest,它会自动把项目根目录加入sys.path,所以没这个烦恼。但unittest没有,这就是为什么很多人unittest写着写着就跑去用pytest了。
6.4 异步代码怎么测:asyncio.run还是IsolatedAsyncioTestCase
现在很多代码是异步的,async def满天飞。unittest早期版本不支持异步测试,我用得最多的办法是:
import asyncio import unittest class TestAsyncFunction(unittest.TestCase): def test_async_fetch(self): result = asyncio.run(fetch_data()) self.assertEqual(result, "expected_data")这个方式简单直接,但有个限制:asyncio.run会创建新的事件循环,如果你在测试里还涉及其他事件循环依赖,就需要小心。
Python 3.8之后,unittest内置了IsolatedAsyncioTestCase,这是更优雅的方案:
import unittest import unittest.isolated_asyncio class TestAsyncFunction(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): await init_resource() async def test_async_fetch(self): result = await fetch_data() self.assertEqual(result, "expected_data") async def asyncTearDown(self): await destroy_resource()IsolatedAsyncioTestCase适合需要异步setup和teardown的场景,比如连接异步数据库。注意它的setUp要写成asyncSetUp,tearDown要写成asyncTearDown,别和同步版本混用。
6.5 测试结果不稳定,随机失败怎么定位
这类问题最让人头疼。代码明明是“好”的,测试却偶尔红。我遇到过几类原因:
- 依赖外部服务:测试里真实调了外部API,网络抖动导致偶发超时。
- 并发竞态:两个测试同时跑时互相影响,比如都在写同一个临时文件。
- 随机数据:测试用了随机数生成,偶尔生成的数据恰好会让逻辑走不同的分支。
定位方法分三步:
- 先看失败时的错误消息和堆栈,判断是断言失败还是异常崩溃。
- 检查失败的测试是否涉及外部依赖,如果有,优先Mock掉。
- 多次运行测试,统计失败概率,如果约等于零但还是偶尔出现,多半是竞态问题。
我处理随机失败的原则:**测试的确定性比测试的覆盖更宝贵。**宁可少测一个分支,也不留一个“偶尔红”的坑。因为团队里的同事看到测试飘红,第一反应不是排查问题,而是跳过测试直接提代码,久而久之这套测试体系就名存实亡了。
7. 几个高级技巧,让测试更省心
7.1 测试用例的随机执行顺序会影响结果吗
unittest默认测试执行顺序是按字母序来的,但如果你用load_tests自定义了加载逻辑,或者用了unittest.TestSuite自己组织用例,顺序就可能不唯一。
我建议保持确定性顺序:每个测试文件里的方法按“基础功能→边界情况→异常情况”排序,跑之前已经固定,不会因为字典序让你看同样的顺序看到厌。
7.2 自定义基础测试类
如果你的多个测试类都需要构造一个复杂的对象,可以建一个公共基类,把公共setUp逻辑提进去:
class BaseTestCase(unittest.TestCase): def setUp(self): super().setUp() self.client = create_test_client() self.test_data = generate_fixture_data() class TestUserFunction(BaseTestCase): def test_user_creation(self): user = self.client.create_user(self.test_data["user"]) self.assertIsNotNone(user.id)这样写,每个子类都能自动获得公共的初始化和数据准备,好处是你不用在每个类里复制粘贴几行setUp。
7.3 失败时的信息量,决定了调试速度
断言失败时,unittest输出的默认消息往往就是AssertionError: 3 != 2,信息太少,你还需要自己回代码里看到底哪个变量错了。
我有个习惯:在断言失败消息里主动加上上下文信息。比如:
self.assertEqual(actual, expected, f"用户ID为{user_id}时,期望值{expected},实际值{actual}")这个不起眼的小动作,在跑几百个用例时能省下大量时间。因为失败信息直接告诉你用户ID、期望值、实际值,你根本不用去翻日志、打断点。
7.4 用mock.patch隔离时间依赖
代码里如果有“当前日期”,测试就不稳定。比如一个函数判断“今天是星期几”,或者是“今天是否过期”,你没法保证在任何一天跑测试都输出正确结果。
解法是用freezegun库,或者用mock手动固定时间。我现在用得比较多的是freezegun,真的能冻结时间:
from freezegun import freeze_time class TestExpiryLogic(unittest.TestCase): @freeze_time("2024-06-01 12:00:00") def test_expiry_middle(self): # 现在是2024年6月1日,测试一个7月1日到期的会员 result = check_membership_status(expire_date="2024-07-01") self.assertEqual(result, "active")如果你不想引入第三方依赖,也可以用mock.patch手动冻结datetime.date.today():
@patch("src.subscription.date") def test_membership_expiry(self, mock_date): mock_date.today.return_value = date(2024, 6, 1) result = check_membership_status(expire_date="2024-06-30") self.assertEqual(result, "expired")注意mock的是src.subscription里的date,不是全局datetime.date,原理跟之前patch网络请求完全一致。
8. 最后,测试代码也是代码,要维护
写了这么多,我最想强调的一点是:测试代码不是一次性的东西,它是你项目的重要资产。它跟你写的业务代码一样,需要保持整洁、需要重构、需要注释。
很多团队测试代码写得比业务代码还乱,结果越写到后面越不敢改测试,因为改一个断言可能要连带改十个其他测试。实际上,好的测试代码应该是“稳”的——业务逻辑变了,测试代码的改动应该被限制在最小范围,而不是每个需求变更都让测试推倒重来。
我现在的写法是:把测试拆成“数据准备”“断言行为”“辅助方法”三个层次。数据准备放在setUp或专门的helper里,断言只写真正关注的行为,辅助方法比如mock请求、构造对象,单独拆函数。这样等业务需求变更时,我往往只需要改helper函数或setUp,核心断言很少要动。
我自己踩过最大的坑,就是刚开始写测试时把mock数据硬编码在断言里,结果每次业务加字段都要去翻所有测试文件,改了后面忘了前面。后来我把mock数据统一放到一个fixtures.py文件里,用的时候导入,改字段只改一个地方。
不管你是刚入行还是写了几年代码,如果还没认真接触过unittest,我都建议从下一个小模块开始试起来。先写三五个简单用例,把目录结构搭好,再慢慢往里加复杂场景。等你真的经历过“测试帮你拦住一次生产事故”,你就再也回不去不写测试的日子了。