ARTICLE DETAIL

资讯详情

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

Python unittest实战:从线上事故到全面测试覆盖

Python unittest实战:从线上事故到全面测试覆盖 去年有次线上事故起因是一行改动。项目里有个format_price函数负责格式化订单金额全站的订单列表、购物车、结算单都在调它。当时为了配合新的优惠规则改了一下舍入逻辑自测时觉得输出差别不大代码评审也过了。结果第二天运营反馈一批订单金额显示成了9.99999排查了大半天才定位到这一行。这个函数没有测试覆盖改动的影响面全靠肉眼判断最后变成线上故障加全量数据修复的惨案。从那以后我把 Python 内置的unittest用成了项目的标配。这篇文章就把我实际项目里的测试写法、执行机制、mock 手法、踩坑记录一次理清楚希望能帮准备认真对待测试的人少走弯路。1. 为什么我坚持在项目里引入 unittest一次线上回归事故的复盘1.1 改掉一行逻辑线上出了三天故障那次format_price的修复过程很典型因为缺乏测试没人能回答这个函数到底被哪些地方调用过、调用方对输出格式有什么隐含依赖。我们只能靠全文搜索找出十几个调用点再逐个手工验证。更麻烦的是即使当时把主路径修好了后续还会有人在其他地方改出类似问题。这类问题就是典型的回归故障——功能没变只是某次改动把老行为破坏了。单元测试的第一价值就是回归保护先把函数当前的行为固化成一堆断言之后每次改代码跑一遍测试就能立刻知道哪些行为被影响了。这种保护不是靠代码审查能替代的审查靠人眼对比新旧差异而测试靠机器验证输出是否匹配预期两者根本不是一个数量级。1.2 单元测试到底在保护什么保护回归只是最表层的作用。写测试的过程中我感受最深的是它对代码设计的反向约束。一个函数如果很难写测试通常意味着它做了太多事内部直接连数据库、依赖全局变量、把网络请求和业务逻辑焊死在同一个方法里。为了让它能被测试你就不得不把逻辑拆开、把依赖参数化、把副作用隔离。换句话说单元测试在你动手重构之前先逼着代码变得更清晰。另外测试也是一份可执行的文档。我接手别人项目时第一件事不是看 README而是翻tests/目录。一组好的测试用例能把函数的正常输入、边界条件和异常行为都展示得清清楚楚比任何注释都准确。注释会过期文档会漏写但测试如果过期运行结果会直接告诉你。1.3 什么代码值得写测试什么代码不必强求不是所有代码都需要单元测试。我的判断标准很简单会被多次修改、被多个地方调用、或出错代价高的代码必须有测试。工具函数、核心业务逻辑、数据解析代码、和外部系统交互的封装层都是优先覆盖的对象。反过来一次性数据分析脚本、临时为某个问题写的探索代码、纯手工操作步骤硬套测试反而是浪费这类代码更值得花时间在小范围验证上。还有一个容易被忽略的场景测试你自己写的库函数。项目里抽出来的工具模块往往是最多人用、也最容易埋雷的部分。拿unittest把这类公共函数全部覆盖住性价比极高。2. 先从一条命令跑通第一个测试unittest 的执行机制与组件拆解2.1 一个最小可运行的测试长什么样假设我有一个calc.py里面放一个加法函数# calc.py def add(a, b): return a b对应的测试文件test_calc.py# test_calc.py import unittest from calc import add class TestAdd(unittest.TestCase): def test_add_positive(self): result add(1, 2) self.assertEqual(result, 3) def test_add_with_zero(self): self.assertEqual(add(0, 0), 0)在终端里跑python -m unittest test_calc -v-v是 verbose 模式会逐个打印执行过的测试方法名。输出末尾出现的OK表示全部通过如果哪个测试挂了会明确告诉你哪个方法、哪一行断言失败、期望值和实际值是什么。这个信息密度比直接python test_calc.py高得多。2.2 框架里四个核心角色TestCase、TestSuite、TestLoader、TestRunner很多人只知道往unittest.TestCase里写test_开头的方法对框架内部怎么把这些方法找出来、按什么顺序执行其实不太清楚。我最初也是把它当黑盒用直到有一次排查为什么我明明写了 10 个测试实际只跑了 6 个时才去翻了源码。unittest的运作可以拆成四个角色TestCase测试用例的基类。你写的每个test_xxx方法最终都会被包装成一个独立的测试项。TestSuite测试套件用来把多个测试用例组织成一个可执行的集合支持嵌套组合。TestLoader负责从模块或目录中发现并加载测试。discover()方法按文件名模式和目录递归查找返回一个TestSuite。TestRunner负责真正执行测试并汇总结果。默认的TextTestRunner把结果输出到终端。当你执行python -m unittest discover框架默认会在当前目录递归查找test*.py文件用TestLoader加载成套件再交给TestRunner执行。你也可以手动组装套件虽然日常用得少但理解它能帮你应付一些特殊场景比如把不同目录的测试组合起来执行import unittest loader unittest.TestLoader() suite unittest.TestSuite() suite.addTests(loader.discover(tests/unit, patterntest_*.py)) suite.addTests(loader.discover(tests/integration, patterntest_*.py)) runner unittest.TextTestRunner(verbosity2) runner.run(suite)2.3 常用运行参数与发现规则我实际项目里最常用的三条命令命令用途python -m unittest -v跑当前目录下所有默认发现的测试python -m unittest test_calc.TestAdd.test_add_zero -v只跑某一个测试方法python -m unittest discover -s tests -p test_*.py -v从tests目录开始按文件名模式发现测试有几个坑要注意测试文件名必须以test开头默认模式是test*.py类名建议以Test开头方法名必须以test_开头。命名不符合规则框架会静默跳过而且不会给你任何提示。如果你手动执行了unittest.main()会发现它和python -m unittest的发现目录基准不一样。推荐始终用python -m unittest这种方式来跑行为更可控。-k参数可以用来按名称过滤测试比如只想跑和字符串处理相关的用例python -m unittest -k str。我在改某个函数时会配合-k快速圈出相关测试省去全量跑的时间。3. 断言方法整理与测试用例设计一个除法函数的完整测试3.1 unittest 最常用的断言方法清单断言是测试的灵魂它决定了一个测试到底在验证什么。unittest里断言方法不少但日常常用的就那么十来个我整理了一个对照表断言方法验证内容assertEqual(a, b)a bassertNotEqual(a, b)a ! bassertTrue(x)/assertFalse(x)x为真 / 为假assertIsNone(x)x is NoneassertIn(item, container)item在容器内assertNotIn(item, container)item不在容器内assertRaises(Exc, func, *args)调用func时抛出指定异常assertRaisesRegex(Exc, regex, func, ...)抛出的异常消息匹配正则assertAlmostEqual(a, b)浮点数近似相等assertIsInstance(obj, cls)类型判断assertDictEqual(a, b)/assertListEqual(a, b)字典 / 列表逐项比较看到assertEqual别以为它只适合数字和字符串。它是类型敏感的比较器比较字典和列表时会递归逐项检查失败时输出的差异信息很详细比手动写assert result expected好用得多。3.2 assertRaises 的两种写法和适用场景测试异常分支是单元测试的重头戏assertRaises有两种写法。第一种是函数式self.assertRaises(ValueError, divide, 1, 0)第二种是我更推荐的上下文管理器写法with self.assertRaises(ValueError): divide(1, 0)上下文管理器的好处是作用域清晰如果divide(1, 0)没有抛异常测试会失败如果你还想验证异常消息可以用assertRaisesRegexwith self.assertRaisesRegex(ValueError, cannot be zero): divide(1, 0)这一步在排查问题时非常省力因为你不仅能确认异常类型还能确认是哪个分支抛出来的。3.3 从正常路径、边界条件、异常分支设计测试写测试不是简单地把函数跑一遍就完事核心是覆盖三类场景正常路径、边界条件、异常分支。以一个除法函数为例# calc.py def divide(a, b): if b 0: raise ValueError(divisor cannot be zero) return a / b完整的测试应该这样写# test_calc.py import unittest from calc import divide class TestDivide(unittest.TestCase): def test_divide_normal(self): self.assertEqual(divide(10, 2), 5) def test_divide_float_result(self): self.assertAlmostEqual(divide(10, 3), 3.3333, places4) def test_divide_negative(self): self.assertEqual(divide(-8, 2), -4) def test_divide_zero(self): with self.assertRaisesRegex(ValueError, cannot be zero): divide(10, 0) def test_divide_zero_both(self): with self.assertRaisesRegex(ValueError, cannot be zero): divide(0, 0)注意这里我没有写test_divide_normal里的两个数字太多样化——一个测试方法最好只验证一个行为。比如正常除法一个方法、浮点结果一个方法、负数一个方法、除数为零一个方法每个方法对应一个明确的场景。这样测试失败时看到方法名就能定位到问题是哪一类不用翻代码猜。3.4 让失败信息一目了然的小习惯assertEqual失败时默认输出期望值和实际值但有时候 context 不明你会对着输出愣半天。我习惯在关键断言后面加第三个参数自定义失败消息self.assertEqual(divide(6, 2), 3, 6 divide by 2 should be 3)别小看这个举动。在团队协作时别人看到一条清晰的失败消息能立刻判断是测试写错了还是代码行为变了。省下来的沟通成本相当可观。4. fixture 的生命周期管理setUp/tearDown 的正确打开方式4.1 fixture 的四个层级和执行顺序测试中经常需要准备数据、创建临时文件、建立连接这些环境准备和清理工作就是 fixture。unittest提供了四个层级的 fixture很多人只知道setUp但对执行顺序和次数往往有误解。fixture 层级写法执行时机模块级setUpModule()/tearDownModule()整个模块的所有测试执行前/后各一次类级setUpClass()/tearDownClass()类里所有测试执行前/后各一次必须用classmethod方法级setUp()/tearDown()每个测试方法执行前/后各一次方法级跳过skipTest()条件不满足时跳过用例顺序是setUpModule→setUpClass→ 对每个测试方法执行「setUp→ 测试方法 →tearDown」→tearDownClass→tearDownModule。4.2 方法级与类级 fixture 的选型权衡方法级setUp每个测试都会跑一遍适合准备轻量、互不干扰的数据。比如每个测试需要一个新的字典、一个新的列表直接在setUp里创建最安全。但如果准备的数据很重比如要初始化一个大对象、链接一次测试数据库每个测试都做一遍就会让整个测试套件慢得吓人。这种场景应该放类级import unittest class TestDatabase(unittest.TestCase): classmethod def setUpClass(cls): cls.conn create_connection(test.db) classmethod def tearDownClass(cls): cls.conn.close() def test_query(self): result self.conn.query(SELECT 1) self.assertEqual(result, 1)但类级 fixture 有一个潜在问题类属性在多个测试方法间是共享的。如果某个测试方法修改了self.conn的内部状态会影响后面的测试。所以共享资源要选那些只读或状态独立的对象或者确认每次操作后都能恢复原状。4.3 常见的 fixture 滥用和状态污染问题我见过不少新手把网络请求写在setUp里理由是每个测试都需要数据。结果测试一跑就几十秒网络稍微一慢整个套件就红。fixture 的正确用法是能把重量级准备降级就降级尽量用假数据代替真实依赖。如果必须连数据库至少用内存数据库或一次性临时数据库。还有一类很隐蔽的状态污染多个测试通过类属性共享同一个可变对象。比如一个测试往共享列表里加数据另一个测试恰好依赖列表为空测试通过与否就取决于执行顺序。unittest默认按方法名的字母序执行和书写顺序完全不同这种隐式依赖会让排查变得非常痛苦。我的原则是每个测试方法都要能从零开始独立运行不依赖其他方法留下的任何状态。4.4 临时目录与资源清理的实践涉及文件读写的测试我最爱用的是tempfile.TemporaryDirectory它的最大优势是不用自己清理import tempfile import unittest class TestFileProcessor(unittest.TestCase): def test_write_file(self): with tempfile.TemporaryDirectory() as tmp_dir: file_path f{tmp_dir}/test.txt write_file(file_path, hello) content read_file(file_path) self.assertEqual(content, hello)with块结束后目录自动清理即使测试失败上下文管理器也能保证临时目录被移除。这不仅避免了残留文件污染后续测试也防止 CI 环境中磁盘堆积临时文件。如果必须在模块级别准备一个共享的大文件也记得在tearDownModule里显式清理别指望系统自动帮你收拾。5. 用 mock 隔离外部依赖让测试不碰网络、不碰文件5.1 mock 解决的问题与核心 API单元测试有个基本要求快速、稳定、可重复。但真实代码经常要访问外部系统——发请求、读写数据库、获取当前时间。这些外部依赖有三大问题慢、不稳定、难以构造异常场景。比如你没法轻松让真实接口返回超时也没法让随机数测试变得可重复。unittest.mock就是用来解决这个问题的。核心有三个东西Mock/MagicMock可以生成任意属性和方法的假对象。MagicMock额外支持魔法方法比如__len__、__iter__。patch在测试期间临时替换某个名字的引用测试结束后自动恢复原样。return_value和side_effect控制 mock 对象被调用时的返回值和副作用。5.2 patch 的正确使用姿势路径、作用域与还原patch最大的坑是路径写错。记住一个原则你要 patch 的是被测模块里实际引用名字的位置而不是引用对象原本所在的库。看个例子# user_service.py import requests def fetch_user_data(user_id): response requests.get(fhttps://api.example.com/users/{user_id}) response.raise_for_status() return response.json()如果这么写测试patch 是不生效的from unittest.mock import patch from user_service import fetch_user_data patch(requests.get) # 错误patch 的不是被测模块里的引用 def test_fetch(self, mock_get): ...正确写法是 patch 被测模块的引用路径patch(user_service.requests.get) def test_fetch(self, mock_get): ...这是因为user_service模块里写的是import requests它访问的requests是模块内全局名字patch 这个全局名字才能真正替换掉requests.get。如果模块里写的是from requests import get那你就要 patchuser_service.get。5.3 用 side_effect 模拟异常和序列返回值只设置return_value往往不够真实世界里一个依赖可能要被调用多次而且每次返回不同结果。side_effect可以非常灵活地处理这个需求。模拟超时异常from unittest.mock import patch, MagicMock from user_service import fetch_user_data patch(user_service.requests.get) def test_fetch_network_timeout(self, mock_get): mock_get.side_effect TimeoutError(network timeout) with self.assertRaises(TimeoutError): fetch_user_data(1)模拟连续调用返回不同值patch(user_service.requests.get) def test_fetch_multiple(self, mock_get): mock_get.return_value.json.side_effect [ {id: 1, name: Alice}, {id: 2, name: Bob}, ] first fetch_user_data(1) second fetch_user_data(2) self.assertEqual(first[name], Alice) self.assertEqual(second[name], Bob)注意这里有个细节mock_get.return_value本身也是个MagicMock所以mock_get.return_value.json.side_effect能直接赋序列值。先把整个调用链理顺requests.get(...)返回 responseresponse.json() 返回字典所以要设置的是mock_get.return_value.json.return_value或side_effect。5.4 mock 使用中容易踩的坑第一个坑是忘了还原。用with patch(...)或装饰器都能自动还原但如果用patch.start()手动开启必须手动stop()否则 mock 会泄漏到其他测试里。我的建议是尽量用with或装饰器别用手动 start/stop。第二个坑是 patch 得太宽。我见过有人为了省事直接patch(requests.get)结果所有测试都共享同一个替换互相干扰。更可怕的是如果测试里某个分支没构造好 mock 的返回结构真实代码会开始在 mock 对象上找各种属性MagicMock 会神奇地返回一大堆看似正常的假值让你误以为逻辑没问题最后在线上翻车。所以 mock 的返回值一定要按真实结构逐层设置清楚。第三个坑是断言不完整。mock 之后不仅要断言函数返回结果正确还要确认 mock 确实被按预期方式调用过mock_get.assert_called_once_with(https://api.example.com/users/1)assert_called_once_with能验证参数是否正确防止代码里 URL 拼接悄悄写错。6. 跑不快、跑乱、跑挂我在 unittest 实战中遇到的坑与排查链路6.1 测试执行顺序的默认规则unittest默认按测试方法名字的字母序执行不是按书写顺序。新手常犯的错误是在test_a里创建某个状态指望test_b用到它。由于字母序不确定这本质上是让一个测试依赖另一个测试的执行副作用一旦重命名方法或改变字母序测试就挂了。处理办法很简单每个测试方法完全独立。需要相同数据就在setUp里创建或用工厂函数生成。如果遇到必须在测试之间共享的昂贵资源用setUpClass且确保只读。6.2 浮点数比较失败assertEqual 的盲区开源社区最著名的失败示例就是0.1 0.2 0.3。浮点数在计算机里是二进制表示的很多十进制小数根本无法精确表示直接assertEqual结果必然是 fail。解决方案是assertAlmostEqualself.assertAlmostEqual(divide(10, 3), 3.3333, places4)places指定比较到小数点后几位delta指定最大误差。处理金额、比例等数值计算时我通常综合使用round()和assertAlmostEqual避免被二进制误差误伤。6.3 失败信息定位-v、--locals 与自定义消息测试失败时最痛苦的是信息太少。我有一次跑 200 多个用例某个方法挂了输出只告诉我期望 3实际 4完全不知道当时上下文是什么数据。后来发现--locals参数能救急python -m unittest test_calc.TestDivide.test_divide_zero --locals -v--locals会在失败时把测试方法内的局部变量全部打印出来能直接看到传入的参数和中间计算结果。再配合-k先筛出相关用例缩小范围定位效率能提升好几倍。如果连--locals都觉得不够就在测试方法里临时加打印。不过要记得调试完删掉别让调试输出留在最终提交的代码里。6.4 CI 环境下缓存与路径问题的排查思路CI 上跑测试最容易出现的一类问题本地能过CI 上红。最常见的根因是路径依赖。很多代码里写着相对路径data/file.csv这种写法严重依赖当前工作目录恰好是项目根目录。本地终端里你恰好从根目录启动没问题CI 从任意目录启动构建流程路径就找不到了。正确的做法是使用Path(__file__)定位测试文件所在的绝对路径再往上组装资源路径from pathlib import Path TEST_DIR Path(__file__).parent DATA_FILE TEST_DIR.parent / data / file.csv另外 CI 上的缓存也会引入不确定性。比如测试往用户主目录写配置、往/tmp写同名文件循环使用时会被上次残留数据影响。遇到这类问题排查链路是先在 CI 里加一步列出工作目录与临时目录内容的调试输出确认测试运行时所在的目录结构然后在本地用同样的工作目录跑一次对比现象最后检查测试代码里所有相对路径和全局临时文件引用。通常走到第三步就能定位到根因。6.5 顺带一提前端 vue 项目里的单元测试报错思路是相通的热搜词里有个vue单元测试报错我虽然主业写 Python但也在团队里见过不少前端测试失败的情况。本质上所有单元测试框架的排查思路都是相通的先看错误堆栈停留在哪一层是断言失败还是运行环境异常然后把用例缩到最小单独跑一条最后检查测试执行环境构建工具、浏览器环境、mock 是否生效和测试代码本身。比如 vue 组件测试常见的报错往往不是断言逻辑错了而是组件的依赖没有 mock、jsdom 环境缺失某个 API、或者异步更新没等渲染完成就断言了。这个和 Python 里 mock 路径不对、临时目录不存在、异步回调没等完后断言是同一个模式的坑。遇到报错按隔离最小用例、检查依赖替换、确认异步时序这三步走大多数问题都能解决。7. 让 unittest 真正进入团队工作流覆盖率、文件命名与持续集成7.1 用 coverage.py 看清测试覆盖了多少代码没有度量的测试等于没底。我习惯用coverage.py来统计覆盖率pip install coverage coverage run -m unittest discover coverage report -mcoverage report -m会列出每个文件的覆盖率百分比还会把没有执行到的行号直接标出来。要看更直观的版本可以生成 HTML 报告coverage html浏览器打开htmlcov/index.html每一行代码是否被测试跑到一目了然。覆盖率不是越高越好。无脑追求 100% 会导致写一堆逻辑重复的测试后期维护成本远比收益高。我的经验是核心模块覆盖率至少到 80% 以上数据解析、金额计算这类容易出错的代码尽量 100%入口文件、配置类、纯样板代码可以放过。7.2 文件命名、类命名与方法命名约定unittest的默认发现机制决定了命名必须守规则否则测试会被静默跳过。我们团队的统一约定是测试文件放在tests/目录文件名test_模块名.py。测试类用Test开头比如TestDivide。测试方法用test_开头后面跟具体行为例如test_divide_by_zero。一个测试类对应一个被测模块一个测试方法对应一个具体行为场景。这套命名约定看着简单实际作用很大当某个测试失败日志里会直接显示test_calc.TestDivide.test_divide_by_zero任何人不用翻代码就能知道是哪个模块的哪个场景出了问题。7.3 一条命令跑完所有测试为了让大家愿意跑测试必须把运行成本降到最低。我在项目根目录放一个Makefiletest: python -m unittest discover -s tests -p test_*.py -v coverage: coverage run -m unittest discover -s tests -p test_*.py coverage report -m这样团队任何人只需要执行make test就能跑完全部用例。CI 里也可以用同一套命令保证本地和 CI 行为一致。如果项目测试日益增多还可以用-k参数按关键字快速筛出一部分用例。比如只跑与订单相关的python -m unittest discover -s tests -k order7.4 我对单元测试成本的真实看法经常有人问我写测试耽误时间值得吗我的体感是前三个测试写得慢因为要设计用例、琢磨怎么 mock写到第十个以后套路固定下来一个函数配一份测试的时间基本稳定在写函数时间的一半以内。而这部分时间会在未来无数次的改动中被十倍省回来。我自己定的规矩是改动任何工具函数或核心业务代码时必须保证对应测试仍然通过新增的公共函数必须带测试才能合入主干。这两条在团队里执行了快两年真正线上的严重回归故障数量降到了个位数。写测试这件事前期确实有学习成本和摩擦感但一旦过了那个坎它会成为你修改代码时最踏实的底气。如果这篇文章能帮一个正在犹豫的人写出自己的第一个unittest用例那说明这时间花得值。
返回列表