做后端开发这些年,我吃过最亏的一次教训,是在一次大版本升级时,连续三个晚上都在修同一个被改动波及的老模块。当时项目里的测试为零,代码一改,谁也不知道哪里会炸。后来我把单元测试系统地捡起来,用 Python 内置的 unittest 框架给核心模块一个个补齐用例,才真正体会到什么叫"改动有底气"。这篇文章不是官方文档的复述,而是我从实际项目中总结出来的一套 Python 单元测试(unittest)实战指南,从测试项目的目录结构、断言方法,到外部依赖的 mock 隔离、数据库和临时文件处理,再到覆盖率统计和 CI 接入,覆盖一条完整的落地链路。适合刚开始接触测试的 Python 开发者,也适合写过一些测试但总感觉"没写到点子上"的同行。
1. 先想清楚:单元测试到底在测什么,unittest 为什么还值得学
1.1 单元测试的"单元"不是文件,而是行为
很多新手写单测时会陷入一个误区:以为只要给每个函数都写一个测试文件,就算完成任务了。其实单元测试衡量的是"代码单元"——一个函数、一个类的方法、一条可独立验证的业务规则——在给定输入下能否产出正确输出,状态是否按预期变化。
我习惯用一个例子来理解这件事。假设生产一台汽车,出厂前不把发动机单独拉上测试台,直接整车路试,一旦出了问题,你没法判断是发动机、电路还是传动系统的锅。单元测试就是那个"单独拉上测试台"的动作:把目标函数从系统里揪出来,喂给它构造好的输入,检查它的输出和副作用。这样错误一出现,定位范围被缩到最小,修复成本也最低。
1.2 unittest 和 pytest、doctest 的边界在哪里
聊到 Python 测试,很多人第一反应是 pytest,这很正常,pytest 的 fixture 和插件生态确实强大。但我想先说清楚一件事:unittest 作为 Python 标准库自带的测试框架,到今天依然没有过时,而且很多时候它才是更稳妥的那个选择。
| 对比维度 | unittest | pytest | doctest |
|---|---|---|---|
| 安装成本 | 标准库自带,零依赖 | 需要 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_url="sqlite:///: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, places=2)我一直认为,断言方法是单测的"语言",如果只会 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_value=b"data"): value = service.get_cache("key1") self.assertEqual(value, b"data")这里有一个非常经典的坑,也是面试里常考的:patch 的参数写的是"被测代码里使用的引用路径",而不是"对象的原始定义路径"。
举个例子。假设有一个模块 app/user_service.py,它是这样写的:
# app/user_service.py import requests def get_user_info(user_id): resp = requests.get(f"https://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 的路径是requests,patch 的也是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(f"https://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 yyy,patch 就要写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": f"Bearer {token}"} resp = requests.get( f"https://api.example.com/users/{user_id}", headers=headers, timeout=5, ) 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_value=datetime(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(amount=99.9, order_id=123)再举一个场景:某个模块在特定条件下不应该触发某个调用,你可以用 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(amount=amount, expected=expected): self.assertAlmostEqual(calc_discount(amount), expected, places=2)subTest 的好处不仅仅是"少写几个函数",更关键的是:普通断言在一个 case 失败时,整个测试会立刻停止,后面的 case 根本没机会执行;而 subTest 会继续执行完所有 case,并把失败信息全部列出来。调试的时候,你能一眼看到到底是哪组输入挂了,而不是修完一组又发现下一组挂了,来来回回跑十趟。
4.4 测试"不需要的异常"与"多余的异常"
有一个新手很容易做错的地方:以为测试里"覆盖了异常"就够了。比如某个函数依赖的数据库连接出错了,函数内部本来就会抛 OperationalError,你写一个 assertRaises(OperationalError) 看起来是通过了,但你要问自己:这个异常是你业务代码有意处理的,还是底层库透传上来的?如果是后者,这个测试的价值就很小,你只是把底层的错误行为记录下来了。
真正有价值的异常测试是:你的业务代码对某类输入明确做了防御性检查,抛出的异常带有业务语义,并且上层调用方依赖这个异常做分支处理。这时候才值得专门写用例去锁定它。
5. 临时文件与数据库:让测试既真实又干净
5.1 TemporaryDirectory 管理临时文件,别用 mkstemp
很多函数会读写文件,如果测试直接在本项目目录下创建一个临时文件,测完又忘记删除,几次下来工作区里全是垃圾文件。更严重的是,如果测试数据是固定的文件名,当测试并行执行时,不同进程会互相覆盖,测试结果随机失败。
正确做法是使用 tempfile.TemporaryDirectory:
import 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", encoding="utf-8") as f: content = f.read() self.assertIn("Alice", content)TemporaryDirectory 会在退出 with 块后自动清理整个目录,不用手动删除。这里要额外注意一点:打开文件时一定要指定 encoding="utf-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 name='Alice'").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(points=2000)这种方式让测试数据的意图一目了然,也避免了"一个字典里 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/checkout@v4 - uses: actions/setup-python@v5 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 而写的回归测试。
还有一个小技巧,测试通过后,我通常会顺手检查一下代码里有没有可以安全删除的兼容逻辑或者临时代码。因为有测试兜底,删起来也格外放心。这种循环做多了,代码质量自然会上去。