Python单元测试(unittest)实战指南:覆盖、Mock与CI落地
做后端开发这些年我吃过最亏的一次教训是在一次大版本升级时连续三个晚上都在修同一个被改动波及的老模块。当时项目里的测试为零代码一改谁也不知道哪里会炸。后来我把单元测试系统地捡起来用 Python 内置的 unittest 框架给核心模块一个个补齐用例才真正体会到什么叫改动有底气。这篇文章不是官方文档的复述而是我从实际项目中总结出来的一套 Python 单元测试unittest实战指南从测试项目的目录结构、断言方法到外部依赖的 mock 隔离、数据库和临时文件处理再到覆盖率统计和 CI 接入覆盖一条完整的落地链路。适合刚开始接触测试的 Python 开发者也适合写过一些测试但总感觉没写到点子上的同行。1. 先想清楚单元测试到底在测什么unittest 为什么还值得学1.1 单元测试的单元不是文件而是行为很多新手写单测时会陷入一个误区以为只要给每个函数都写一个测试文件就算完成任务了。其实单元测试衡量的是代码单元——一个函数、一个类的方法、一条可独立验证的业务规则——在给定输入下能否产出正确输出状态是否按预期变化。我习惯用一个例子来理解这件事。假设生产一台汽车出厂前不把发动机单独拉上测试台直接整车路试一旦出了问题你没法判断是发动机、电路还是传动系统的锅。单元测试就是那个单独拉上测试台的动作把目标函数从系统里揪出来喂给它构造好的输入检查它的输出和副作用。这样错误一出现定位范围被缩到最小修复成本也最低。1.2 unittest 和 pytest、doctest 的边界在哪里聊到 Python 测试很多人第一反应是 pytest这很正常pytest 的 fixture 和插件生态确实强大。但我想先说清楚一件事unittest 作为 Python 标准库自带的测试框架到今天依然没有过时而且很多时候它才是更稳妥的那个选择。对比维度unittestpytestdoctest安装成本标准库自带零依赖需要 pip install标准库自带零依赖测试发现内置 discover 机制自动发现手动跑 docstring断言能力TestCase 提供的丰富断言方法原生 assert 表达式依赖文档示例插件生态一般但有标准方案非常丰富几乎没有学习曲线平缓结构固定灵活但需要理解 fixture 机制最平缓实际项目中我两种都用过我的建议是如果团队在内网环境、装第三方包要走流程unittest 可以直接用如果项目里已经有 pytest 的代码习惯也没必要强行改回 unittest。更重要的是unittest 是 Python 开发者的基础技能读别人的代码时能看懂 unittest 用例是绕不开的基本功。对我个人来说还有一点很实在unittest 是标准库意味着任何一台装了 Python 的机器都能直接跑测试不需要先搭环境。这在一个大型团队里能省掉大量我这边装不了依赖的扯皮。1.3 什么时候不该用 unittest也不能把 unittest 吹上天。如果你写的是小型脚本、一次性数据处理任务或者只是想在交互环境里快速验证一段逻辑那写单元测试确实有点杀鸡用牛刀。另外如果你的团队已经在 pytest 上积累了成熟的 fixture 库和插件生态为了统一而强行切到 unittest 也不明智。还有个典型场景如果你的代码大量依赖 C 扩展或底层 IO比如直接操作硬件、调用 GPU 能力这类场景的单元测试价值有限重点应该放在集成测试或端到端验证上。单元测试解决的是逻辑正确性问题解决不了系统协同问题。一句话总结unittest 适合覆盖项目里那些纯逻辑密集、可依赖隔离的模块适合标准化开发流程、适合作为团队默认测试基础设施——它稳定、无依赖、有官方的持续支持。2. 从零搭出一个工程级测试项目目录、用例和断言2.1 推荐的目录结构别把所有测试塞进一个文件我见过很多项目把几十个测试类全部堆在一个 test_all.py 里看起来很方便实际维护起来非常痛苦。单测文件的组织应该跟着被测模块走一个模块对应一个测试文件文件名用 test_ 前缀。一个相对标准的目录结构大概是这样的project/ ├── app/ │ ├── __init__.py │ ├── calc.py │ ├── user_service.py │ └── ... ├── tests/ │ ├── __init__.py │ ├── test_calc.py │ ├── test_user_service.py │ └── ... ├── requirements.txt └── README.md这里有两个细节需要注意。第一个tests 目录下一定要加init.py。有些新手会想不通测试代码又不是被 import 的包为什么要加空文件原因是 unittest 的 discover 机制在递归查找测试模块时需要把找到的目录当做 Python 包来导入没有init.py某些路径下会导入失败尤其是被测代码也在项目里、需要跨目录 import 的时候。这个文件加了以后python -m unittest discover -s tests才能稳定工作。第二个被测代码要尽可能做成可导入的包而不是一堆脚本文件。如果 app 只是一个普通目录而没有init.py运行测试时在 tests 目录下 import app.calc 就会报 ModuleNotFoundError。解决方法是把项目根目录加到 PYTHONPATH或者从项目根目录运行测试命令。我的习惯是直接在被测代码包里放init.py保证目录结构本身就是合法的 Python 包。2.2 第一个真正能跑的测试用例假设 app/calc.py 里有一个非常简单的加法函数# app/calc.py def add(a, b): return a b那对应的测试文件 tests/test_calc.py 可以这样写# tests/test_calc.py import unittest from app.calc import add class TestAdd(unittest.TestCase): def test_add_two_positive_numbers(self): self.assertEqual(add(1, 2), 3) def test_add_negative_and_positive(self): self.assertEqual(add(-1, 2), 1)写好之后在项目根目录运行python -m unittest discover -s tests -v执行结果会显示每个用例是否通过。这里有个最常见的坑很多人习惯直接运行测试文件本身比如python tests/test_calc.py这样跑起来往往报错找不到 app 模块或者只能跑当前文件无法实现批量测试。正确做法永远是从项目根目录用 discover 来发现测试。2.3 setUp 和 tearDown什么东西适合放进去setUp 和 tearDown 是 unittest 里最容易用错的两个方法。它们的执行时机是每个测试用例执行前先跑 setUp用例执行完再跑 tearDown。也就是说有多少个测试方法setUp 就会执行多少次。基于这个机制setUp 里只应该放每个用例都需要且内容完全一样的准备逻辑。比如初始化一个通用对象、准备一份公共数据字典、建立一条基础连接。如果只有部分用例需要某个数据不要放进 setUp应该做成辅助方法在需要它的用例里显式调用。import unittest class TestUserService(unittest.TestCase): def setUp(self): self.service UserService(db_urlsqlite:///:memory:) # 每次用例前都重新创建 service保证用例之间互不影响 def tearDown(self): self.service.close()还有一个容易忽略的点setUp 里创建的实例每个用例执行前都会重新创建。这其实是刻意设计的隔离机制确保一个用例的数据污染不会传导到另一个用例。不要去试图用 setUpClass 做这种事情。那么 setUpClass 和 tearDownClass 用在哪里呢适合那些创建成本高、且所有用例共享的准备比如初始化一个重量级的数据库连接池、启动一个共用的外部进程。要注意的是setUpClass 修改的类属性会被所有用例共享使用上要格外小心避免产生跨用例的状态依赖。2.4 断言方法的选择assertEqual 不是万能的在测试里断言方法选得好不好直接决定失败时你能看到多少信息。很多新手只用一个 assertTrue(expr expected)这也是能跑的但一旦断言失败输出只会告诉你表达式是 False你还要回代码里猜。assertEqual 就不一样失败时它会同时打印出实际值和期望值肉眼一扫就知道差距在哪。以下是 unittest 断言方法里实战中最常用的几个断言方法作用典型场景assertEqual / assertNotEqual判断值相等/不等函数返回值校验assertTrue / assertFalse判断布尔结果业务开关、状态判断assertIs / assertIsNone判断身份相等对象单例、空值校验assertIn / assertNotIn判断成员关系列表、字典 key 校验assertAlmostEqual浮点数近似相等科学计算、金额计算assertRaises断言抛出异常非法输入校验assertWarns / assertLogs断言警告/日志弃用提醒、日志记录assertIsInstance判断对象类型工厂方法返回值校验这里特别想提醒一个坑浮点数比较永远不要用 assertEqual。def calculate_rate(total, count): if count 0: return 0.0 return total / count比如calculate_rate(10, 3)的结果是 3.3333333333333335你期望是 3.33直接 assertEqual 一定是失败的。正确的做法是 assertAlmostEqual它可以指定比较精度self.assertAlmostEqual(calculate_rate(10, 3), 3.33, places2)我一直认为断言方法是单测的语言如果只会 assertEqual很多特征表达不出来测试写起来就很别扭。稍微花点时间把常用断言过一遍写用例的速度会快很多。3. 隔离外部依赖mock 与 patch 的实战用法3.1 为什么要 mock测试的目标是你的代码不是别人的服务单元测试有一个天然要求稳定、可重复、运行快。如果你的被测函数内部发起了 HTTP 请求、查询了数据库、读取了环境变量那测试结果就变得不可控了。网络波动会导致用例失败第三方服务限流会导致超时别人改了线上数据会让你的断言对不上。这时候就要用 mock 了。mock 的本质是用一个可控的假对象替换真实对象把测试的关注点从外部环境拉回到你自己的业务逻辑上。我常用一个比喻来解释 mock你在写一个程序程序里有一段逻辑需要验证用户是否是 VIP 用户而 VIP 状态来自第三方接口。你不希望在测试时真的等那个接口返回数据于是你造了一个假的接口让它每次都返回是 VIP这时候再验证你的业务逻辑是否给出正确结果。这就是 mock 做的事。它的好处很明显测试速度极快不依赖网络和外部服务。结果可控你让 mock 返回什么它就返回什么。能模拟异常、超时等真实场景验证你的代码在失败时是否有兜底逻辑。用例之间互相独立不会因为线上数据变化而崩溃。3.2 patch 的三种用法与作用域陷阱unittest.mock 模块提供了 patch 函数有三种常见的用法实际项目中会用哪个看具体场景。第一种装饰器方式适合整个测试方法都需要 mock 的情况from unittest import mock from app import user_service mock.patch(app.user_service.requests.get) def test_get_user_success(mock_get): mock_get.return_value.json.return_value {name: Alice} user user_service.get_user_info(1) self.assertEqual(user[name], Alice)第二种上下文管理器方式适合只在测试代码中间段需要 mock 的情况def test_get_user_with_retry(self): with mock.patch(app.user_service.requests.get) as mock_get: mock_get.side_effect [TimeoutError(timeout), {ok: True}] result user_service.get_user_info_with_retry(1) self.assertEqual(result[ok], True)第三种patch.object适合替换一个对象上的具体属性或方法def test_use_redis_client(self): with mock.patch.object(redis_client, get, return_valuebdata): value service.get_cache(key1) self.assertEqual(value, bdata)这里有一个非常经典的坑也是面试里常考的patch 的参数写的是被测代码里使用的引用路径而不是对象的原始定义路径。举个例子。假设有一个模块 app/user_service.py它是这样写的# app/user_service.py import requests def get_user_info(user_id): resp requests.get(fhttps://api.example.com/users/{user_id}) return resp.json()很多新手会这样做mock.patch(requests.get) # 错误 def test_get_user(self, mock_get): ...这样做为什么错因为 patch 替换的是requests 模块里的 get而你的被测模块里 import 的是 requests 这个模块调用的时候用的是requests.get。一旦 patch 生效requests.get确实被换掉了但因为你在 test 文件里 import 的路径是requestspatch 的也是requests所以实际上能生效。真正出错的情形是被测代码写成from requests import get这样它 import 的是 get 函数的引用你 patchrequests.get就不管用了。这时必须 patch 被测模块里的引用# user_service.py from requests import get def get_user_info(user_id): resp get(fhttps://api.example.com/users/{user_id}) return resp.json()测试时就得写成mock.patch(app.user_service.get) def test_get_user_info(self, mock_get): ...关键在于记住patch 的目标是被测代码所在模块中名称查找路径上的那个名字。一条实用规则只要被测代码里写的是from xxx import yyypatch 就要写app.yyy_module.yyy如果是import xxx后面通过xxx.yyy调用patch 可以写xxx.yyy也可以写app.xxx.yyy但为了严谨我习惯统一写后者也就是到被测模块的命名空间里去替换。3.3 完整实例模拟 HTTP 请求、时间依赖和环境变量实际业务里一个方法往往同时依赖多个外部因素。我展示一个稍微完整点的例子。假设有一个用户等级模块# app/user_service.py import os import requests from datetime import datetime def get_user_level(user_id): token os.environ.get(SERVICE_TOKEN, ) headers {Authorization: fBearer {token}} resp requests.get( fhttps://api.example.com/users/{user_id}, headersheaders, timeout5, ) resp.raise_for_status() user resp.json() created datetime.fromisoformat(user[created_at]) days (datetime.now() - created).days if days 365: return senior if user[points] 1000: return vip return normal测试这个函数我们需要同时控制环境变量、HTTP 响应和当前时间。逐个来from unittest import mock, TestCase from datetime import datetime from app import user_service class TestGetUserLevel(TestCase): mock.patch(app.user_service.datetime) mock.patch(app.user_service.requests.get) mock.patch.dict(os.environ, {SERVICE_TOKEN: test-token}) def test_returns_senior_when_registered_more_than_a_year( self, mock_get, mock_datetime ): mock_get.return_value.json.return_value { created_at: 2020-01-01T00:00:00, points: 10, } mock_datetime.now.return_value datetime(2024, 6, 1, 12, 0, 0) mock_datetime.fromisoformat.side_effect datetime.fromisoformat result user_service.get_user_level(1) self.assertEqual(result, senior) mock_get.assert_called_once()这里有两个经验要分享。第一个patch.dict 是修改环境变量的利器它会在 with 块或装饰器函数结束时自动恢复原有值避免污染其他测试。第二个mock 标准库的 datetime 时有坑。datetime 在 CPython 里是 C 语言实现的类型直接替换 datetime.fromisoformat 可能会出问题。我这里的做法是把 datetime 整体替换成 mock 对象然后手动让 fromisoformat 走原始实现。但这依然比较绕。实际项目中我更推荐的是把获取当前时间封装成一个独立函数# app/user_service.py def _now(): return datetime.now()然后业务代码里统一调_now()测试时只 patch 这个私有函数mock.patch(app.user_service._now, return_valuedatetime(2024, 6, 1, 12, 0, 0)) def test_returns_vip_when_points_high(self, mock_now): ...这个思路的核心是在代码里加一层薄薄的接缝让测试能插进去。不要试图去 mock 那些底层到 C 实现、或与 Python 运行时深度绑定的对象那是自己给自己找麻烦。3.4 mock 对象的核心行为return_value、side_effect 和调用断言mock 对象最常用的配置是 return_value它决定调用 mock 时始终返回同一个值。这个很简单不用多说。我想重点说的是 side_effect它的功能远不止返回数据。side_effect 可以是一个异常、一个可迭代对象、或者一个函数。实际使用中最常见的三种场景模拟第一次调用抛异常第二次调用正常返回用来测试重试逻辑mock_get.side_effect [TimeoutError(first timeout), response_obj]模拟接口限流mock_get.side_effect requests.exceptions.HTTPError(429 Too Many Requests)根据调用的参数动态决定返回值def fake_get(url, **kwargs): if users in url: return fake_user_response() return fake_order_response() mock_get.side_effect fake_get有一类测试不仅要验证返回结果对不对还要验证函数是否以正确的方式调用了依赖。举例你有一个支付服务测试时要确认扣款函数确实被调用了且传入的金额参数正确。这是 mock 对象的一个不可替代的优势。mock_pay.assert_called_once_with(amount99.9, order_id123)再举一个场景某个模块在特定条件下不应该触发某个调用你可以用 assert_not_called 来验证。这些断言方式正好对应了测试的两个目标验证结果正确验证过程正确。断言方法作用assert_called_once确认调用恰好一次assert_called_once_with确认调用一次且参数完全匹配assert_any_call确认至少一次以指定参数调用assert_not_called确认从未被调用call_count获取调用次数4. 异常、边界值与多组输入单测质量的分水岭4.1 用 assertRaises 测异常但别只测是否抛错很多项目的测试覆盖内容里异常分支是重灾区。不少开发者写测试时测异常就是简单地写with self.assertRaises(ValueError): parse_int(abc)这样写能在确实抛了 ValueError时通过但有一个重要信息被丢掉了异常信息本身。如果你的 parse_int 函数有一天改了异常文案这是不是一种破坏性变更如果你希望调用方根据异常信息做分支处理文案变化就会影响下游逻辑。所以更严谨的写法是拿到异常对象连上下文一起验证with self.assertRaises(ValueError) as ctx: parse_int(abc) self.assertEqual(str(ctx.exception), invalid literal for integer: abc)还有一种常见问题测试对象抛出来的异常类型太泛比如直接抛 Exception那你在测试里也没法区分这是预期的异常还是代码 bug 导致的异常。我建议业务方法里抛出具体的异常类型要么是内置的 ValueError、TypeError要么是项目自定义的业务异常子类错误类型本身就是一种文档。4.2 边界值、空值、非法输入一组典型的用例设计写单测时用例设计比用例数量重要。我习惯按照这几类输入来组织测试正常输入常规业务数据。边界输入最小、最大、接近某阈值。空值输入None、空字符串、空列表、空字典。非法输入类型错误、超范围数值。异常输入组合两个边界条件同时发生时。举一个简单的会员折扣函数def calc_discount(amount): if amount 0: raise ValueError(amount cannot be negative) if amount 1000: return amount * 0.8 if amount 100: return amount * 0.95 return amount针对这个函数我至少会写这些用例def test_normal_low_amount(self): self.assertEqual(calc_discount(99), 99) def test_boundary_100(self): self.assertEqual(calc_discount(100), 95) def test_boundary_1000(self): self.assertEqual(calc_discount(1000), 800) def test_between_boundaries(self): self.assertEqual(calc_discount(500), 475) def test_negative_amount_raises(self): with self.assertRaises(ValueError): calc_discount(-1) def test_zero_amount(self): self.assertEqual(calc_discount(0), 0) def test_minimal_positive_amount(self): self.assertEqual(calc_discount(0.01), 0.01)看到没有针对一个只有几行的函数可以写出七个清晰的用例。测试的价值不在于代码量而在于你把所有输入类别都保护起来了。以后如果有人重构这个函数把折扣区间改成满 999 打八折这些用例会立刻报警。4.3 subTest同一逻辑多组输入的最佳表达方式上面那个例子如果每个输入都写成一个独立的 test 方法代码重复度很高也不好维护。unittest 提供了 subTest 子测试机制专门用来处理同一段断言逻辑、多组输入数据的场景。def test_discount_for_multiple_amounts(self): cases [ (0, 0), (50, 50), (99, 99), (100, 95), (500, 475), (999, 949.05), (1000, 800), ] for amount, expected in cases: with self.subTest(amountamount, expectedexpected): self.assertAlmostEqual(calc_discount(amount), expected, places2)subTest 的好处不仅仅是少写几个函数更关键的是普通断言在一个 case 失败时整个测试会立刻停止后面的 case 根本没机会执行而 subTest 会继续执行完所有 case并把失败信息全部列出来。调试的时候你能一眼看到到底是哪组输入挂了而不是修完一组又发现下一组挂了来来回回跑十趟。4.4 测试不需要的异常与多余的异常有一个新手很容易做错的地方以为测试里覆盖了异常就够了。比如某个函数依赖的数据库连接出错了函数内部本来就会抛 OperationalError你写一个 assertRaises(OperationalError) 看起来是通过了但你要问自己这个异常是你业务代码有意处理的还是底层库透传上来的如果是后者这个测试的价值就很小你只是把底层的错误行为记录下来了。真正有价值的异常测试是你的业务代码对某类输入明确做了防御性检查抛出的异常带有业务语义并且上层调用方依赖这个异常做分支处理。这时候才值得专门写用例去锁定它。5. 临时文件与数据库让测试既真实又干净5.1 TemporaryDirectory 管理临时文件别用 mkstemp很多函数会读写文件如果测试直接在本项目目录下创建一个临时文件测完又忘记删除几次下来工作区里全是垃圾文件。更严重的是如果测试数据是固定的文件名当测试并行执行时不同进程会互相覆盖测试结果随机失败。正确做法是使用 tempfile.TemporaryDirectoryimport os import tempfile import unittest class TestFileExporter(unittest.TestCase): def test_export_writes_csv(self): with tempfile.TemporaryDirectory() as tmpdir: file_path os.path.join(tmpdir, output.csv) exp FileExporter() exp.export(file_path, data[{name: Alice, age: 30}]) self.assertTrue(os.path.exists(file_path)) with open(file_path, r, encodingutf-8) as f: content f.read() self.assertIn(Alice, content)TemporaryDirectory 会在退出 with 块后自动清理整个目录不用手动删除。这里要额外注意一点打开文件时一定要指定 encodingutf-8。在 Windows 上默认编码可能是 gbk测试用例在本地跑得好好的一提交到 Linux 的 CI 环境就报 UnicodeDecodeError这种问题我已经见过很多次了。5.2 数据库相关测试内存数据库与事务回滚数据库测试比文件测试复杂得多常见做法有两种。第一种使用内存数据库。SQLite 的:memory:能提供完整的 SQL 能力而且速度极快不需要清理磁盘文件。适合验证纯 ORM 模型、简单的增删改查逻辑。import sqlite3 import unittest class TestUserRepository(unittest.TestCase): def setUp(self): self.conn sqlite3.connect(:memory:) self.conn.execute(CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)) self.conn.execute(INSERT INTO users (name) VALUES (Alice)) def tearDown(self): self.conn.close() def test_get_user_by_name(self): result self.conn.execute(SELECT name FROM users WHERE nameAlice).fetchone() self.assertEqual(result[0], Alice)第二种使用独立的测试数据库。当业务对 SQL 方言有依赖比如用到 PostgreSQL 的 JSON 字段、MySQL 的特定函数时内存 SQLite 不够用了就需要一个真实的测试库。这里有一条重要经验不要在 tearDown 里直接 drop 数据库也不要每次用例都重建整个库那样会慢到让你怀疑人生。我习惯的做法是在所有用例开始前setUpClass建好数据库结构并插入公共基础数据然后在每个用例的 tearDown 里把表数据清空或者用事务回滚的方式控制。用事务回滚的思路是这样的from unittest import TestCase class TestRepository(TestCase): def setUp(self): self.connection create_test_database() self.connection.begin() # 开启事务 def tearDown(self): self.connection.rollback() # 回滚数据全部还原如果 ORM 和事务控制得比较规范这会是个很顺滑的方案setUp 开启事务用例里随便改数据tearDown 直接回滚等于每个用例都拿到一份干净数据且不需要真实删除任何记录。5.3 测试数据的构造技巧最小化不要复制生产库最容易让单测变质的行为之一是把生产环境的真实数据比如一张十兆的 JSON直接拷贝到测试里当 fixture。这样会导致几个问题测试文件巨大、依赖的字段太多、生产数据包含大量无关信息断言时你根本不知道哪些字段是关键的。正确的做法是最小化测试数据只保留当前业务逻辑真正会用到的字段。假设你要测试的是解析用户注册信息你的测试数据只需要 id、name、created_at 这些字段不需要把用户的订单列表、积分明细全都搬进来。数据越少用例的可读性越高断言的目标越明确。我个人的习惯是利用一个小工厂函数生成测试对象而不是在测试里硬编码一长串字典def make_user(**overrides): data { id: 1, name: Alice, created_at: 2023-01-01T00:00:00, points: 100, is_active: True, } data.update(overrides) return data测试时只要覆盖你要验证的字段user make_user(points2000)这种方式让测试数据的意图一目了然也避免了一个字典里 30 个字段只有 2 个是关键这种让人觉得晦涩的代码。6. 覆盖率、CI 与测试报告怎么让单测发挥更大价值6.1 unittest 的命令行与测试发现机制测试写好了光靠 IDE 里右键运行单个文件是走不远的。unittest 提供了 discover 机制可以从一个目录开始自动查找并运行所有测试。我最常用的命令是python -m unittest discover -s tests -p test_*.py -v参数含义-s tests指定起始目录为 tests。-p test_*.py匹配以 test_ 开头、以 .py 结尾的文件。-v输出每个用例的详细信息比如 PASS/FAIL 和运行时长。为什么不直接跑python tests/test_calc.py因为每个文件独立运行你没有统一的入口也不能批量控制。一旦用例数量上去了手动点文件会点到手酸更不可能在 CI 里自动化执行。6.2 覆盖率统计coverage.py 的基本用法单测的覆盖范围可以通过 coverage 这个第三方工具来统计。安装后在项目根目录运行pip install coverage coverage run -m unittest discover -s tests -v coverage report -m coverage htmlreport 会输出每个文件的覆盖百分比html 会生成一个浏览器可查看的报告能直观看到哪一行没有被执行到。这个报告对团队 review 和后续补测试特别有用能很快发现这个模块完全没测过。但我要强调一个观点覆盖率数字高并不代表测试质量高。我见过不少团队把覆盖率当成 KPI然后把所有分支都写了能跑但不断言结果的测试覆盖率很快刷到 90% 以上但代码行为完全没被约束住。举个例子一个函数里有个 if 分支你的测试调用了这个函数却只检查函数不抛异常那这一行代码被覆盖了但它的正确性没有任何保障。覆盖率真正的价值是帮你发现完全没测到的盲区而不是衡量测试的优劣。核心业务模块覆盖率至少要跑到 80% 以上但更重要的是每个分支都有对应的断言验证结果。我在项目里会同时关注两件事报告里那些红色的未覆盖行以及测试代码里断言的质量。6.3 接入 CI让单测成为每次提交的必经关卡单元测试要真正发挥作用必须和 CI 串起来。每次提交代码、每次合并请求都自动跑一遍全套测试任何失败都阻断合并。这做好之后很多 bug 在被同事看到之前就被拦下了。一套最小可用的流程大概是先安装依赖再跑测试最后生成并上传测试报告。以 GitHub Actions 为例配置文件可以这样写name: Python Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.12 - run: pip install -r requirements.txt - run: coverage run -m unittest discover -s tests -v - run: coverage report -m这个配置很短但效果很直接每个 push 和 PR 都会自动执行测试套件任何一条用例失败都会让 CI 飘红。团队里一旦有人引入了回归谁提交的、影响了哪个模块一眼就能看出来。接入 CI 的时候有几个细节值得注意。第一个测试环境尽量和生产环境保持一致的 Python 版本别本地 3.12 跑得欢CI 里用 3.8 就各种报语法错误。第二个不要在 CI 里跳过全部测试只为了快速跑通。合入到主干之前哪怕多花两分钟把全部用例跑完也比让一个低级 bug 混进去强。第三个如果项目里有异步代码、定时任务之类的复杂逻辑单测覆盖不了的一定要用集成测试补上。CI 是最后一道防线别把所有压力都放在开发者的我本地跑过了上。7. 常见坏味道与重构实战中总结的几条经验7.1 测试耦合生产实现而不是行为这是我在评审同事测试代码时最常说的一句话。有些测试为了覆盖某一个分支直接依赖函数内部私有方法的名字甚至断言内部某一步调用了某个具体实现。这种做法在短期内让覆盖率好看了但一旦重构哪怕函数的对外行为完全没变测试也会因为内部实现调整而崩溃。我举一个真实的例子。某个模块原来用 requests 请求外部接口后来技术升级改成了 httpx这个时候如果你之前的测试是通过 patchrequests.get来写的那升级后测试就会瞬间挂掉。但业务行为并没有变化我们想要保护的是传入正确的参数、返回正确的数据而不是请求必须用 requests。好的测试应该只关注两个层面输入是什么、输出是什么。中间用了什么库、内部怎么实现那不是测试该管的。所以我在写 patch 时会尽量限定在测试文件里、限定在必要的位置而不是到处依赖实现细节。诚然完全隔离实现细节有时候很难但我们的目标是把这种耦合度降到最低。7.2 一个用例检查太多东西假设一个测试方法长这样def test_user_creation(self): user create_user(Alice, 30) self.assertIsNotNone(user.id) self.assertEqual(user.name, Alice) self.assertEqual(user.age, 30) self.assertTrue(user.is_active) self.assertEqual(user.created_at.year, 2024) self.assertIsInstance(user, User)这条用例一口气验证了分配 id、姓名、年龄、状态、创建时间、类型断言多达六个。如果中间某一个挂掉你还要先判断是哪一个再判断它和其他断言有没有关联。当用例失败时排错信息越多定位反而越慢。我的经验是一个测试方法里断言最好不要超过三个最好是围绕同一个行为。如果一个函数真的有这么多需要验证的点拆成多个用例反而更清晰。比如上面这个例子可以拆成 TestUserCreation 类里的 test_assigns_id、test_sets_basic_info、test_defaults_is_active。7.3 测试命名让别人一眼看懂失败原因测试失败是家常便饭但一个好的测试名能省掉大量排查时间。我一般遵循的命名格式是test_方法名_场景_期望结果举几个例子test_add_positive_numbers_returns_sumtest_validate_email_rejects_invalid_formattest_get_user_level_when_points_above_1000_returns_viptest_calc_discount_when_amount_negative_raises_value_error这样的命名在跑测试的时候即使不看日志只看测试名也知道失败的用例在测什么。如果测试名只是test_add1、test_case2这种编号那你在 CI 日志里看到失败时还得手动去代码里猜这测的是什么完全是给自己添堵。7.4 不要用 skip 逃避问题unittest 提供了unittest.skipIf和unittest.skip用来跳过某些测试。这个机制本身没问题但在团队实践里它经常成为逃避问题的出口。常见的情况是某个用例依赖外部服务或特定操作系统暂时跑不了于是有人直接加个 skipIf 绕过去。这种做法短期内让测试全绿但长期来看那些被跳过的测试就是一堆死代码——没有人会记得去解掉 skip也就没有人能保证那个逻辑是正常的。我推荐的原则是如果依赖的是外部服务能在测试里 mock 就 mock不能 mock 就在 CI 里单独建一个 integration 任务不要把集成测试和纯单测混在一起如果是因为某个平台的特性导致跑不了要在注释里写清楚这个用例为什么被跳过什么时候能恢复。最差的选择是为了绿而绿直接把问题 test 注释掉。跳过三条测试的那天你骗过了 CI也骗过了产品但没骗过线上的 bug。7.5 时机问题先写测试再修 bug比想象中好用最后分享一个我坚持了很久的习惯也是我觉得对测试质量提升最有效的做法修 bug 之前先写一个会失败的测试然后再去修代码直到测试通过。这个做法在团队里推广的时候起初大家觉得多此一举后来就真香了。因为它相当于先把缺陷翻译成一个可验证的标准然后让修改结果向着这个标准收敛既避免了改了 A 处B 处的老问题又复现的情况也给后续人留下了一个回归用例。改动完成之后这个测试会一直留在测试套件里就像一个哨兵防止同样的逻辑错误再次出现。从成本角度看这种为 bug 而写的测试往往是最划算的测试因为你已经知道会发生什么、问题在哪里、怎么验证写起来非常快但回报是一劳永逸的。我甚至可以说我项目里最有价值的那些测试很大一部分就是当年为修 bug 而写的回归测试。还有一个小技巧测试通过后我通常会顺手检查一下代码里有没有可以安全删除的兼容逻辑或者临时代码。因为有测试兜底删起来也格外放心。这种循环做多了代码质量自然会上去。