Python单元测试实战:从unittest到mock的工程化落地

发布时间:2026/9/8 14:52:14
Python单元测试实战:从unittest到mock的工程化落地 你有没有遇到过这样一种情况项目里的某个核心模块已经很久没人愿意碰了因为每次改完需求总会在另一个看起来毫不相关的入口冒出问题。我之前在一个电商项目里负责订单价格计算那套代码没有一行单元测试每次提测前我都得像人肉回归机器一样把所有入口手工点一遍。后来我下定决心把“写测试”变成日常开发的一部分用 Python 标准库自带的 unittest 把核心逻辑一层层保护起来才真正体会到“可回归”这三个字的价值。这篇文章就以 Python 单元测试为主线从 unittest 最基础的用例结构讲起到 setUp/tearDown 测试夹具、mock 外部依赖、测试套件组织再一直讲到 IDE 配置和覆盖率统计基本覆盖了我平时写单测的全部经验。适合刚开始接触测试的 Python 开发者也适合已经写过几个用例、但还不清楚怎么把测试工程化的朋友。unittest 是 Python 自带的标准单元测试框架不需要安装任何第三方包。这一点很重要在很多依赖管理严格、无法随便 pip install 的环境里它几乎是最省心的选择。1. 先想清楚单元测试在解决什么问题它覆盖的是回归风险1.1 一段没有测试的老代码为什么没人敢改我刚工作头两年也信奉“测试浪费时间”有那功夫不如多写两个功能。直到有一次线上价格计算出了问题我排查了三个小时最后发现是一个折扣规则的改动影响到了另一个入口。那次之后我才明白没有测试保护的代码正确性完全依赖人的记忆力而你今天的记忆力大概率应付不了三个月前的逻辑。举个例子。你有一个订单模块普通用户满 300 减 30VIP 用户一律打八五折。这个规则看起来不难但当需求变成“会员日当天 VIP 可以享受折上折”时问题就来了满减分支到底还执行不执行是先打折还是先减满如果你不知道原来每个分支的期望输入输出是什么就只能靠上线后用户反馈来发现问题。单元测试的核心价值就是把你对代码行为的预期固化成一组可重复执行的检查。这样下次任何人改动这段逻辑测试都能在几秒内告诉他你改坏了什么。它衡量的是“回归风险”而不是代码有没有 Bug。1.2 单测能抓住什么抓不住什么很多新手对测试有错误期待觉得写完测试代码就不可能出 Bug 了。实际上单元测试的工作边界比想象中窄得多。它验证的是在被测函数输入确定的情况下输出是否符合预期。它能抓住“折扣计算多减了十块”这种逻辑错误但抓不住“数据库连接串配置错了”这种环境问题更抓不住“第三方接口改了返回结构”这种集成问题。后面两类要交给集成测试、端到端测试和监控去解决。测试类型主要验证内容稳定性常用手段单元测试单个函数/类方法的逻辑高mock 掉外部依赖集成测试模块间协作、数据库/缓存/HTTP中真实服务或容器手工验证完整用户流程低点页面、看日志、核对数据单元测试是金字塔的底座数量最多、执行最快、反馈最及时。拿价格计算这种核心逻辑来说单测能覆盖绝大多数分支而“订单创建后能否正确写入数据库”这种跨模块行为更适合用集成测试去验证。1.3 为什么从 unittest 而不是 pytest 开始我知道现在很多项目组用 pytest网上教程也多。但作为入门我更推荐先吃透 unittest原因很朴素它是标准库的一部分你不需要引入任何外部依赖就能跑起来这对初学者排除环境干扰非常重要。unittest 是典型的 xUnit 风格类继承、setUp、tearDown 这些概念和 Java 的 JUnit、C# 的 NUnit 是一脉相承的。你在这里建立的心智模型换到其他语言也能直接复用。pytest 语法确实更简洁fixture 也很强大但它是对测试思维的封装如果底层概念不理解换个框架可能又变成只会抄配置。还有一个很现实的原因有些公司内部工具的插件、CI 基础镜像、离线环境里根本没有 pytest但你永远可以用python -m unittest跑测试。等到你把 unittest 的类组织、断言、mock、测试发现都弄明白了再切到 pytest基本上半天就能上手。2. 从零跑通第一个 unittest 用例目录、命名与断言方法2.1 最小工程目录与被测模块设计为了让后面的内容不悬空我设计一个贯穿全文的示例一个订单折扣计算函数。实际项目里这样的函数可能藏得很深但本质都是一样的——接收输入、处理逻辑、返回结果。先搭目录结构project/ ├── shop/ │ ├── __init__.py │ └── order.py └── tests/ ├── __init__.py └── test_order.py被测代码写在shop/order.py里def calc_discount(price, level): if price 0: raise ValueError(price must be positive) if level vip: return round(price * 0.85, 2) if price 300: return round(price - 30, 2) return price我刻意让这个函数包含三个分支和一个异常分支VIP 折扣、满减、无折扣、非法输入。这样后面演示各类测试手段时每个分支都能用上。2.2 写第一个用例并跑起来三种运行方式测试文件tests/test_order.pyimport unittest from shop.order import calc_discount class CalcDiscountTest(unittest.TestCase): def test_vip_user_discount(self): self.assertEqual(calc_discount(100, vip), 85.0) def test_normal_user_under_300_no_discount(self): self.assertEqual(calc_discount(200, normal), 200) def test_normal_user_over_300_has_reduction(self): self.assertEqual(calc_discount(350, normal), 320) def test_invalid_price_raises(self): with self.assertRaises(ValueError): calc_discount(0, normal)这里有三条 unittest 的硬性规则缺一不可测试类必须继承unittest.TestCase测试方法名必须以test开头断言必须用self.assertXxx系列方法而不是裸写assert在项目根目录执行python -m unittest tests.test_order -v你会看到四个用例依次跑过输出里带ok。整套流程走通之后我再强调一个新手最容易踩的坑。很多人的习惯是直接运行测试文件比如python tests/test_order.py。这也不是不行但有点条件文件里要补上unittest.main()入口而且要在项目根目录下运行让 Python 能找到shop包。真正推荐的方式是python -m unittest因为-m会把当前目录加入模块搜索路径。如果你在 tests 目录下直接跑测试文件Python 会把tests目录当作根路径from shop.order import calc_discount大概率会报ModuleNotFoundError。这个问题不解决你会在测试入门阶段白白耗掉很多时间。另外unittest的测试发现机制也很常用python -m unittest discover -s tests -p test*.py -v-s指定测试目录-p指定匹配文件名的模式。这样你不需要手工维护文件列表新加的测试文件只要命名符合test*.py就会被自动发现。2.3 常用断言方法选型从 assertEqual 到 assertRaises断言是测试的“裁判”你选择什么断言方法决定了失败时能拿到多少信息。我整理了一张平时最常用的表格断言方法检查内容适用场景assertEqual(a, b)a b数值、字符串、对象相等assertTrue(x)/assertFalse(x)x为真/假布尔结果、标志位assertIsNone(x)x is None函数没有返回值assertIn(item, container)item in container列表、字典键、字符串子串assertAlmostEqual(a, b, places7)浮点数近似相等涉及浮点运算的结果assertRaises(exception, callable, *args)是否抛出指定异常非法输入校验assertRaises的上下文管理器写法在实际项目里最常用因为它可以精确控制抛异常的代码范围with self.assertRaises(ValueError): calc_discount(0, normal)这个写法的好处是如果被测代码没有抛ValueError测试立即失败而且失败信息会明确指出期待的是哪种异常。断言失败信息也值得花点心思。默认情况下assertEqual失败会打印两个值但如果你在复杂业务里跑了上百条断言未必能一眼看出是哪个环节错。我习惯给关键断言加第三个参数也就是自定义失败消息self.assertEqual( calc_discount(100, vip), 85.0, VIP 用户 100 元订单应打八五折后为 85 元, )这样测试失败时控制台会直接告诉我们业务期望而不是只给两个让人摸不着头脑的数字。2.4 关于浮点数断言的一个经典易错点如果你在测试里写过assertEqual(0.1 0.2, 0.3)大概率踩过坑。这不是 Python 的问题而是二进制浮点数的通用问题0.1 0.2的计算机结果是0.30000000000000004和0.3并不相等。所以只要被测代码涉及浮点数计算就不要用assertEqual而要改用assertAlmostEqualself.assertAlmostEqual(0.1 0.2, 0.3, places7)places7表示比较到小数点后 7 位。回到价格计算示例我故意在函数里用了round(price * 0.85, 2)把结果约束在两位小数所以assertEqual(100 * 0.85, 85.0)大概率能过。但更稳妥的做法仍然是针对浮点计算使用assertAlmostEqual因为哪怕你在代码里先乘后除中间结果也可能出现尾部误差。3. setUp/tearDown 与测试夹具别让测试依赖共享状态3.1 setUp 与 tearDown 的执行细节每个用例一次当你开始测试文件读写、数据库操作或者其他需要准备数据的功能会发现每个用例前都要重复做相似的事情创建临时文件、插入测试记录、建立连接。unittest 提供了setUp和tearDown方法来封装这些准备和清理工作。看一下这个写临时文件的例子import os import tempfile import unittest class FileProcessorTest(unittest.TestCase): def setUp(self): fd, self.path tempfile.mkstemp() with os.fdopen(fd, w) as f: f.write(100,200\n300,400\n) def tearDown(self): if os.path.exists(self.path): os.remove(self.path) def test_read_lines(self): with open(self.path) as f: content f.read() self.assertIn(300,400, content)setUp会在每一个测试方法执行前被调用tearDown会在每个测试方法执行后调用。执行顺序是setUp() test_read_lines() tearDown()即使某个测试方法断言失败tearDown依然会执行这一点是 unittest 替你兜底的。但如果setUp本身抛了异常tearDown不会被调用这个细节很多人不知道后面我会在踩坑章节单独展开。使用setUp的核心意义是让每个用例从同一个“干净起点”出发。你不能假设上一个用例跑完了文件内容还是原样更不能假设某个用例给数据库插了一条数据下一个用例就能直接读到。3.2 setUpClass 与 tearDownClass整个测试类只执行一次有些资源的创建成本很高比如数据库连接、大型配置对象。如果每个用例都重建一次测试会慢得让人失去耐心。这种情况下可以用类级别的夹具class DbTest(unittest.TestCase): classmethod def setUpClass(cls): cls.connection create_database_connection() cls.connection.execute(CREATE TABLE temp_orders (...)) classmethod def tearDownClass(cls): cls.connection.execute(DROP TABLE temp_orders) cls.connection.close()注意这里必须加classmethod装饰器而且方法名是setUpClass不是setUp。类级夹具只在类里第一个测试方法执行前运行一次在所有测试方法执行完后再清理一次。使用类级夹具意味着多个用例共享同一个资源所以你必须保证用例之间不会互相修改共享资源中的数据。通常的做法是连接对象可以共享表数据绝不共享每个用例在setUp里插入本次测试需要的数据在tearDown里清掉。3.3 夹具反模式共享状态的全域污染新手最容易写出的问题是不清理测试数据并且假设执行顺序固定。比如第一个用例往列表里加了一个元素第二个用例基于这个元素继续断言。这么写当时可能没问题但 unittest 的执行顺序是按方法名的字典序来排的而不是代码里的书写顺序。你今天写test_a、test_b能过明天加一个test_0执行顺序变了测试就挂了。还有一个更隐蔽的坑模块级全局变量。测试类里定义的类属性是全局共享的如果你在测试方法里直接修改self.__class__.some_list会影响后续所有用例。相比之下setUp里创建的实例属性是全新的互不干扰这是更安全的做法。我总结一条经验测试夹具的设计目标是“可重复、可隔离”。判断标准很简单——同一批测试跑十次每次的结果应该完全一致任意挑一个用例单独跑结果也应该和其他用例一起跑时完全一致。4. 把外部依赖请出测试现场unittest.mock 的隔离思路与常见翻车点4.1 为什么单测不能真实请求外部依赖假设订单模块要判断用户是不是高价值会员高价值的标准是外部会员服务的积分大于等于 90。最直白的实现是这样的# member_client.py import requests def fetch_member_score(uid): resp requests.get( fhttp://member.internal/api/score/{uid}, timeout3, ) return resp.json()[score]# service.py from member_client import fetch_member_score def is_high_value_member(uid): return fetch_member_score(uid) 90如果测试里真的去请求http://member.internal后果很清晰第一测试依赖网络网络一抖动就挂第二测试依赖对方服务的返回数据对方数据一变你就得跟着改测试第三单元测试的执行速度会从毫秒级变成秒级跑一次全量测试变得很痛苦。单元测试想验证的是“当外部积分为 95 时is_high_value_member返回 True”而不是“会员服务今天是否真的返回 95”。所以我们要把外部依赖换成可控的替身这就是 mock 存在的理由。4.2 patch 目标写在哪最容易翻车的 location 问题unittest.mock提供的主要工具是mock.patch。最常用的有两种姿势装饰器风格和上下文管理器风格。from unittest import mock from service import is_high_value_member class HighValueMemberTest(unittest.TestCase): mock.patch(service.fetch_member_score) def test_high_value_member(self, mock_fetch): mock_fetch.return_value 95 self.assertTrue(is_high_value_member(u_001)) mock_fetch.assert_called_once_with(u_001)也可以改成上下文管理器写法当你只在一个方法里的部分代码需要 mock 时更灵活def test_high_value_member(self): with mock.patch(service.fetch_member_score) as mock_fetch: mock_fetch.return_value 95 self.assertTrue(is_high_value_member(u_001))这里有一个极大的坑patch 的目标参数service.fetch_member_score是“被测代码去查找这个名字的模块”而不是“这个函数原始定义在哪个模块”。因为service.py是通过from member_client import fetch_member_score把函数绑到了自己的模块命名空间里所以is_high_value_member执行时它查找的是service.fetch_member_score。如果你写成mock.patch(member_client.fetch_member_score)替换的是 member_client 里的名字而 service 里的引用根本没有变mock 不会生效。测试可能照常通过但通过的原因是你没有触发真实的网络请求还是有别的巧合就很难判断了。判断标准是先看被测函数内部通过哪个名字访问依赖。如果用from module import func就 patch 本模块里的func如果通过import module然后module.func()调用就 patch 原始模块的module.func。4.3 return_value 与 side_effect不同场景怎么选return_value是给 mock 对象设定固定返回值适合大多数“返回一个结果”的场景。mock_fetch.return_value 95side_effect则更强大。它有三种常见用法第一种让 mock 抛异常用来测试异常分支。比如入口调用超时mock_fetch.side_effect TimeoutError(member service timeout) with self.assertRaises(TimeoutError): is_high_value_member(u_

相关新闻