
搞了这么多年Python除了写业务代码最让我觉得“值回票价”的事就是认真对待单元测试。特别是用Python自带的unittest它不像pytest那样开箱即炫但胜在稳定、无依赖、任何环境都能跑。很多人觉得写测试麻烦、拖进度但真实情况是没有测试保护的项目越到后期越改不动。这篇文章我不打算讲那种“茴香豆的茴有几种写法”的教程而是把我实际项目中用unittest踩过的坑、总结出来的套路以及一套可以直接抄作业的完整流程一次性分享出来。无论你是刚接触Python测试的新手还是写了一阵子pytest想回头补补unittest基础的老手这篇文章都能给到一些实在的东西。1. 为什么单元测试值得你认真对待1.1 从一次线上事故说起我之前维护过一个数据处理服务核心逻辑是一个金额汇总函数。某次需求变更同事顺手把一个小数位处理改了一下结果线上报表对不上账。排查到最后就是那个函数在边界输入下返回了错误结果。当时项目里没有任何测试谁都不敢拍胸脯说“我只改了一行不影响别的地方”。这件事之后我痛定思痛开始给核心模块补测试。补的过程才发现unittest能帮你抓住的问题远比想象中多。它不只是在“验证结果对不对”更是在给你后续的重构和迭代上保险。你改了一个函数的内部实现只要对外行为不变测试全部通过你就可以放心提交代码这种安全感是写业务代码时很难获得的。1.2 单元测试到底在保护什么单元测试的本质是把代码拆成最小可验证单元然后对每个单元的输入输出做断言。它保护的不仅仅是“当前功能正确”更重要的是“未来功能依然正确”。尤其是多人协作的项目里别人改你的代码、你改别人的代码如果没有测试兜底很多问题要等到联调、测试环境甚至生产环境才暴露。用一句话概括单元测试是让你在改代码的时候敢说“这没问题”的底气来源。它不会帮你减少写代码的时间但能帮你大幅减少排查问题的时间而且省下的往往是熬夜加班的深夜时间。理解了这一点下面那些看似繁琐的框架细节才有了真正的价值。2. unittest核心机制拆解把框架的脾气摸透2.1 TestCase、TestSuite、TestRunner三件套unittest的设计思路脱胎于JUnit核心就三个概念TestCase测试用例、TestSuite测试套件、TestRunner测试运行器。TestCase是最小测试单元你写的每个测试类都要继承unittest.TestCase类里每个test_开头的方法就是一条具体用例。TestSuite是多个用例的集合你可以把不同模块的测试类组织到一起统一运行。TestRunner负责执行测试并输出结果平时跑python -m unittest时背后就是一个默认的TestRunner。最基础的写法长这样import unittest class TestStringMethods(unittest.TestCase): def test_upper(self): self.assertEqual(foo.upper(), FOO) def test_isupper(self): self.assertTrue(FOO.isupper()) self.assertFalse(Foo.isupper()) if __name__ __main__: unittest.main()这段代码几乎出现在所有入门教程里但实际工作中有一点新人容易忽略if __name__ __main__这行只是方便你用python test_demo.py直接运行。更标准的做法是直接用python -m unittest test_demo来跑这样能自动识别测试类里的用例不需要你手动一个个调用。2.2 断言方法全家桶别只会assertEqualunittest最大的便利就是自带几十个断言方法。许多人写测试只会用assertEqual和assertTrue结果很多本可以用专用断言清晰表达的场景都绕了个弯。其实掌握常用的这些就够了断言方法作用使用场景assertEqual(a, b)判断相等最常见验证返回值assertNotEqual(a, b)判断不等验证传入非法参数后的返回assertTrue(x)/assertFalse(x)判断真/假验证布尔逻辑如权限判断assertIsNone(x)/assertIsNotNone(x)判断是否为None验证异常分支返回assertIn(a, b)/assertNotIn(a, b)判断成员关系验证列表中包含预期元素assertRaises(SomeError)判断抛异常验证非法输入的报错行为assertAlmostEqual(a, b, placesn)判断近似相等浮点数比较避免精度问题assertIsInstance(obj, Cls)判断类型验证工厂函数的返回类型其中最容易被坑的是assertAlmostEqual。你计算金额、比例之类的浮点结果时直接assertEqual极大概率会因为浮点精度问题失败而assertAlmostEqual才是正确选择。默认比较到小数点后7位也可以通过places参数指定精度。异常断言也值得单独拎出来说。期望某个函数抛异常时有些人喜欢用try...except自己写逻辑其实完全没必要with self.assertRaises(ValueError): parse_int(abc)用assertRaises上下文管理器测试代码简洁清晰而且它能精确捕获指定异常类型子类异常也会匹配非常方便。2.3 setUp与tearDown的执行逻辑环境准备与清理setUp和tearDown是测试固件fixture分别在每个测试方法执行前和执行后运行。很多人误以为它们在类级别执行一次其实不是。import unittest class TestDemo(unittest.TestCase): def setUp(self): print(每个用例前执行) self.data {key: value} def tearDown(self): print(每个用例后执行) # 这里可以清理外部资源 def test_one(self): print(用例一) self.assertEqual(self.data[key], value) def test_two(self): print(用例二) self.assertIn(key, self.data)执行顺序是setUp - test_one - tearDown - setUp - test_two - tearDown。所以setUp里创建的实例属性每个用例拿到的都是新对象不用担心中间状态污染。另外还有类级别的setUpClass和tearDownClass用classmethod装饰整个测试类只执行一次。这块适合做重资源初始化比如数据库测试只建立一次连接。但注意一个坑setUpClass里的初始化操作如果失败整个测试类会直接报错连tearDownClass都不会执行。所以类级别的资源初始化一定要确保稳定可靠。3. 从零搭建一个完整测试项目3.1 待测模块准备拿订单折扣计算练手理论讲再多都不如做个完整项目。我这里准备一个订单折扣计算的模块涉及金额、折扣率、边界条件判断非常适合演示单元测试的常见场景。# order.py from decimal import Decimal, ROUND_HALF_UP def calc_discount(total_amount: float, level: str) - float: 根据用户等级计算折扣后的价格。 :param total_amount: 订单原始金额 :param level: 用户等级可选 normal / vip / svip :return: 折后金额 if total_amount 0: raise ValueError(订单金额必须大于0) if level normal: discount Decimal(1.0) elif level vip: discount Decimal(0.9) elif level svip: discount Decimal(0.8) else: raise ValueError(f未知用户等级: {level}) amount Decimal(str(total_amount)) result amount * discount return float(result.quantize(Decimal(0.01), roundingROUND_HALF_UP))这个函数逻辑简单但隐藏了几个容易出问题的点浮点数转Decimal的精度控制、四舍五入规则、异常分支。这些正是测试要重点覆盖的地方。3.2 编写测试用例覆盖正常和异常分支针对上面的模块我写了下面这套测试# test_order.py import unittest from order import calc_discount class TestCalcDiscount(unittest.TestCase): def test_normal_no_discount(self): 普通用户不打折 self.assertEqual(calc_discount(100, normal), 100.0) def test_vip_ten_percent_off(self): VIP用户打九折 self.assertEqual(calc_discount(100, vip), 90.0) def test_svip_twenty_percent_off(self): SVIP用户打八折 self.assertEqual(calc_discount(100, svip), 80.0) def test_decimal_rounding(self): 金额含小数时四舍五入到分 self.assertEqual(calc_discount(99.99, vip), 89.99) def test_zero_amount_raises(self): 金额为0时报错 with self.assertRaises(ValueError): calc_discount(0, vip) def test_negative_amount_raises(self): 金额为负时报错 with self.assertRaises(ValueError): calc_discount(-10, vip) def test_unknown_level_raises(self): 未知用户等级时报错 with self.assertRaises(ValueError): calc_discount(100, gold) def test_large_order_boundary(self): 大额订单不丢失精度 result calc_discount(9999999.99, svip) self.assertAlmostEqual(result, 7999999.99, places2) if __name__ __main__: unittest.main()这套测试覆盖了正常分支、异常分支、边界条件三大类情况。你可能注意到我特意加了test_decimal_rounding这个用例它验证的是当折扣后出现无限小数时函数能否正确四舍五入到分。这个细节在涉及钱的业务里极其重要差一分钱对账都不平。关于浮点比较再啰嗦两句。如果直接写assertEqual(calc_discount(100, vip), 90.0)大部分时候没问题因为Python内部对90.0这种浮点数能精确表示。但当你遇到0.1 0.2这种经典问题时就必须用assertAlmostEqual了。3.3 测试发现与运行从单个用例到整个项目手动指定测试文件运行很方便但项目规模大了之后你不可能一个文件一个文件地跑。unittest提供了测试发现机制python -m unittest discover -s tests -p test_*.py这条命令会扫描tests目录下所有以test_开头、.py结尾的文件自动收集里面的TestCase并执行。不过要注意目录结构。如果你在项目根目录运行上面的命令那么tests目录下需要有一个__init__.py文件如果Python版本在3.3以上命名空间包可以不需要否则可能出现模块查找不到的问题。这里踩过的坑是明明文件都在但discover就是找不到用例多半是包结构或者当前工作目录的问题。用python -m unittest的好处是它会自动把当前目录加入sys.path这样测试文件里import order就能正常工作。而如果你直接用python tests/test_order.py运行反而可能因为路径问题导致导入失败。3.4 跳过用例与预期失败测试也需要“特判”不是所有用例在所有环境都能跑。比如某些用例依赖外部服务或者某个已知bug还没修完。unittest提供了几个装饰器来处理这些情况import unittest import sys class TestSkipDemo(unittest.TestCase): unittest.skip(还没实现先跳过) def test_not_implemented(self): pass unittest.skipIf(sys.platform.startswith(win), Windows环境跳过) def test_linux_only(self): pass unittest.skipUnless(sys.version_info.major 3, 要求Python3) def test_python3_only(self): pass unittest.expectedFailure def test_known_issue(self): # 已知bug预期测试失败失败不会导致整体报红 self.assertEqual(1 1, 3)expectedFailure这个用得少但很实用。当某个bug暂时没法修复你又不想看到测试列表里一片红的时候标记为预期失败后测试结果会以“expected failure”而不是“FAIL”展示整个测试套件依然是通过的。这样既记录了问题又不影响CI的通过状态。4. 测试替身当被测代码依赖外部资源时4.1 为什么需要mock绕过第三方依赖的干扰写单元测试时最头疼的就是被测函数依赖外部资源比如请求Redis、调用第三方接口、读取配置文件。你不希望单元测试真的去发网络请求也不希望测试结果受外部环境影响。这时候就需要mock。mock的本质是用一个假对象替换真实对象让你能控制依赖的返回值、验证调用参数、模拟异常抛出。unittest自带unittest.mock模块无需额外安装。4.2 unittest.mock快速上手patch与MagicMockmock模块里最常用的就是patch和MagicMock。patch用来临时替换某个位置的属性MagicMock是万能替身它几乎能模拟任何对象的行为。看个简单例子。假设有这样一个函数# payment.py import requests def get_exchange_rate(currency: str) - float: 从第三方接口获取汇率 resp requests.get(fhttps://api.example.com/rate?from{currency}toCNY) data resp.json() return data[rate] def convert_to_cny(amount: float, currency: str) - float: 把外币金额按实时汇率换算成人民币 rate get_exchange_rate(currency) return amount * rate测试convert_to_cny时我们绝不想真的去请求api.example.com。用patch替换掉get_exchange_rate即可# test_payment.py import unittest from unittest.mock import patch from payment import convert_to_cny class TestConvertToCny(unittest.TestCase): patch(payment.get_exchange_rate) def test_convert(self, mock_rate): # 指定汇率接口返回值为7.2 mock_rate.return_value 7.2 result convert_to_cny(100, USD) self.assertEqual(result, 720.0) # 验证接口确实被调用参数正确 mock_rate.assert_called_once_with(USD) if __name__ __main__: unittest.main()patch(payment.get_exchange_rate)这里要注意patch的是这个函数在被测模块里的引用位置而不是它定义的位置。因为payment模块里通过from requests import ...是在模块顶部完成的而在convert_to_cny里调用的是get_exchange_rate这个全局名字所以patchpayment.get_exchange_rate才能生效。这是mock最容易踩的坑搞错位置怎么打patch都不生效。4.3 side_effect的妙用模拟异常与动态返回值return_value只能返回固定值但真实世界往往需要根据输入参数返回不同结果或者直接抛异常。这时候用side_effect。from unittest.mock import patch from payment import convert_to_cny class TestConvertToCny2(unittest.TestCase): patch(payment.get_exchange_rate) def test_convert_with_dynamic_rate(self, mock_rate): # 根据参数返回不同汇率 mock_rate.side_effect lambda currency: 7.0 if currency USD else 6.5 self.assertEqual(convert_to_cny(100, USD), 700.0) self.assertEqual(convert_to_cny(100, EUR), 650.0) patch(payment.get_exchange_rate) def test_convert_with_exception(self, mock_rate): # 模拟接口异常 mock_rate.side_effect ConnectionError(network error) with self.assertRaises(ConnectionError): convert_to_cny(100, USD) if __name__ __main__: unittest.main()side_effect传一个可调用对象时它会接收被测代码的调用参数然后返回对应结果传一个异常对象时调用就会抛出该异常。这两个能力组合起来几乎能模拟任何外部依赖场景。5. 测试覆盖率统计与CI集成5.1 用coverage工具看清测试盲区测试写了不少但质量到底如何光靠“测试都通过了”远远不够你需要知道代码里哪些行被执行过、哪些分支从没走到。coverage这个工具能帮你统计。安装和运行pip install coverage coverage run -m unittest discover -s tests coverage report -m coverage htmlcoverage run会记录测试执行期间所有Python代码的覆盖情况report在终端展示各文件的覆盖率html会生成一个可视化报告在浏览器里打开能看到每个源文件里哪些行被覆盖、哪些行被遗漏。覆盖率要追求100%吗我的建议是核心业务逻辑尽量高至少90%以上但不要为了凑数字去写那些没有意义的测试。比如一个只读配置的函数你为了覆盖率硬写两个用例价值就非常有限。真正应该关注的是高复杂度、高风险的函数特别是那些涉及分支判断、异常处理的逻辑一定要确保每条分支都被走到。5.2 接入CI让每次提交都自动跑测试测试写好了约束别人也约束自己最好的方式就是把它接入CI。最轻量级的做法是GitHub Actions。在项目里放一个workflow文件# .github/workflows/test.yml name: Python Tests on: push: branches: [ main ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - name: Set up Python uses: actions/setup-pythonv4 with: python-version: 3.11 - name: Install dependencies run: | pip install -r requirements.txt pip install coverage - name: Run tests run: | coverage run -m unittest discover -s tests coverage report -m这样每次push或提PRGitHub都会自动跑一遍测试。如果某次提交让测试挂了CI会直接标红代码就合不进去。这种“无聊的检查”在关键时刻能救命——你永远不知道哪次看似无关的改动会破坏一个角落里的逻辑。6. 实战避坑指南高频报错与新手困惑6.1 常见报错与排查思路清单报错信息原因解决方案ImportError: No module named xxx被测模块不在sys.path中在项目根目录运行python -m unittest让当前目录自动加入路径测试发现不了用例文件命名不符合test*.py规则检查文件名是否以test开头检查discover的-s参数指向的目录是否正确AssertionError但代码看起来没问题浮点精度问题改用assertAlmostEqual做浮点比较patch不生效patch的路径写错了或作用域不对确认patch的是“被测模块里的引用位置”而不是“定义位置”TypeError: NoneType object is not callablemock对象没有正确配置return_value或side_effect检查mock对象是否被重复patch或者被MagicMock默认行为干扰6.2 一套我一直在用的测试目录规范项目结构对测试体验影响很大推荐一套经过验证的目录布局project_root/ ├── my_package/ │ ├── __init__.py │ ├── order.py │ └── payment.py ├── tests/ │ ├── __init__.py │ ├── test_order.py │ └── test_payment.py └── requirements.txttests目录放独立的__init__.pyPython 3.3以上非必须但建议保留与my_package平级。运行测试时在project_root下执行python -m unittest discover -s tests这种结构的好处是测试代码和生产代码完全隔离新增模块时对应新增一个test_xxx.py一目了然同时python -m unittest自动把project_root加入sys.pathimport my_package不受影响。6.3 写测试的“度”过度测试比不测试更可怕最后聊一个经验问题。很多团队推测试推得急结果出现一批“为了测试而测试”的用例。比如一个函数内部实现改了下测试也跟着改最后测试完全绑定在了实现细节上。这种测试不仅没价值还增加了维护成本坏味道特别明显。我的原则是测试要面向“行为”而非“实现”。也就是说你测试的是一个函数“输入什么、输出什么、抛什么异常”而不是“内部怎么实现”。只要对外行为不变测试代码就不应该改。如果某次需求变更导致测试大量改动先别急着改测试回头想想是不是测试姿势有问题——多半是把测试写得太细和内部实现耦合太紧了。另外要控制测试的粒度。一个模块的测试不应该去验证另一个模块的行为。如果你发现测试依赖其他模块需要mock掉那就mock。单元测试的边界就是“只测当前单元”跨模块的交互交给集成测试去管别让单元测试背负过多的责任。最后分享一点我的真实感受写了几年测试最明显的体验是测试不是在“花时间”是在“买时间”。你投入的每一份测试代码都会在未来的某次重构、某个需求变更后以“一次通过”的形式回报给你。unittest或许没有pytest那么多花哨的插件但它足够稳定、足够底层当你对Python的测试机制理解变深后再切到任何其他测试框架都会非常轻松。这套从TestCase到Mock再到覆盖率检查的完整链路是我个人认为每个Python开发者都值得掌握的看家本领。