ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python单元测试实战:用unittest为代码构建可靠安全网

2026/9/8 6:53:02 拓冰建站 浏览量
Python单元测试实战:用unittest为代码构建可靠安全网 很多人一听到“单元测试”这四个字第一反应往往是“这不归我管功能能跑就行。”我以前也这么想直到有一次改一个订单金额计算函数把向下取整改成了四舍五入结果线上所有低于 0.5 的分位金额都多了一分钱。这个 bug 说出去不复杂但排查代价很高因为没有任何代码提示你“这里改坏了”。从那以后我才真正意识到写测试不是给公司交差也不是测试工程师一个人的事而是给未来的自己留一份行为说明和改代码的安全网。这篇文章想写的是一套可以直接上手的 Python 单元测试实战经验核心就是标准库自带的 unittest 框架。内容覆盖从最基本的测试用例怎么写、断言怎么用到 setUp/tearDown、Mock 外部依赖、测试套件组织、覆盖率统计再到我实际维护项目时踩过的坑。无论你是刚接触 Python 的新手还是写过几个月脚本但一直没系统搞过测试的开发者都能照着文章里的思路把单元测试真正用起来而不是停留在“看过教程但不会落地”的状态。1. 为什么值得先把 unittest 吃透1.1 单元测试到底解决什么问题单元测试本质上是把“代码里的最小逻辑单元”单独拎出来验证比如一个函数、一个类方法给定输入检查输出是否符合预期。听起来很简单但它解决的是开发中特别现实的问题没人敢改老代码。我说几个场景你一定不陌生。一个项目维护了大半年核心模块越堆越复杂这时候产品提了个需求要调整某个计算逻辑。你改吧担心牵一发动全身不改吧需求就卡在手里。还有更痛苦的线上报了一个边界条件的 bug修复之后过了两周同样的问题又因为另一个入口复现了原因是当初修的时候只改了那一处没考虑其他调用方。单元测试不能阻止 bug 出现但它能在你改完代码后把“是否破坏了原有行为”这件事暴露出来让你在提交代码之前就知道哪里不对。从另一个角度看单元测试还是一种可执行的行为文档。新同事接手你负责的模块与其看大段注释不如看测试代码来得直观。测试里清清楚楚写着这个函数支持什么输入、抛什么异常、返回什么结构这些信息比文字描述更精确而且能一直跟着代码演进。1.2 为什么选择 unittest 而不是一上来就上 pytest现在的 Python 测试生态里pytest 确实很流行插件多、写法简洁、fixture 机制强大。但我在很多项目里仍然优先建议先把 unittest 吃透原因非常朴素unittest 是 Python 标准库的一部分不需要额外安装任何东西只要有 Python 环境就能直接跑测试。这看起来是个小优势在真实项目里却非常重要。你无法保证所有同事都愿意在自己环境里 pip install pytest更别说在一些离线环境、生产部署包或者别人拉下来的教学项目里少一个依赖就少一个安装失败的借口。另一个更关键的原因是pytest 完全兼容 unittest 风格的测试用例。也就是说你用 unittest 写出来的测试将来项目升级引入 pytest不需要重写pytest 会直接发现并运行它们。先学 unittest 不会走弯路反而是打基础。当然用 unittest 写测试确实比 pytest 啰嗦一点需要写类、写方法、手动调断言。但我反而觉得这种“啰嗦”对新手是好事。它把测试的结构摆得很明确一个测试类对应一组相关的测试场景一个 test 开头的方法对应一个具体用例可读性非常强。1.3 什么样的代码值得写单元测试不是所有代码都需要单元测试也不是功能写完顺手补两个断言就行。我自己在实际项目里的判断标准很简单纯函数优先业务逻辑其次外部 IO 尽量用 Mock 来测。所谓的纯函数是指同样的输入一定得到同样的输出、不依赖外部状态、没有副作用的函数。比如金额计算、字符串格式化、数据转换、类型校验、解析函数这些是单元测试收益最高的地方。热搜词里经常有人搜“Python 类型转换”“Python 语法”这类工具函数恰恰是最适合用单测锁死行为的。而像爬虫里真正的网络请求、数据库读写、调用第三方 API这些不适合直接测真实环境但可以通过 Mock 来模拟后面我会专门讲。还有一类代码建议优先补测试那就是你自己都拿不准的“危险函数”。如果你每次改某个函数都要小心翼翼、反复检查调用点或者它曾经出过线上 bug那就别再犹豫了给它写测试把它变成你可以随意改动、放心重构的逻辑。2. 从零搭第一个测试用例核心概念与最小可运行示例2.1 准备环境与标准运行方式先确认你本机的 Python 环境没问题。在命令行执行python --version如果有输出版本号说明解释器正常。接着试一下python -m unittest --help如果能看到帮助信息说明 unittest 自带的测试运行器已经可用了。这里有个很多人忽略的细节运行 unittest 的正确姿势是用python -m unittest而不是直接python test_xxx.py。区别在于python -m unittest会先加载并识别测试类再按测试框架的方式执行并汇总结果而直接 python 运行脚本虽然也能执行但如果是没有命令行入口的测试文件往往什么都不输出还容易造成“测试通过了”的错觉。我建议你养成一个习惯在项目根目录下建一个tests目录把所有测试文件放进去命令行里统一用python -m unittest discover来自动发现测试。这样即使测试文件越来越多也不用手动逐个指定文件名。2.2 最小示例一个订单折扣计算函数为了让你快速理解 unittest 的核心概念我写一个非常贴近业务的例子。假设我们正在做一个电商系统需要一个根据用户等级计算折扣价格的函数。我刻意在函数里加入了类型校验和异常处理因为真实业务里这种情况非常常见而且这些分支都是单元测试要覆盖的重点。# order.py def calculate_discount(price, level): 根据用户等级计算打折后的金额。 :param price: 原始价格数字类型 :param level: 用户等级normal / vip / svip :return: 打折后的金额 if not isinstance(price, (int, float)): raise TypeError(price 必须是数字类型) if price 0: raise ValueError(price 不能为负数) if level normal: return round(price, 2) if level vip: return round(price * 0.9, 2) if level svip: return round(price * 0.8, 2) raise ValueError(f未知的用户等级: {level})接下来是针对这个函数的测试代码。新建一个test_order.py文件内容如下# test_order.py import unittest from order import calculate_discount class TestCalculateDiscount(unittest.TestCase): # 测试普通用户原价 def test_normal_level_no_discount(self): self.assertEqual(calculate_discount(100, normal), 100.0) # 测试 VIP 用户打九折 def test_vip_level_10_percent_off(self): self.assertEqual(calculate_discount(100, vip), 90.0) # 测试 SVIP 用户打八折 def test_svip_level_20_percent_off(self): self.assertEqual(calculate_discount(100, svip), 80.0) # 测试价格为 0 的边界情况 def test_zero_price_edge_case(self): self.assertEqual(calculate_discount(0, normal), 0.0) # 测试小数价格验证四舍五入 def test_float_price_rounding(self): self.assertEqual(calculate_discount(10.05, vip), 9.05) # 测试负数价格抛异常 def test_negative_price_raises_value_error(self): with self.assertRaises(ValueError): calculate_discount(-1, normal) # 测试非数字价格抛异常 def test_non_numeric_price_raises_type_error(self): with self.assertRaises(TypeError): calculate_discount(abc, normal) # 测试未知用户等级抛异常 def test_unknown_level_raises_value_error(self): with self.assertRaises(ValueError): calculate_discount(100, diamond) if __name__ __main__: unittest.main()在命令行进入test_order.py所在目录执行python -m unittest test_order -v会看到类似下面的输出test_float_price_rounding (test_order.TestCalculateDiscount) ... ok test_negative_price_raises_value_error ... ok test_non_numeric_price_raises_type_error ... ok test_normal_level_no_discount ... ok test_svip_level_20_percent_off ... ok test_unknown_level_raises_value_error ... ok test_vip_level_10_percent_off ... ok test_zero_price_edge_case ... ok ---------------------------------------------------------------------- Ran 8 tests in 0.002s OK其中的-v参数表示详细输出把每个用例的名字和结果都列出来。不加-v时会紧凑地显示点和 OK 之类的状态信息一个点表示通过F表示断言失败E表示代码执行时抛出异常。看到FAILED (failures1)时就说明有测试和你预期不一致了。2.3 核心概念TestCase、test 方法、断言与测试报告从上面的例子里你已经能看到 unittest 的三个核心元素。第一个是测试类它必须继承unittest.TestCase。这个基类提供了所有断言方法、测试控制逻辑以及后面会讲到的 setUp/tearDown 钩子。类的名字用什么其实不影响执行但为了可读性通常用Test加上被测对象名比如TestCalculateDiscount。第二个是测试方法必须以test_开头。unittest 会通过这个前缀自动识别哪些方法需要执行。没有test_前缀的方法即使写在测试类里也不会被运行这一点经常有人踩坑以为写了就一定会跑。第三个是断言方法。我根据自己的经验把最常用的几个整理成一张表方便你查用断言方法作用assertEqual(a, b)判断 a 和 b 相等assertNotEqual(a, b)判断 a 和 b 不相等assertTrue(x)判断 x 为 TrueassertFalse(x)判断 x 为 FalseassertIsNone(x)判断 x 为 NoneassertIsNotNone(x)判断 x 不为 NoneassertIn(item, container)判断 item 在 container 中assertNotIn(item, container)判断 item 不在 container 中assertIsInstance(obj, cls)判断 obj 是 cls 的实例assertAlmostEqual(a, b, places)判断 a 和 b 在小数点后 places 位内相等assertRaises(exception)配合 with 使用判断是否抛出指定异常这里特别要提醒一句测试里不要用print来“看”结果。print只能让你肉眼判断对错但测试的意义是把预期固化下来以后每次改动代码都能自动对比。所以任何时候能用断言就尽量用断言断言才是测试和普通脚本之间的分界线。3. 测试装置Fixture生命周期setUp 和 tearDown 的正确打开方式3.1 为什么要用 setUp而不是每个测试函数里重复准备单元测试有一个基本要求每个测试用例之间是相互独立的。也就是说运行第一个测试时的状态不应该影响到第二个测试。但真实业务里很多测试都需要前提数据。比如测一个解析函数你得先准备一份样例文本测数据库操作你得先建一张临时表测缓存逻辑你得先往缓存里写数据。如果没有统一的准备机制你只能在每个 test 方法里写一遍初始化代码测试一多内容就会非常冗余而且很容易出现“后面的人只复制了一个用例忘了准备初始数据”的坑。unittest 提供了一套生命周期钩子来解决这个问题setUp会在每个测试方法执行前自动调用tearDown会在每个测试方法执行后自动调用。直接看一个文件解析的示例。假设我们有一个函数读取 JSON 文件并返回其中某个字段# file_utils.py import json def read_json_field(file_path, field): with open(file_path, r, encodingutf-8) as f: data json.load(f) return data.get(field)对应的测试类可以用 setUp 在每个用例前创建临时文件用 tearDown 清理掉# test_file_utils.py import json import os import tempfile import unittest from file_utils import read_json_field class TestReadJsonField(unittest.TestCase): def setUp(self): # 在系统临时目录里创建一个测试文件 self.temp_dir tempfile.TemporaryDirectory() self.file_path os.path.join(self.temp_dir.name, config.json) with open(self.file_path, w, encodingutf-8) as f: json.dump({name: unittest, version: 1}, f) def tearDown(self): # 清理临时目录 self.temp_dir.cleanup() def test_read_existing_field(self): self.assertEqual(read_json_field(self.file_path, name), unittest) def test_read_missing_field_returns_none(self): self.assertIsNone(read_json_field(self.file_path, not_exist))这样做的好处非常明显每个测试跑的时候都有一份干净的文件任何测试修改了它下一个测试又会重新创建互不干扰。即便中途某个测试失败tearDown 也会尽量执行清理不会把临时垃圾文件留在项目目录里。3.2 setUpClass 和 tearDownClass类级别的一次性资源如果每个测试用例都需要准备一模一样的重量级资源比如连接数据库、启动一个模拟服务或者加载一份很大的语料每次 setUp 都新建一份会非常耗时。这种场景就适合用setUpClass和tearDownClass。这两个方法是类级别的钩子整个测试类只会在最开始执行一次 setUpClass在所有测试跑完后执行一次 tearDownClass。注意它们必须配合classmethod装饰器使用否则会运行报错。import unittest class TestDatabaseOperations(unittest.TestCase): classmethod def setUpClass(cls): cls.connection create_connection() # 假设这里创建数据库连接 cls.connection.open() classmethod def tearDownClass(cls): cls.connection.close() def test_insert(self): # 使用 cls.connection 操作数据库 pass def test_query(self): # 使用 cls.connection 查询数据库 pass这里有两点需要特别注意。第一类级别资源是所有测试方法共享的所以放在类属性里cls.connection。如果某个测试方法修改了连接的状态很可能影响后续测试所以使用类级资源时测试之间要保持更严格的约定。第二setUpClass里的代码一旦抛出异常这个测试类里所有用例不会执行因此建议只在创建真正重量级且只读的资源时使用类级初始化其余临时数据还是放到 setUp 里更安全。3.3 用 addCleanup 简化清理逻辑实际开发中经常遇到资源不一定能优雅关闭的情况。比如抛异常中断了 tearDown或者资源是动态申请、不确定在哪个阶段被创建的。这时候addCleanup比 tearDown 更灵活。addCleanup注册一个回调函数在整个测试过程中无论测试成功还是失败只要测试跑完注册的回调就会被执行。结合前面读 JSON 文件的例子可以改写成这样class TestReadJsonField(unittest.TestCase): def setUp(self): self.temp_dir tempfile.TemporaryDirectory() self.addCleanup(self.temp_dir.cleanup) self.file_path os.path.join(self.temp_dir.name, config.json) # ...其余省略addCleanup的好处是你可以在资源创建后立刻注册清理不用担心后续代码是否中断。它是在测试框架层面保证“就算断言失败清理也会执行”比手动在 tearDown 里层层判断要可靠得多。3.4 关于 setUp 失败的隐藏陷阱这里分享一个我实际项目里排过很久的坑。unittest 的执行逻辑是先执行 setUp再执行测试方法最后执行 tearDown。但如果 setUp 本身抛了异常测试方法不会运行tearDown 也根本不会执行。这意味着如果你在 setUp 里创建了临时文件然后在 setUp 后面某一步抛了异常这个临时文件就没人清理了。解决方法是前面说的addCleanup因为它在 setUp 执行前就完成注册能跟着整个生命周期的结束被调用。另一个办法是把资源创建放到尽量简单的位置不要在 setUp 里做复杂的初始化宁可多写几行也别把临时文件、数据库连接、网络请求这些容易出问题的操作堆在一起。4. 用 Mock 隔离外部依赖让测试稳定且快速4.1 什么时候需要 Mock 外部依赖单元测试的另一个原则是快速、稳定、可重复。可是真实的业务函数通常不只有计算逻辑它可能发网络请求、读系统时间、连数据库、依赖随机数。这些外部依赖有三个问题不稳定网络一抖测试就失败很慢一次真实 HTTP 请求可能花几百毫秒不可控你不知道线上第三方 API 今天返回什么。解决思路就是 Mock用模拟对象替换真实依赖让被测函数以为自己调了外部服务实际上拿到的是我们预先设计好的假数据。举个最常见的爬虫场景。热搜词里有大量“python 爬虫”“爬虫解析”相关搜索很多初学者写爬虫时根本没有测试概念一旦网站结构变了或者网络超时只能手动去跑脚本调试。其实爬虫的解析部分非常适合单元测试。我们有一个模块web_parser.py负责通过 requests 获取网页并解析标题# web_parser.py import requests from bs4 import BeautifulSoup def fetch_title(url): resp requests.get(url, timeout5) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) return soup.title.string.strip()如果不 Mock测试时真的去访问外网那测试结果就取决于网络状况。正确做法是模拟requests.get的返回值让它返回一段固定的 HTML。4.2 patch 与 Mock 的完整示例测试代码这样写# test_web_parser.py import unittest from unittest import mock from web_parser import fetch_title class TestFetchTitle(unittest.TestCase): mock.patch(web_parser.requests.get) def test_fetch_title_returns_stripped_title(self, mock_get): # 准备模拟响应对象 fake_resp mock.Mock() fake_resp.text htmlheadtitle 测试页面 /title/head/html fake_resp.raise_for_status mock.Mock() mock_get.return_value fake_resp result fetch_title(https://example.com/any-page) self.assertEqual(result, 测试页面) mock_get.assert_called_once_with(https://example.com/any-page, timeout5)这里最关键的一行是mock.patch(web_parser.requests.get)。新手最容易犯的错误是写成mock.patch(requests.get)。为什么不对因为web_parser.py是通过import requests把模块引入了当前命名空间当被测函数调用requests.get时它访问的是web_parser模块里的全局名字requests而不是requests原始模块里的get。所以 patch 的路径必须是被测模块里、调用点所在位置的名字也就是web_parser.requests.get。理解这一点很多 Mock 失效的诡异问题都能解决。mock.Mock()会生成一个自动对象你给它赋什么属性它就有什么属性。这里给它定义了text和raise_for_status然后把整个对象赋给mock_get.return_value。这样被测函数里执行responses.get(...)时拿到的是 fake_resp执行.text时得到我们预设的 HTML执行raise_for_status()时不会抛异常。最后一句assert_called_once_with是校验函数是否按预期参数调用了 requests.get防止将来有人不小心改了超时时间或者 URL 拼接逻辑。4.3 side_effect 的三种用法Mock 的side_effect非常强大它可以让同一个 Mock 对象在不同调用下表现不同。实际业务里最典型的需求有三种。第一种是抛异常。比如测试网络超时后程序能正确捕获异常而不是直接崩溃mock.patch(web_parser.requests.get) def test_fetch_title_raises_on_timeout(self, mock_get): mock_get.side_effect requests.Timeout(connect timeout) with self.assertRaises(requests.Timeout): fetch_title(https://example.com)第二种是返回一个序列。比如模拟接口分页第一次调用返回第一页数据第二次调用返回第二页数据mock_get.side_effect [fake_resp_page1, fake_resp_page2]Mock 会依次把列表里的元素作为返回值。第三种是让同一个 Mock 根据不同输入返回不同结果这时可以把side_effect设成一个函数def fake_get(url, timeout5): resp mock.Mock() if good in url: resp.text title好页面/title else: resp.text title坏页面/title resp.raise_for_status mock.Mock() return resp mock_get.side_effect fake_get这几种用法足以覆盖绝大多数外部依赖模拟场景。不管你是测量化策略里的时间函数、爬虫里的请求重试还是业务代码里的随机数分支思路都是同一套用 Mock 把不可控的东西换掉让测试只关注你的代码逻辑本身。4.4 Mock 的常见坑与使用守则Mock 虽然好用但不能滥用。我见过一些测试把所有依赖全部 mock 掉最后测试已经不是在验证真实逻辑而是在“验证 Mock 是否被正确调用”这种测试的价值会迅速下降。写 Mock 之前先问自己一个问题我到底想测什么如果这个依赖不影响当前函数的输入输出只是副作用那可以不 mock如果它直接影响计算结果而且真实环境不方便访问那才值得 mock。另外一个常见坑是Mock 的自动属性太灵活容易掩盖拼写错误。比如被测函数里写的是resp.text你在 Mock 里写的是resp.texMock 不会报错它会返回一个新的 Mock 对象然后你的断言大概率会失败而且报错信息不容易一眼看出是属性拼错。所以建议在设置 Mock 属性时尽量用spec参数限制 Mock 只能访问真实对象上存在的属性或者直接在测试里给 Mock 对象赋好准确的属性名。5. 测试套件、跳过、子测试与持续集成5.1 用 discover 组织多文件测试当项目规模变大tests目录下的测试文件越来越多手动指定文件就会变得不现实。unittest 自带测试发现机制你可以直接运行python -m unittest discover -s tests -p test_*.py-s指定测试文件所在的目录-p指定文件名的匹配模式。默认情况下unittest 会进入目录只要文件的命名匹配test*.py就会自动加载其中的测试类和测试方法。命令行最后还有一个-v加上之后会输出每条用例的详细信息适合在本地调试时观察具体哪个用例失败了。在持续集成环境里这条命令尤其重要。测试失败时python -m unittest的进程退出码不是 0CI 系统会据此判断流水线是否失败。所以建议在 CI 的脚本里优先使用这种写法而不是python test_xxx.py后者即使测试失败了脚本也可能继续执行起不到拦截作用。5.2 跳过测试与预期失败真实项目里会有些测试暂时不能跑最常见的是依赖特定操作系统、依赖某些环境变量、或者依赖第三方库是否安装。这时可以使用skip装饰器。import sys import unittest class TestSkipExamples(unittest.TestCase): unittest.skip(功能未完成暂不执行) def test_not_ready(self): pass unittest.skipIf(sys.platform.startswith(win), Windows 环境跳过) def test_linux_only(self): pass unittest.skipUnless(sys.version_info (3, 10), 需要 Python 3.10) def test_new_feature(self): passskip是无条件跳过skipIf是条件为真时跳过skipUnless是条件为假时跳过。这些装饰器可以加在测试方法上也可以加在测试类上。如果整个类都不想执行加在类上行即可。还有一个装饰器expectedFailure它最容易被忽略但很有用。当你知道某个旧代码还有 bug暂时又没时间修可以用它标记表示“这个用例预计会失败”。如果之后 bug 被修复测试竟然通过了unittest 会把结果标记为unexpected success这能提醒我们“当初标记的问题已经解决可以移除标记了”。这种方式比把失败测试注释掉要专业得多因为你的测试套件始终在监控着已知问题。5.3 用 subTest 实现轻量参数化如果你有一组数据想用同一套测试逻辑验证多个输入输出很多人会写多个 test 方法或者用 for 循环。但这两种方式都有痛点for 循环里的断言一旦失败整个循环会中断后面的用例根本没跑而且你很难从报表里看出失败的是哪组数据。unittest 提供了subTest它可以解决这个痛点。看一个类型转换函数的例子这种函数在业务代码里特别常见def to_int(value, defaultNone): try: return int(value) except (TypeError, ValueError): return default测试如下import unittest from converters import to_int class TestToInt(unittest.TestCase): def test_to_int_with_multiple_cases(self): cases [ (123, 123, 字符串转整数), ( , None, 纯空白返回默认值), (3.14, None, 小数无法直接转整数), (abc, -1, 非法输入返回自定义默认值), (None, 0, None 返回默认值), ] for value, expected, desc in cases: with self.subTest(casedesc, valuevalue): self.assertEqual(to_int(value, expected if expected is not None else 0), expected if expected is not None else 0)这个例子里即使其中一组数据断言失败循环也不会中断其他数据照样执行。报表中会精准显示是哪个case挂了。subTest 本质上是给多组输入输出做轻量参数化的路子虽然不如 pytest 的参数化插件灵活但已经能满足绝大多数场景不需要额外引入依赖。5.4 测试执行顺序与状态隔离有一个容易被忽视的问题unittest 会按方法名的字母顺序执行测试方法而不是按你写在类里的顺序。比如test_a_first一定比test_b_second先执行。这就意味着如果你不小心在第一个测试里修改了某个类变量或全局变量第二个测试可能会受到影响而这种影响非常难排查因为从代码表面看两个函数没有任何直接关系。所以测试之间隔离的守则一定要记住不要在测试方法里依赖另一个测试方法执行过的结果所有公共状态尽量放到 setUp 里重建类属性只放不变的常量不放可以修改的“临时缓存”。这也是很多团队强制要求“每个测试必须能独立运行”的原因。将来如果你想用随机顺序跑测试来排查依赖问题单靠 unittest 的原生能力不够但遵守状态隔离原则至少能保证测试顺序变化时结果稳定。6. 覆盖率与旧项目落地技巧6.1 用 coverage.py 统计测试覆盖率你在让别人跑测试时最常被问的问题就是“测了多少代码”这个问题的量化指标叫代码覆盖率。它表示测试执行过程中被测代码里有多少行、多少个分支被跑到了。unittest 本身不提供覆盖率统计功能需要配合第三方库 coverage.py 使用。安装很简单如果你的环境里有 pippip install coverage在项目根目录执行coverage run -m unittest discover -s tests coverage report -m第一行会用 coverage 启动 unittest 并收集覆盖率数据第二行会把结果打印出来格式类似这样Name Stmts Miss Cover Missing ---------------------------------------------- converter.py 25 3 88% 17-19, 24 order.py 40 8 80% 33, 45-52 ---------------------------------------------- TOTAL 65 11 83%想生成更直观的 HTML 报告可以执行coverage html它会生成一个htmlcov目录用浏览器打开里面的 index.html就能看到每个文件哪一行被覆盖、哪一行没被覆盖的标注。这个功能对排查“为什么覆盖率上不去”特别有用。6.2 覆盖率指标的正确心态覆盖率只是参考不是目标。我见过一些项目疯狂追求 100% 覆盖率最后测试全在 mock、全在测那些 getter/setter真正的核心逻辑反而没被验证到。这属于为指标而指标的自我欺骗。更合理的做法是重点关注重要的业务模块覆盖率尤其是有复杂 if/else 分支、有异常处理的函数先把逻辑的“主要分支”覆盖掉再慢慢补齐边界条件。另外一定要记住高覆盖率不等于代码没有 bug。覆盖只能说明“这些行执行过”不能说明“执行的结果都符合预期”。你仍然需要写正确的断言否则覆盖率再高也只是表面功夫。6.3 老项目如何平滑引入单元测试给已经运行很久、没有任何测试的存量代码补测试最忌讳的就是想一口吃成胖子。我的建议是从风险最高的“危险函数”开始。怎么找第一看最近半年出过 bug 比较多的模块第二看每次改动你都不敢动的函数第三看纯逻辑、参数多的工具函数。找到目标后先不要急着改代码先按照被测函数现有的行为写测试把当前行为“固化”下来。这时候即使函数本身有 bug你也先不要修只记录现有行为。等测试跑通后再开始重构或者修 bug每改一步就运行一次测试确认没有破坏已知行为。对存量代码来说单元测试最重要的价值不是证明代码有多正确而是让它从此可以“被安全地修改”。没有测试的旧代码就像一座没有扶手的楼梯你可以走但每一步都提心吊胆。补上测试之后楼梯就有了护栏后面的人再改建时心里才踏实。7. 常见问题与排查技巧实录7.1 高频问题速查表我把这几年维护项目时经常遇到的问题整理成一张速查表每种问题的背后几乎都藏着一段真实的踩坑经历。现象可能原因解决思路测试文件找不到模块项目根目录不在 sys.path 中或缺少__init__.py在项目根目录运行命令或设置PYTHONPATH.浮点数断言不稳定直接使用 assertEqual 比较浮点结果改用assertAlmostEqual指定精度Mock 不生效仍然发起真实请求patch 路径指向第三方库原始模块而非被测文件引用检查被测文件的 import 方式patch 调用点的名字某个测试单独跑通过合在一起跑失败测试之间共享了类属性或全局变量把可变状态放到 setUp 中隔离用例中文输出在命令行显示乱码Windows 控制台默认编码问题设置环境变量PYTHONIOENCODINGutf-8测试方法写错了但没报错方法名没有以test_开头检查方法前缀识别器只会执行 test 开头的方法测试运行一直卡住被测代码里存在真实网络请求或死循环优先 Mock 外部调用或设置超时大量测试重复执行目录下有多个 discover 命令同时匹配相同文件调整-p的模式避免重复收集assertRaises 不生效括号写错了位置把函数调用写在了 with 块里注意在with self.assertRaises(...)中调用函数setUp 里抛异常导致 tearDown 不执行资源没有创建完成清理逻辑无法展开使用addCleanup或把复杂初始化拆小7.2 真实排障复盘Mock 失效的那一次说一个我印象特别深的排障过程。当时有个函数是从某平台拉数据我用mock.patch(service.api_client.get)去模拟跑了半天测试还是疯狂请求真实接口。我检查了很多次最后发现service.py文件顶部是这样写的from api_client import get它把get这个函数直接 import 进来了所以函数内部调用的是service.get而不是api_client.get。这时候我 patchapi_client.get没有任何效果因为名字已经被复制到了 service 的命名空间。正确做法是 patchservice.api_client.get或者 patchservice.get。所以记住一个判断逻辑patch 的路径 被测模块里最终访问的那个全局名字的完整路径。看 import 语句如果写的是import api_client就 patchapi_client.get如果写的是from api_client import get被测模块里已经没有“api_client”这个概念了这时候就要 patchservice.get。7.3 关于运行环境和 IDE 的补充建议热搜词里有很多人搜“vscode python环境配置”“pycharm配置python环境”说明环境问题一直困扰大家。单元测试同样可能被环境问题影响。我的建议是先确认命令行里能跑的测试再考虑 IDE 集成。比如你进了一个虚拟环境但 pycharm/vscode 的解释器还指向系统 Python那很多依赖版本就会产生分歧。排查思路很简单在 IDE 里打开终端执行python -m unittest如果它找不到某些依赖就说明当前 IDE 的终端和命令行环境不一致需要检查解释器配置。另外把测试命令固化成一个 shell 脚本或者 Makefile 里的一个 target 也是一种好习惯。比如在项目根目录加一个run_tests.sh#!/bin/bash export PYTHONPATH. python -m unittest discover -s tests -v这样团队成员无论用什么 IDE都能用同一套命令跑出一样的结果。环境差异在测试世界里是个隐形杀手能提前消除就别偷懒。结尾分享说了这么多最后分享一点个人体会。我自己的习惯是给老项目补测试时先从那个让你“最害怕改动”的函数开始。不要一开始就追求覆盖率数字也不要试图把所有代码都测一遍那是完不成的任务。真正有效的路径是先找两三个核心函数把它们的测试写扎实运行起来你立刻会感到一种微妙的安心。以后每次改代码运行一遍测试看到一片绿色那种“底线还在”的信心是任何代码审查都给不了的。另外还有个小技巧对新手特别有用先写你认为应该有的行为再补测试实现。很多人在写assertEqual(calculate_discount(...), 90.0)时会下意识被现有代码带跑用了错误的 Expected 值测试等于白写。反向操作先想想业务上这组输入应该得到什么结果再去看代码是否满足。这样测试才有真正的守护价值。这篇文章的实操部分到这里就告一段落了。单元测试这东西看起来简单真正用起来是在无数次踩坑之后才慢慢上手的。希望我的这些经验能让你少走一段弯路。