
改完一个功能最怕的不是报错而是不知道自己改坏了什么。尤其是Python这种动态语言没编译期兜底一个类型写错、一个返回值漏处理分分钟线上翻车。我自己这几年维护的项目凡是敢放心重构的靠的全是同一套东西——单元测试而最基本的框架就是Python自带的unittest。有人觉得unittest是老古董语法啰嗦不如pytest写起来爽。但我必须说实话unittest是Python标准库里的官方测试框架零依赖、开箱即用所有Python环境都自带团队协作时不用约定任何安装流程。更重要的是它提供的那套TestCase、TestSuite、TestLoader结构到现在都不过时反而是很多人用pytest用久了反而没搞明白测试背后的组织逻辑。这篇东西我不打算写官方文档式的手册就把我这几年的实战经验、踩过的坑、总结出来的套路全部摊开来讲。无论你是刚入门想给项目加测试还是写了一阵子测试但总觉得哪里别扭都能在这里找到点有用的东西。1. 先搞清楚unittest解决什么问题核心组件与测试生命周期1.1 四个核心组件缺一不可unittest整个框架说穿了就四个核心概念TestCase测试用例、TestSuite测试套件、TestRunner测试运行器、TestLoader测试加载器。很多人刚开始学的时候只盯着TestCase写把另外三个当透明人结果等用例多到一定数量就跑不动也查不好了。我习惯用一个类比来解释这套东西TestCase就像车间里的一个个工位每个工位只干一件事只验证一个行为TestSuite就是把这些工位按工序排成一条流水线TestRunner是启动流水线的按钮负责把人叫过来干活、把结果记录下来TestLoader则是干“盘点原料”的——它自动去目录里找哪些文件是测试文件哪些函数是测试函数把符合条件的全捞出来。核心还是TestCase。你的每一个测试类都继承它每个以test_开头的方法就是一个独立的测试用例。unittest底层用TestResult对象统计每个用例的结果一个用例通过、失败、报错还是跳过都是独立记录的。实测下来这个“一个方法只验证一个行为”的粒度是最舒服的粒度太粗断言一堆出了问题要花大量时间定位是哪个环节断了粒度太细写用例的时间和维护成本又翻倍。一个规范的TestCase长这样import unittest class TestMathFunc(unittest.TestCase): def test_add(self): self.assertEqual(2 3, 5) def test_subtract(self): self.assertEqual(10 - 3, 7)看起来简单吧但这里有几个很容易被忽略的细节第一测试类名建议用Test开头这样unittest的自动发现机制才能认出来第二测试方法名必须用test_开头否则运行器会直接跳过这是新手最常见的“明明写了测试却一个都没跑”的原因第三测试文件命名通常用test_*.py这是给后面的TestLoader做自动发现预留的。1.2 setUp和tearDown测试前置与清理的正确姿势很多教程喜欢上来就讲断言、讲mock但我觉得真正决定一套测试能不能长期维护的其实是用例的前置动作和清理动作也就是setUp和tearDown。这两个方法的执行机制是这样的每运行一个测试方法之前unittest都会先执行setUp测试方法结束后再执行tearDown。也就是说一个Test类里如果有三个测试方法setUp和tearDown就会被各执行三次而不是一次。另一个容易搞混的是setUpClass和tearDownClass这俩是类级别的整个测试类只执行一次适合创建数据库连接、启动服务这类重资源操作。这里最关键的坑就是千万别把只能在初始化时做一次的事情放在setUp里。我见过有人把一段要执行好几秒的业务初始化代码塞进setUp一百个用例就是几百秒最后直接放弃跑测试。判断标准就一条如果每个测试方法都互不影响、需要独立环境用setUp如果所有测试共享同一个成本很高的资源、并且这个资源可以安全复用用setUpClass加classmethod装饰器。我自己写测试的习惯是能用setUp解决的绝不提前优化能清理的无条件放在tearDown。尤其是涉及临时文件、Mock对象、环境变量篡改的测试清理不干净后面的用例就全被污染了。比如改环境变量的测试我会在意setUp里记录旧值在tearDown里恢复旧值防止测试之间互相“串味”。2. 写第一个测试并跑起来断言、用例组织与测试发现2.1 unittest里的断言到底有多全断言是整个测试的灵魂。很多人写测试只知道用assertEqual其实unittest的断言方法比大多数人想象中丰富得多。按用途分大概有这几大类值断言assertEqual、assertNotEqual、assertIs、assertIsNot、assertIsNone、assertIsNotNone布尔断言assertTrue、assertFalse容器断言assertIn、assertNotIn、assertNotIn、assertCountEqual类型断言assertIsInstance、assertNotIsInstance比较断言assertGreater、assertGreaterEqual、assertLess、assertLessEqual异常断言assertRaises、assertRaisesRegex正则断言assertRegex、assertNotRegex、assertRegexpMatches很多问题其实都是断言用错了导致的。比如判断一个列表里是否存在某个元素如果用assertTrue(x in my_list)当断言失败时输出的只是False你根本不知道列表里实际有什么但如果用assertIn(x, my_list)失败信息会同时打印出x的值和完整的my_list排查效率天差地别。这个细节在调试复杂业务逻辑时能省下大量时间。浮点数比较也是重灾区。直接assertEqual(0.1 0.2, 0.3)铁定失败因为二进制浮点数有精度问题。正确的做法是用assertAlmostEqual(first, second, places7)它会比较两个数四舍五入到指定小数位后是否相等。我再补一个实际经验如果比较的是金额、汇率这类业务数据建议用places2而不要默认的7位涉及到科学计算再根据有效数字精度自行调整。异常测试也很有讲究。有一种常见错法是先调用函数再用assertTrue去判断某个标志位看起来能测但异常根本没触发测试形同虚设。正确姿势是这样的import unittest def divide(a, b): if b 0: raise ValueError(除数不能为0) return a / b class TestDivide(unittest.TestCase): def test_divide_by_zero(self): with self.assertRaises(ValueError) as ctx: divide(10, 0) self.assertEqual(str(ctx.exception), 除数不能为0)使用assertRaises上下文管理器还能捕获异常对象再对异常内容做进一步断言。这个写法在测自定义异常时尤其好用能确认的不仅仅是“抛没抛”还有“抛的信息对不对”。2.2 组织用例从单个py文件到几十个测试模块项目刚起步时一个test_all.py文件里堆几十个测试类还能忍但代码量一上来这种“大锅炖”模式就会变成灾难文件打开要卡半天跑单个用例要等全量加载同事改代码都不知道该往哪个文件里加测试。我的组织习惯是这样的基本没踩过坑project/ ├── my_app/ │ ├── __init__.py │ ├── calculator.py │ └── utils.py └── tests/ ├── __init__.py ├── test_calculator.py └── test_utils.py测试目录必须加__init__.py原因后面说这是很多人跑不起来discover的头号根因。然后测试文件与被测模块保持“一文件对一文件”的关系calculator.py对应test_calculator.pyutils.py对应test_utils.py这样可以快速定位任何一个业务文件对应的测试在哪。等用例量上了几百个再用TestSuite手动聚合会显得很有必要。比如某次发版只需要跑“支付相关”的用例我不想全量回归就单独拼一个套件出来import unittest from tests.test_payment import TestPayment, TestRefund def payment_suite(): suite unittest.TestSuite() suite.addTests([ TestPayment(test_charge), TestPayment(test_charge_timeout), TestRefund(test_refund_partial), ]) return suite if __name__ __main__: runner unittest.TextTestRunner(verbosity2) runner.run(payment_suite())这里给每个测试类传的字符串参数就是具体的测试方法名。用TestSuite的好处是执行顺序可控、范围可控。虽然日常跑全量测试时更多还是依赖TestLoader自动发现但版本迭代到某个阶段只跑受影响模块的用例是真能节约不少时间的。2.3 用命令行和代码两种方式启动测试这是新手最容易懵的地方测试写好了但不知道用什么命令跑。其实unittest启动测试的方式就两大类一类是命令行一类是写进代码里。命令行方式最常用的是# 运行单个测试模块 python -m unittest tests.test_calculator # 运行单个测试类 python -m unittest tests.test_calculator.TestCalculator # 运行单个测试方法 python -m unittest tests.test_calculator.TestCalculator.test_add # 自动发现并运行所有符合规则的测试 python -m unittest discover -s tests -p test_*.py -v这里有个你必须知道的细节-m unittest后面接的是“可导入的模块路径”不是文件路径所以用的是点号tests.test_calculator而不是斜杠。这是Python导入机制决定的框架会把点号路径解析成模块并导入再从中检索测试类与方法。代码方式则是在每个测试文件的末尾加上if __name__ __main__: unittest.main()然后直接用python tests/test_calculator.py就能跑。它和命令行方式在原理上完全一样只是unittest.main()内部会替你调用TestLoader。verbosity参数也在这里起作用设成2时会打印出每个用例的名称和结果失败了还会输出详细的traceback我一般调试时都开这个级别。discover自动发现是用例多起来后必须要掌握的。它有几个隐藏规则默认只搜索当前目录下所有test*.py文件加了-s tests之后如果tests目录下没有__init__.py有可能出现找不到模块或导入失败的情况-p参数是文件通配符默认是test*.py按自己命名习惯调整就行。实测下来我建议统一让discover -s tests -p test_*.py三件套固定下来团队所有人用同一套命令不折腾。3. 真实项目里最常用的进阶技巧mock、参数化与跳过3.1 mock把外部依赖“换掉”再测单元测试的核心约束是“只测当前单元不测外部环境”。但实际业务里一个函数动不动就发HTTP请求、查数据库、调第三方SDK要是每次都真连真实服务测试就变成集成测试了不稳定、跑得慢、还可能踩到接口限流。这个问题的标准解法就是unittest.mock。mock的本质就是“替身演员”在测试期间把某个对象的属性或方法临时替换成一个可编程的假对象测试结束后再恢复原样。它的核心是MagicMock类你几乎可以给任何方法设置返回值、设置副作用、断言调用次数而且它能动态接受任意调用不会报错。最常见的用法是patch装饰器或上下文管理器from unittest.mock import patch import unittest from my_app.payment import charge class TestPayment(unittest.TestCase): patch(my_app.payment.requests.post) def test_charge_success(self, mock_post): mock_post.return_value.status_code 200 mock_post.return_value.json.return_value {trade_no: 20240001} result charge(amount100) self.assertEqual(result[trade_no], 20240001) mock_post.assert_called_once()这里最关键的坑是patch的路径必须是“被测代码里用到这个对象的位置”而不是“这个对象定义的位置”。什么意思呢如果payment.py里写的是import requests然后调用requests.post那patch要写my_app.payment.requests.post如果写的是from requests import post然后直接post(...)那就要patch(my_app.payment.post)。这个点我第一次用的时候踩了整个下午后来总结成一句话被替换的是“调用它的那个模块里的名字”不是“它真正来自哪里”。mock的另一半能力是验证交互。看上面的mock_post.assert_called_once()它可以确认被测函数确实是调用了一次请求。类似的还有assert_called_with、assert_called_once_with这类断言能防止“代码没报错但根本没走预期分支”的假通过。硬要说的话mock是单元的测试的“能量饮料”但也要适可而止mock得太多测试就变成在验证mock自身没有实际意义后面我会专门讲这个。3.2 参数化与subTest减少重复样本测试一个函数通常不会只测一组数据。比如写一个判断闰年的函数你可能要测“能被4整除但不能被100整除”“能被400整除”“不能被4整除”等多组样例。最早的笨办法是每个样例写一个测试方法复制粘贴一大片后来有人想到用循环包在一个测试方法里但循环里一旦某组数据断言失败后面的数据就直接不测了报错信息也不直观。unittest自带的解决方案是subTest。它允许你在同一个测试方法里定义多个子测试每个子测试独立记录成败一个失败了不影响其他子测试继续运行。import unittest class TestLeapYear(unittest.TestCase): def test_leap_year_cases(self): cases [ (2000, True), # 能被400整除 (2024, True), # 能被4整除但不能被100整除 (1900, False), # 能被100整除但不能被400整除 (2023, False), # 普通年份 ] for year, expected in cases: with self.subTest(yearyear): self.assertEqual(is_leap_year(year), expected)用了subTest后哪组数据失败输出里就会明确显示year1900这类上下文信息定位非常精准。另外一个避免复制粘贴的小技巧是可以用类属性或传参的方式把测试数据抽出来统一管理数据量更大时再考虑外置的YAML或JSON文件。不过我也要提个醒subTest不等于真正的参数化测试。如果你希望“每个参数组合都生成一个独立的测试用例并且能单独选中运行”那unittest原生并不支持得靠subTest模拟或者升级到pytest的pytest.mark.parametrize。团队规范强的话我更倾向于用subTest毕竟不引入额外依赖。3.3 跳过用例、预期失败与异常测试业务迭代中经常遇到这种情况某个功能依赖的第三方接口还没开发好但这个用例是完整的想跑但一跑就失败或者某个旧版本已经暴露了一个已知缺陷但短期内不打算修复测试写出来就知道会红。这两类场景unittest都给了官方支持。跳过用例用unittest.skip、unittest.skipIf或unittest.skipUnless。skip是无条件跳过skipIf是条件成立时跳过skipUnless是条件不成立时跳过后面两个装饰器都要求传一个布尔表达式。常见的现实用法是“当环境变量不是CI时跳过”或者“当数据库未启动时跳过”接一个理由字符串比如import os import unittest class TestExternalAPI(unittest.TestCase): unittest.skipUnless(os.getenv(RUN_EXTERNAL_TESTS) 1, 外部接口测试默认不跑) def test_call_external_api(self): ...预期失败用unittest.expectedFailure表示这个用例失败是“意料之中”的跑出来之后测试报告里会标注为expected failure而不会当作真正的失败。我在实际项目里用它的场景是知道存在一个回归bug但因为要等上游修复所以先把这个用例标记为预期失败。等bug修好了再把装饰器摘掉用例就恢复成正常校验。这比把用例注释掉干净得多至少能保持测试文件一直在跑随时发现bug是否已经消失。顺带说一句异常测试除了前面提到的assertRaises还有一种场景是测“不应该抛异常”比如某个代码块的输入边界值都不能炸。最简洁的写法就是直接调用如果抛出了异常测试自然失败class TestEdgeCases(unittest.TestCase): def test_should_not_raise(self): for value in [0, 1, -1, 100]: convert(value) # 抛异常就算测试失败4. 我踩过的坑常见问题与排查技巧实录4.1 断言选择不对导致“误报”我见过最坑的一次排查经历是一个测试明明断言了“返回列表里包含期望元素”结果生产环境还是出了数据缺失的bug。后来一查才发现测试里写的是assertTrue(expect in result)表面上在判断“元素在列表里”但因为列表里的元素是复杂对象而对象没有实现__eq__in运算符退化成对象身份比较永远返回False。更麻烦的是这段测试从一开始就通不过但团队里有人看到红后直接加了个assertTrue(True)硬让测试过等于把测试废了。这类问题最好的解法就是上面说的用合适的断言方法并且别容忍“硬过”。我自己写测试前会先想清楚一个问题这次断言到底要验证“相等”“包含”“类型”还是“异常”对应去找专门的断言方法。对于复杂对象优先给被测类补上__eq__或__repr__方法这样断言失败时输出信息才有意义否则测试永远在黑盒里排障全靠猜。4.2 setUp/tearDown放错位置导致“脏数据”有一次我在测试支付模块时新增了一个实现类结果把同一份测试代码复制过去改了几下就提交了测试全部通过。后来接口限流、数据库状态被改得一塌糊涂。原因出在tearDown里没清理状态导致前一个用例写入的脏数据残留到下一个用例里。规矩很简单所有测试之间必须完全隔离。只要一条用例会修改全局状态、环境变量、数据库记录、文件内容就必须在tearDown里恢复原状。隔离的方式优先级从高到低是优先使用局部变量让Python的垃圾回收自己清理其次用addCleanup注册清理函数它比tearDown更上瘾因为即使测试中途断言失败addCleanup注册的函数也依然会执行而tearDown则不一定。后面这句话很重要tearDown只有在setUp成功后才保证调用而addCleanup只要能注册成功基本铁定执行。所以凡是“必须清理”的资源我都优先用addCleanup。4.3 mock没有打对地方导致“无效测试”“mock打错地方”的直接后果是测试跑得好好的但根本没mock到真正的调用点所有断言其实都在验证一个mock自身的行为业务代码压根没被覆盖到。更阴险的是这种问题不报错测试一片绿看起来一切正常上线后该漏的漏该挂的挂。排查方法很简单在测试里给mock设置一个side_effect抛异常如果测试居然还通过说明mock没生效被测函数根本没走到这个调用。另外可以配合coverage工具看被测代码的行覆盖率如果某段业务逻辑的覆盖率明显偏低十有八九mock路径打错了。我还见过一种情况patch装饰器的顺序和mock参数的顺序是反的。记住一个口诀装饰器离函数越近对应的mock参数位置越靠前。patch(module_a.func) patch(module_b.func) def test_something(self, mock_b, mock_a): ...这个顺序坑了我不止一次现在写patch多的测试我都会先停一下数清楚参数位置。4.4 discover不加载用例测试为零命令行里跑python -m unittest discover -s tests -v结果输出Ran 0 tests这个问题我见过无数次新手遇到基本都是一脸懵。原因最常见的是这几种tests目录缺少__init__.py导致该目录不是合法的Python包导入链直接断掉测试文件的命名不符合test*.py规则默认情况下discover只匹配这个模式测试类没有继承unittest.TestCase比如继承的是一个普通类或者压根没继承那是不会被发现的测试方法没以test_开头被静默忽略。排查可以用一条命令来定位python -m unittest discover -s tests -p test_*.py -v加上-v之后每个通过、跳过、失败、发现的用例都会打印出来。如果还是没有用例检查前面四类原因挨个排除。还有一个建议不要在__init__.py里写任何逻辑空文件就行里面一旦导入出错所有测试模块都会跟着导入失败。4.5 测试顺序与数据污染很多人默认unittest的用例执行顺序和文件里定义的顺序一致这个想法其实不完全对。unittest按字符串排序来执行测试方法也就是test_a、test_b这种顺序跟书写顺序没关系。类之间的顺序、模块之间的顺序也都是字母序。如果你在测试里隐隐约约依赖了“先跑这个用例再跑那个用例”某天就会因为新加了一个文件名排在前面导致顺序乱了测试又红了。应对方案很简单每个测试必须独立可运行不依赖顺序、不依赖前一个用例留下的状态。如果确实有一组用例必须按特定顺序执行最稳妥的方式是手动用TestSuite来拼接而不是赌自动发现的排序。我自己的态度是“顺序依赖就是脏设计的信号”能拆就拆拆不了再上套件。5. unittest和pytest怎么选我的实际体会5.1 为什么我仍在用unittest现在很多人一上来就选pytest搞得好像unittest是上个世纪的遗产。说实话pytest的插件生态、fixture机制、断言内省确实更舒服。但unittest有一个无可替代的优势它是标准库不引入任何第三方依赖。只要环境里有Python就能跑unittest。这对很多有严格依赖管控的公司项目来说太关键了引入pytest意味着要过一遍依赖评审而unittest则永远不会有这个问题。另外unittest的TestCase本质上是一个以组合方式组织测试的容器它自带的断言库、mock、临时目录工具、skip机制面对90%的业务测试需求都够用。如果你把所有项目都统一到unittest团队学习的成本也很低文档遍地都是新同事上手快。它确实没那么花哨但胜在稳定、靠谱、无人不知。5.2 pytest的生态优势我不替unittest辩护到盲目的程度。pytest在下面这些场景确实是更好的选择需要大量参数化测试pytest.mark.parametrize的表达比subTest简洁得多需要复杂fixture管理fixture的作用域和依赖注入比setUp加tearDown灵活得多需要第三方插件比如出HTML报告、测接口、测异步代码pytest的插件生态根本不是unittest能比的。还有一点pytest的断言失败信息比unittest好看太多它对assert语句做了运行时重写能直接告诉你左右两侧的值而unittest的assertEqual输了也会打印但很多复杂结构打印出来可读性一般。我现实里的策略是核心底层库、标准工具优先unittest因为依赖少业务测试、团队新项目如果条件允许可以切到pytest。但无论用哪个框架测试思维完全通用不用觉得学了这套会白费。该懂的TestCase组织、mock思路、skip策略到pytest里也一样用。5.3 兼容迁移路径如果你的老项目想从unittest平滑迁移到pytest好消息是不用重写。pytest原生支持运行unittest风格的测试类只要装了pytest直接在项目根目录跑pytest它能自动识别unittest.TestCase的用例并执行。迁移初期可以两个框架并行等pytest侧验证稳定后再按模块逐步改造成fixture风格。真正重写的部分通常是setUp和tearDown改造成fixturemock使用方式也略有变化pytest官方推荐用monkeypatch或pytest-mock插件替代unittest.mock.patch但MagicMock本身还是可以继续用。整体来说这种迁移是渐进的不要求“一天切完”。我个人建议如果团队已经用了pytest就不要在同一文件里混用两种风格会把后来人逼疯。最后再分享一个我自己的实际习惯不管项目最终选什么框架我在写每个测试用例之前都会问自己一个问题——“如果这个测试挂了我能不能在5分钟内定位到是哪个单元出了问题”如果答案是否定的说明这个测试设计得还不够好需要拆小、换断言或者调整mock范围。保持这个习惯之后我的测试越来越像一整套故障预警系统而不是那种“顺手写的、绿着就不管”的摆设。unittest能做的不止是给代码兜底它也是在帮你把项目拆成一个个可验证的小模块而且这个过程本身就是提升代码质量最实在的路径。