ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python unittest单元测试实战:从断言到mock的完整指南

Python unittest单元测试实战:从断言到mock的完整指南 很多人第一眼看到 Python 标准库里的 unittest第一反应往往不是“好用”而是“麻烦”。命名要写一长串断言方法还分那么多种跟现在流行的 pytest 一比好像又老又啰嗦。但我这几年维护项目写下来反而觉得 unittest 是最值得先吃透的一套单测框架。它是标准库自带的不需要装任何第三方包就能跑而且 pytest 也能直接兼容 unittest 的用例。也就是说你花时间学会 unittest 的套路去哪儿都不亏甚至后面换 pytest很多经验照样平移过去。今天这篇不打算给你铺一堆概念而是从真实开发场景出发把 Python 单元测试unittest里最实用的部分拆开讲一遍。内容适合几类人刚接触后端开发、想把代码从“能跑”变成“敢改”的新人写了几年脚本但一直靠手动验证的老哥还有想在团队里规范测试但被 pytest 生态绕晕的工程负责人。看完你至少能独立写出一个可维护的测试目录搞明白 mock、skip、参数化和覆盖率这些高频需求该怎么落地。1. 为什么单测值得做以及为什么偏偏是 unittest1.1 单元测试解决的真实问题做单元测试本质上不是为了“看起来规范”也不是为了应付绩效考核而是为了解决三个非常实际的问题回归、信号、重构保险。回归是最好理解的一个。我写过很多业务系统最头疼的版本不是新功能上线那天而是三个月后有人改了个公共函数结果把八个业务模块全带崩。你手动点页面能验证两条主流程可验证不了所有边界。单元测试能在你改完代码后跑一遍几百个断言哪个模块炸了会直接报红不用靠经验猜。这个价值在项目越大时体现得越明显。第二个是信号。测试用例本身就是一种“需求说明书”。比如你写了一个计算折扣的方法测试里写了输入订单金额 1000、折扣率 20%、期望输出 800那后面接手的人一看这个测试就明白了哦原来这个方法的设计约定是这样。很多接口文档写得不及时但测试是活的它一直锁住行为。第三个是重构保险。没有单测的时候抽象公共方法、改内部实现、替换第三方库每一次都是如履薄冰。有了单测你重构完直接跑一遍测试绿了就说明对外行为没变。我个人的感觉是单测写得越多的模块我越敢动它的代码不敢动的老代码往往就是没有单测保护的代码。1.2 unittest、pytest、doctest 怎么选Python 的测试工具其实很多但入门阶段核心就三个unittest、pytest、doctest。我先把它们的区别和适用场景列出来你心里有数了再选就不容易被带偏。框架依赖情况风格典型优势适合场景unittest标准库自带xUnit 风格类 方法零安装、任何环境都能跑脚本、工具库、公司项目、CI 受限环境pytest需要安装函数式 魔法断言写法简洁、插件丰富量级较大、追求效率的团队项目doctest标准库自带写在 docstring 里的例子文档即测试小型函数、示例代码验证从学习性价比来说unittest 是最稳的起点。你不需要为了写测试先搭建环境python -m unittest这条命令在任何装了 Python 的机器上都能用。如果后面觉得 unittest 写起来太重pytest 对 unittest 用例是兼容的你原来的测试类不需要改动就能用 pytest 跑。等于你学 unittest 是在给自己留后路。我见过一些团队一上来就上 pytest当然体验很好插件多得眼花缭乱。但如果你写的是工具类、数据处理脚本、企业内部小系统unittest 真的够用了不需要引入额外的测试依赖。1.3 什么项目适合直接用 unittest不是所有项目都必须上重武器。根据我自己的项目经验下面这几类直接用 unittest 就很合适。第一类是小工具包、SDK、算法模块。这类代码逻辑密集、外部依赖少用 unittest 的 TestCase 写起来干净利落。第二类是运行环境受限的服务器或 CI 系统比如内网机器往往不能随便装第三方包但标准库一定在。第三类是团队里已经有一套 pytest 规范但你接手的是老模块老模块里已经有 unittest 写好的大量用例这时候直接用 unittest 跑反而是最稳妥的。反过来讲如果项目是大型 Web 应用、需要大量 mock HTTP 服务、需要合并 JUnit 风格的 XML 报告或者测试数据要高度工程化那可以并行引入 pytest把 unittest 只作为兼容层保留。2. 理解 unittest 的核心机制TestCase、断言与 fixture2.1 TestCase 到底在干什么很多人把 unittest.TestCase 当成“测试类”这个理解太粗了。TestCase 其实是一个“用例容器”你在里面定义的每一个以test_开头的方法才是真正的一条测试用例。import unittest class TestDemo(unittest.TestCase): def test_first(self): self.assertEqual(1 1, 2)当你运行这个模块时unittest 会扫描所有继承了 TestCase 的子类找出名字以test_开头的方法然后逐个执行。一个类里可以放十几个测试方法它们共享 setUp 和 tearDown 提供的测试环境但每条用例的执行是独立的先 setUp然后运行 test 方法再 tearDown。这里有个细节很容易踩坑用例的执行顺序不是按你代码里写的先后而是按方法名的 ASCII 码顺序。比如test_add永远比test_b先跑。所以你的用例之间绝对不能有顺序依赖。我自己碰到过最经典的坑是有人把“创建订单”和“查询订单”写在两个用例里查询依赖创建的数据结果换了一台机器、换了 Python 版本之后顺序变了一下第二个用例就挂得一塌糊涂。2.2 常用断言方法怎么选unittest 的断言方法看着多其实核心就那么几个用熟之后非常顺手。我整理了一个迷你速查表断言方法作用assertEqual(a, b)判断 a 和 b 是否相等assertNotEqual(a, b)判断不相等assertTrue(x) / assertFalse(x)判断布尔值assertIsNone(x) / assertIsNotNone(x)判断是否为 NoneassertIn(x, items) / assertNotIn(x, items)判断成员关系assertAlmostEqual(a, b, places7)浮点数近似相等assertRaises(Exc, func, *args)断言必须抛出指定异常assertIsInstance(obj, cls)判断类型assertGreater(a, b)判断 a b有几个地方值得单独提醒。浮点数比较千万别用 assertEqual。你写assertEqual(0.1 0.2, 0.3)结果永远是 False因为二进制浮点数的精度问题。要比较浮点结果用assertAlmostEqual或者round之后再比才能避免莫名其妙地飘红。异常断言也千万别写自己 try。有些新手会这样try: calc.divide(10, 0) except ZeroDivisionError: pass这种写法的问题是没有异常的时候这个用例也会“静默通过”等于白测。正确做法是用assertRaises的上下文管理写法with self.assertRaises(ZeroDivisionError): calc.divide(10, 0)这段代码表达的意思很清晰如果divide(10, 0)不抛出 ZeroDivisionError测试就直接失败抛了才算通过。2.3 setUp、tearDown 与 addCleanup 的生命周期setUp 和 tearDown 是所有 unittest 用例的“前置准备”和“后置清理”。它们会在每条用例执行前、后各跑一次所以非常适合放一些通用初始化逻辑。class TestCalculator(unittest.TestCase): def setUp(self): self.calc Calculator() def tearDown(self): # 这里可以清理临时文件、断开连接等 pass def test_add(self): self.assertEqual(self.calc.add(1, 2), 3)这样做的好处很明显每一个用例拿到的都是全新的 Calculator 实例不会出现上一个用例改了内部状态、影响下一个用例的情况。如果你有一组用例需要共用一个重量级对象可以把它放到 setUpClass 里用类方法实现class TestDatabase(unittest.TestCase): classmethod def setUpClass(cls): cls.conn create_test_database() classmethod def tearDownClass(cls): cls.conn.close()setUpClass 只在类跑之前执行一次适合创建数据库连接、初始化耗时资源。但这里有个大坑如果 setUpClass 里创建的是一个可变的共享对象而某个用例又改了它的状态那接下来的用例全部会受影响。所以共享资源要么是只读的要么每个用例里重新拷贝一份。我实际使用中更喜欢用 addCleanup 而不是 tearDown。addCleanup 是注册一个“无论用例成功失败都会执行”的清理函数而且它是后进先出特别适合处理临时目录、mock、进程这类资源def test_use_temp_dir(self): tmp tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup) # 开始干活为什么推荐它因为如果 setUp 阶段就抛异常tearDown 不一定能正常执行容易留下脏数据而 addCleanup 的注册机制更稳健处理异常场景很从容。3. 完整实战从纯函数到有外部依赖的服务3.1 纯函数层计算器的完整测试光讲概念容易飘我直接用一个真实会出现在项目里的模块来演示。先写一个纯业务类Calculatorclass Calculator: def add(self, a, b): return a b def divide(self, a, b): if b 0: raise ZeroDivisionError(除数不能为0) return a / b def parse_number(self, text): try: return float(text.strip()) except (ValueError, TypeError): raise ValueError(f无法解析: {text!r})然后在 tests 目录下写测试文件import unittest from calculator import Calculator class TestCalculator(unittest.TestCase): def setUp(self): self.calc Calculator() def test_add(self): self.assertEqual(self.calc.add(1, 2), 3) self.assertEqual(self.calc.add(-1, 1), 0) def test_add_float(self): self.assertAlmostEqual(self.calc.add(0.1, 0.2), 0.3) def test_divide_normal(self): self.assertEqual(self.calc.divide(10, 2), 5) def test_divide_by_zero(self): with self.assertRaises(ZeroDivisionError): self.calc.divide(10, 0) def test_parse_number_ok(self): self.assertEqual(self.calc.parse_number( 3.14 ), 3.14) def test_parse_number_invalid(self): with self.assertRaises(ValueError): self.calc.parse_number(abc)看这里的设计思路。test_add里我放了两组数据第一组是正整数和第二组是负数相加这是覆盖“正常路径”和“符号边界”。test_add_float处理的是浮点精度。test_divide_by_zero和test_parse_number_invalid是“异常路径”。真正有效的单测从来不是把每个方法都点一遍而是把同一个方法按“正常、边界、异常”三个维度拆开验证。运行测试时在项目根目录执行python -m unittest tests.test_calculator -v-v会输出每个用例名和结果看到绿色的 OK 才放心。3.2 外部服务层用 mock 测订单折扣跑项目里的实际代码很少有纯函数这么简单。大部分函数会依赖外部接口、数据库或时间如果直接联网测试不仅慢而且结果不稳定。这时候就必须上 mock。我写一个典型的订单服务它依赖 requests 去请求折扣接口# order_service.py import requests class OrderService: def __init__(self, base_urlhttps://api.example.com): self.base_url base_url def fetch_discount(self, order_id): resp requests.get(f{self.base_url}/discount/{order_id}, timeout3) if resp.status_code ! 200: raise RuntimeError(f请求失败: {resp.status_code}) data resp.json() return data[discount]如果在真实测试里直接调这个函数每次都要访问外网服务一挂测试就崩。于是我们用 unittest.mock 把 requests.get 替换掉from unittest.mock import patch, MagicMock import unittest from order_service import OrderService class TestOrderService(unittest.TestCase): patch(order_service.requests.get) def test_fetch_discount_success(self, mock_get): mock_resp MagicMock() mock_resp.status_code 200 mock_resp.json.return_value {discount: 0.15} mock_get.return_value mock_resp service OrderService() result service.fetch_discount(A001) self.assertEqual(result, 0.15) mock_get.assert_called_once_with( https://api.example.com/discount/A001, timeout3 )这段代码有几个关键点。patch(order_service.requests.get)里的路径是“order_service 模块里的 requests 模块的 get 方法”它替换的是被测模块实际调用的那个对象。很多人写 mock 失败就是因为 patch 的路径写错了后面我会专门讲这个坑。mock_get.assert_called_once_with(...)是在验证被测函数确实发起了符合预期的请求URL 拼接正确、超时时间正确。这个断言特别有价值因为你可能改代码时把 URL 拼错了但返回值 mock 出来一样能过前一步这一步能把拼错 URL 的问题抓出来。再补充一个异常场景的测试class TestOrderServiceFail(unittest.TestCase): patch(order_service.requests.get) def test_fetch_discount_http_error(self, mock_get): mock_resp MagicMock() mock_resp.status_code 500 mock_get.return_value mock_resp service OrderService() with self.assertRaises(RuntimeError): service.fetch_discount(A001)这个用例模拟的是后端接口返回 500 的状态确保业务代码能正确识别并抛出异常。这种用例不联网、不依赖环境随便哪台机器跑都是稳定的。3.3 测试隔离与运行方式的实战细节很多测试写得越来越难维护根因就是“用例之间互相污染”。你可能会遇到这样的场景第一次单独跑某条用例是绿的全量跑就红或者按 A 顺序跑全绿按 B 顺序跑就有几条失败。这基本可以断定是隔离没做好。要保证隔离我的习惯是守住三条纪律不在测试里修改全局配置而不恢复不把测试数据写进共享目录不在类属性上保存会被修改的对象。比如有些代码依赖环境变量测试里需要临时改一下我会用unittest.mock.patch.dict或者patch.object来改而不是直接赋值这样用例结束后自动恢复with patch.dict(os.environ, {APP_ENV: test}): # 这里读到的环境变量是 test # 这里自动恢复原样运行方式上除了单文件运行更常用的是整个 tests 目录批量跑python -m unittest discover -s tests -p test_*.py -v这条命令会扫描 tests 目录下所有以 test_ 开头、以 .py 结尾的文件自动收集里面的测试用例并执行。后续每新增一个测试文件不用改任何配置它自然会被发现。4. 参数化、跳过场景与 mock 进阶4.1 用 subTest 做轻量参数化写测试时最枯燥的就是“同一个函数换几组数据反复断言”。如果你把多组数据写成多个用例文件会膨胀得很厉害。unittest 没有像 pytest 那么漂亮的 parametrize但它有 subTest一样能解决批量测试数据的问题。class TestParseNumber(unittest.TestCase): def test_multi(self): cases [ ( 1.5 , 1.5), (abc, ValueError), (, ValueError), ( 0 , 0.0), ] calc Calculator() for text, expected in cases: with self.subTest(texttext): if expected is ValueError: with self.assertRaises(ValueError): calc.parse_number(text) else: self.assertEqual(calc.parse_number(text), expected)subTest 的好处是一组数据失败其他组依然会继续跑而且失败信息里会带上text...这样的上下文你一眼就能看出是哪组数据出了问题。如果不加 subTest用普通的 for 循环一旦中间某组断言失败整个用例直接停止后面的数据全部没测到排错效率会差很多。4.2 skip 与 expectedFailure 的正确用法unittest 提供了跳过机制用来处理“当前环境不适合跑某条用例”的情况。比如某个功能只在 Python 3.10 以上才支持你要在低版本环境保持测试通过可以这样import sys import unittest unittest.skipIf(sys.version_info (3, 10), 需要 Python 3.10) def test_new_feature(self): ...还有unittest.skip(暂时跳过)和unittest.skipUnless(condition, reason)一个是无条件跳过一个是不满足条件时跳过。这些在跨平台、跨版本的项目里特别实用。但我要泼一盆冷水skip 机制非常好用也非常容易被滥用。我见过一个项目里堆了几百个 skip 用例表面全绿实际上核心逻辑没人在测。如果你跳过是因为“知道这个功能坏了但没时间修”请至少用unittest.expectedFailure它表达的是“这个用例预期会失败失败了算通过如果哪天意外通过还会提醒你去清理标记”。这样至少把“故意跳过”和“坏了不修”分开。4.3 mock 的常用进阶姿势mock 的初级用法是模拟返回值进阶用法有三个方向我挨个说。第一个是使用 side_effect 模拟异常或连续不同的返回值。如果你测超时场景只需要让 mock 在被调用时抛异常from unittest.mock import patch patch(order_service.requests.get) def test_timeout(self, mock_get): mock_get.side_effect TimeoutError(请求超时) service OrderService() with self.assertRaises(TimeoutError): service.fetch_discount(A001)side_effect 也可以传一个列表每次调用依次返回列表里的元素适合模拟“第一次失败第二次成功”的重试逻辑。第二个是使用 assert_has_calls 验证一系列调用顺序。有时候你不仅关心函数被调用过还关心它的调用顺序比如先保存草稿再提交订单。mock 对象记录了每次调用的参数你可以用assert_has_calls把期望的调用序列写出来。第三个方向是 mock 对象要尽量使用 spec。直接创建 MagicMock 时你访问任何属性都能返回一个 mock这会导致你调用了根本不存在的属性也不会报错。如果你的被测代码本意是resp.json写成了resp.jsno因为 MagicMock 太宽松测试照样通过但它实际上是无效测试。用MagicMock(specResponse)可以限制它只能访问 Response 类真实存在的属性多一层保护。mock_resp MagicMock(specrequests.Response)不过我也要强调一个边界不要过度使用 mock。如果你把被测函数依赖的全部东西都 mock 掉了那测试就不再是验证真实逻辑而是“自证自”。要遵循一个原则只 mock 边界之外的不可控依赖比如网络、时间、随机数自己写的核心业务逻辑不要 mock。5. 测试工程化目录、命名与持续集成5.1 推荐的目录结构与发现规则测试写多了以后文件和目录的组织就会直接影响可维护性。我长期用的结构是这样project/ app/ __init__.py calculator.py order_service.py tests/ __init__.py test_calculator.py test_order_service.pyapp 目录放业务代码tests 目录放测试代码两者不混在一起。测试文件名统一用test_开头方便 unittest discover 自动收集。tests 目录下加不加__init__.py取决于你的项目。加了之后测试模块就可以用全限定名导入比如from tests.test_calculator import TestCalculator在 IDE 里跳转定位会更方便不加的话用python -m unittest discover -s tests也能跑。但如果遇到相对导入的问题我通常更推荐加一个空文件省得后续为导入路径折腾。单个用例、单个类、整个目录三种运行方式都要会# 跑单个测试用例 python -m unittest tests.test_calculator.TestCalculator.test_add -v # 跑整个测试类 python -m unittest tests.test_calculator.TestCalculator -v # 跑整个测试目录 python -m unittest discover -s tests -v5.2 保证测试的可重复性好的测试必须满足一个特性随便在哪台机器、随便跑多少次结果都一样。做不到这一点的测试会慢慢失去信任最后变成“反正跑不跑都过不了干脆不跑”。可重复性的敌人主要是外部状态。我遇到过的几种典型问题测试依赖某个固定工作目录下的文件换个目录跑就找不到文件测试依赖系统时间断言“今天”的逻辑一到跨天就挂测试往真实数据库插了一条数据没清理干净下次再跑就主键冲突。对策很直接。文件读写尽量用tempfile.TemporaryDirectory()测完自动清理。时间相关的逻辑把“当前时间”设计成可以通过参数传入测试时注入固定时间或者用 mock 修改时间函数。数据库相关测试要么用事务回滚要么用独立测试库不要碰生产环境。随机数相关则固定 seed。这些都是老生常谈但每一条都对应一个真实踩过的坑。5.3 接入 CI 自动跑测试本地写测试只是第一步真正让测试发挥价值的是让它自动跑。你不用再纠结选哪个 CI本质逻辑都一样代码一提交流水线里执行一条跑测试的命令。我常用的命令是这一套pip install -r requirements.txt python -m unittest discover -s tests -v如果项目里有 coverage再追加覆盖率统计coverage run -m unittest discover -s tests coverage report -m接入 CI 之后最大的收益是“回归被自动发现”。我不需要指望每个同事都在本地跑测试提交记录一多哪次改动弄坏了东西马上就能从流水线状态看到。这个体验一旦尝到就很难退回纯手工验证的状态。6. 常见问题排查实录6.1 模块导入失败症状是跑测试时报ModuleNotFoundError: No module named calculator。九成原因是终端当前目录不在项目根目录或者 PYTHONPATH 没配置。解决办法是在项目根目录运行命令而不是在 tests 目录里运行。如果你确定目录没错再看看测试文件里的导入语句是不是from app.calculator import Calculator这个路径要和你的包结构完全对应。一个排查技巧先写一个临时脚本python -c import sys; print(sys.path)看看当前路径在不在搜索范围里。6.2 断言失败难定位断言失败的报错看起来很长其实核心只有两行期望值是什么、实际值是什么。我用 assertEqual 失败时会输出详细的 diff用 assertTrue 失败时只显示 True is not True信息太少。所以尽量用更具体的断言方法。如果你发现实际值和期望值明明长得一样但类型不同先检查是不是一个是 int、一个是 float比如 3 和 3.0断言是严格相等这种情况下会失败。6.3 patch 路径写错patch 路径错误是 mock 最经典的坑。比如你在 order_service.py 里写的是from requests import get然后用patch(requests.get)去 patch那一定不会生效因为 order_service 模块里根本没有requests.get这个全局名字。正确路径是 patch 到被测模块的命名空间如果模块里import requests那就写patch(order_service.requests.get)如果模块里from requests import get那就写patch(order_service.get)。理解逻辑很简单你 patch 的是“被测代码在运行时去查找名字的那个位置”不是请求库原本的位置。6.4 测试之间相互污染遇到过很多次全部用例跑就挂几条单独跑那几条反而全绿。先检查是不是共享了可变对象。比如 setUpClass 里创建了一个列表对象每条用例往里追加数据第二条用例断言时就发现列表多了东西。解决办法是每个用例里重置对象或者干脆在 setUp 里新建实例。还有一个隐蔽原因是环境变量被改了没恢复用patch.dict能解决。6.5 网络相关的不稳定测试测试里出现真实网络请求是稳定性最大敌人。有些开发者只把成功路径 mock 掉但异常路径比如超时、断网、返回非 JSON根本没有 mock于是测试时好时坏。建议把所有网络边界全部放进 mock 的覆盖范围离线环境下也能完整跑通全部测试。我有一条硬规矩单测里不允许出现真实网络请求凡是外部接口调用必须被 patch。6.6 每个用例必须在任何顺序下都能跑unittest 按方法名 ASCII 顺序执行用例这个顺序是不稳定的。你不能指望先跑 A 再跑 B因为以后可能有人给方法重命名或者你接入了随机排序插件。保证每个用例独立成立最佳方式就是不在用例之间共享数据。如果真有公共准备数据放在 setUp 里为每个用例单独创建不要用一个类属性互相传递。我自己也是从“写测试只是赶工”一路走过来的。最开始写单测纯粹是因为团队要求覆盖率我甚至会把断言和最简代码堆在一起糊弄。后来有一次改动一个几十处调用点的公共方法凭着手动验证根本盯不过来是 unittest 把所有回归问题一次暴露出来的。从那次以后我才真正把测试当成开发的一部分。如果你现在还没养成写单测的习惯我建议从新写的模块开始不用贪多先把一个纯函数和一个带外部依赖的服务测起来。用 unittest 不用装任何东西跑通一次之后的正反馈会远远超出你写测试时付出的那点时间。最后再分享一个小技巧写新功能前先花五分钟把期望行为用测试用例描述出来再写实现。这个顺序虽然反直觉但能让你的接口设计更清晰很多没想明白的问题在写测试的那几分钟里就会暴露出来省下的调试时间远比你想象的多。
返回列表