如果你写Python已经写过一段时间,多半会有这样一个体验:写代码一时爽,改代码火葬场。一个需求下来,改动三五个文件,心里面默念着“应该没问题吧”,然后手动跑一遍流程,看起来正常,上线。结果第二天用户反馈一个边角场景崩了。这种事经历几次之后,你就会意识到,靠手工点验的“信心”根本不靠谱。我大概是在工作第二年开始认真用unittest的,从那之后,代码的交付底气完全不一样了。
unittest是Python标准库自带的单元测试框架,不需要安装任何第三方包,import unittest就能用。它解决的问题说白了就一句话:让你的代码逻辑每一次改动之后都能被快速验证,而不是靠肉眼盯、靠运气上线。不管你是写爬虫、做量化策略、写数据分析脚本,还是在维护一个大型Web服务,只要写代码,单元测试这件事就绕不开,而unittest就是最基础的起点。
1. 为什么说unittest是Python测试的基础设施
1.1 没有测试的代码,慌的不止是你一个人
我在很多群里见过类似的求助:有人改了一个公共函数,结果整个服务启动都异常了;有人重构了一个模块,自测的时候看着没啥问题,上线后线上日志刷屏。这些问题单看表象是“代码bug”,但根子往往在“没有测试”上。代码是人写的,改动的波及范围很难完全靠脑子记清楚,尤其当项目代码量超过几千行、调用链拉到三四层之后,任何一次改动都可能踩中某个隐蔽依赖。
单元测试的存在,本质上就是为了应对这种不确定性。你把关键函数和核心流程写成测试用例,每次改动后跑一遍,如果用例全绿,至少说明你改动的直接影响面是可控的。哪怕测试没覆盖到所有分支,它也像一个护栏,拦住大部分掉下去就疼的情况。我自己的习惯是:不写测试的代码,在提交之前心里永远是虚的。
1.2 unittest凭什么成为首选之一
Python生态里测试框架不少,pytest写起来更简洁、插件更丰富,很多人一上来就被pytest的“黑魔法”吸引。但我要说的是,unittest才是那个最基础、最不容易翻车的地基。原因有三个。
第一,它是标准库的一部分。只要你装了Python,import unittest就能运行,不需要单独安装、不需要担心依赖冲突。在很多公司内网环境里,安装第三方包要走审批流程,而unittest天然就能用。
第二,它背后的设计思路非常正统。unittest的灵感来源于Java的JUnit,采用的是经典的xUnit架构:TestCase组织用例、TestSuite组合用例、TestRunner执行用例、TestLoader负责发现用例。这套结构理解透了,你再去学pytest、nose或者其他框架,上手会非常快,因为核心思想是相通的。
第三,它不依赖任何魔法。unittest通过继承、约定命名(test_开头)、以及显式断言来工作,代码改写起来很直白。出了问题,排查路径清晰:要么是被测代码有bug,要么是断言写错了,要么是setUp之类的生命周期函数有问题,不存在“框架帮你自动干了什么”的迷惑行为。
2. 核心机制拆解:TestCase、断言与生命周期
2.1 TestCase的子类化:最小的测试组织单位
unittest里最核心的类就是unittest.TestCase。你写一个测试类,继承它,然后在类里面定义以test_开头的方法,这些方法就是一个个测试用例。注意,这里有两个关键点:类必须继承TestCase,方法名必须用test_前缀。
import unittest class TestMathFunctions(unittest.TestCase): def test_add(self): self.assertEqual(1 + 1, 2) def test_subtract(self): self.assertEqual(5 - 3, 2)为什么非得是test_开头?因为unittest的默认发现机制是按这个命名约定来识别用例的。当你在命令行执行python -m unittest时,框架会扫描测试模块,把符合规则的类和方法收集起来,自动执行。不满足这个命名约定的方法,哪怕你写在测试类里,也不会被当成测试用例。
这里有一个经验:一个测试方法最好只测一个行为。比如test_add就只验证加法的结果,别又验证加法参数类型报错、又验证浮点数精度,那样一旦失败,你得花时间分析具体是哪一步出问题。测试方法粒度越细,排查成本越低。
2.2 断言方法:验证代码行为的工具箱
TestCase提供了几十个断言方法,比单纯用assert语句强大得多。核心的几个你最好记牢:
| 断言方法 | 作用 |
|---|---|
assertEqual(a, b) | 验证a == b |
assertNotEqual(a, b) | 验证a != b |
assertTrue(x)/assertFalse(x) | 验证x的真假 |
assertIs(a, b) | 验证a is b(对象身份) |
assertIsNone(x) | 验证x是None |
assertIn(item, container) | 验证item在容器中 |
assertNotIn(item, container) | 验证item不在容器中 |
assertRaises(Exception, func, *args) | 验证调用func会抛出指定异常 |
assertAlmostEqual(a, b, places=None) | 验证浮点数近似相等 |
assertGreater(a, b) | 验证a > b |
assertRegex(s, regex) | 验证字符串匹配正则 |
用这些断言,其实是在把“什么样的结果算正确”这条规则写清楚。我见过很多新手用assertTrue(result == expected)代替assertEqual,这在功能上没错,但失败时报错信息会非常难看:只能看到False is not True,看不到实际值是多少。而assertEqual失败时,unittest会打印两边的值,排查起来一目了然。
关于浮点数比较,必须用assertAlmostEqual而不是assertEqual。因为浮点运算有精度问题,0.1 + 0.2 == 0.3在Python里是False。如果你写assertEqual(0.1 + 0.2, 0.3),这个测试必然失败。assertAlmostEqual默认比较到小数点后7位,对于大多数业务场景足够了,也可以在places参数里指定精度。
class TestFloat(unittest.TestCase): def test_float_compare(self): # 不推荐:assertEqual(0.1 + 0.2, 0.3) 会失败 self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)2.3 setUp与tearDown:测试前后的“搭台”与“拆台”
单元测试的一大铁律是“用例之间相互独立”。每个测试方法跑的时候,都应该有一个干净的初始环境,跑完就把环境清理干净。unittest用setUp和tearDown这两个钩子来帮你实现这一点。
setUp:在每个测试方法执行之前运行。适合创建测试数据、初始化对象、打开临时文件。tearDown:在每个测试方法执行之后运行。适合关闭文件、清理数据库记录、删除临时目录。
import unittest class TestOrder(unittest.TestCase): def setUp(self): self.order = Order() self.order.add_item("苹果", 5, 2) def tearDown(self): # 如果有外部资源,在这里释放 pass def test_total(self): self.assertEqual(self.order.total, 10) def test_apply_discount(self): self.order.apply_discount(0.2) self.assertAlmostEqual(self.order.total, 8)这里有个细节很多人会忽略:setUp和tearDown的执行次数。它们不是每个测试类跑一次,而是每个测试方法都跑一遍。也就是说,如果测试类里有3个测试方法,setUp会被调用3次。这样设计的初衷就是保证隔离性,但代价是如果setUp里面做了重量级操作(比如连数据库),测试会变得很慢。
如果某些初始化只需要做一次、所有测试方法共享,可以用setUpClass和tearDownClass,它们是类方法,整个测试类只执行一次。但这个选择必须谨慎:一旦类级别的共享状态被某个测试方法改动,其他测试方法可能被影响。
2.4 测试调度器:从TestCase到TestSuite再到Runner
单个测试类只是点,真正跑起来靠的是unittest的调度体系。简单说,流程是这样的:TestLoader发现并加载测试类里的方法,把每个方法包装成一个TestCase实例;多个TestCase实例可以组合成TestSuite;最后TestRunner拿到TestSuite按顺序执行,并输出结果。
日常使用中,你大部分时候不用手动去碰这些底层组件,直接跑命令行就行。但理解这个链路有实际价值。比如,你可以用TestSuite把多个测试类手动组织起来,指定执行顺序,这在做集成测试时挺有用。
import unittest if __name__ == "__main__": suite = unittest.TestSuite() suite.addTest(TestOrder("test_total")) suite.addTest(TestMathFunctions("test_add")) runner = unittest.TextTestRunner(verbosity=2) runner.run(suite)再比如,你可以通过unittest.defaultTestLoader.discover来扫描目录下的所有测试模块,这在项目逐步变大时几乎是必备功能。
3. 实战演练:从零搭一个可复用的测试体系
3.1 为什么拿订单系统来练手
我认为单元测试最好的学习场景,不是那种刻意简化到“加个数字”的demo,而是一个有状态、有规则、有异常分支的真实业务对象。订单系统就很典型:它有时刻变化的总价字段,有需要校验的输入参数,有打折这种容易出边界问题的逻辑,还经常要对接支付、库存这类外部依赖。把这些场景拆开练习,你学到的unittest用法,几乎可以原样迁移到爬虫、量化策略、数据分析管道等其他项目里。
3.2 编写被测代码与测试用例
先写一个相对完整的订单类:
# order.py class Order: def __init__(self): self.items = [] self.total = 0 self.discount = 0 def add_item(self, name, price, quantity=1): if price < 0: raise ValueError("价格不能为负数") if quantity <= 0: raise ValueError("数量必须为正整数") self.items.append({"name": name, "price": price, "quantity": quantity}) self.total += price * quantity def apply_discount(self, rate): if not 0 <= rate <= 1: raise ValueError("折扣率必须在0到1之间") self.discount = rate self.total *= (1 - rate) def get_items_count(self): return sum(item["quantity"] for item in self.items)然后写对应的测试模块:
# test_order.py import unittest from order import Order class TestOrderInitialization(unittest.TestCase): def setUp(self): self.order = Order() def test_initial_total(self): self.assertEqual(self.order.total, 0) def test_initial_items(self): self.assertEqual(self.order.items, []) class TestOrderAddItem(unittest.TestCase): def setUp(self): self.order = Order() def test_add_single_item(self): self.order.add_item("苹果", 5) self.assertEqual(self.order.total, 5) self.assertEqual(self.order.get_items_count(), 1) def test_add_multiple_items(self): self.order.add_item("苹果", 5, 3) self.assertEqual(self.order.total, 15) self.assertEqual(self.order.get_items_count(), 3) def test_add_negative_price_raises(self): with self.assertRaises(ValueError): self.order.add_item("苹果", -1) def test_add_zero_quantity_raises(self): with self.assertRaises(ValueError): self.order.add_item("苹果", 5, 0) class TestOrderDiscount(unittest.TestCase): def setUp(self): self.order = Order() self.order.add_item("苹果", 100) def test_apply_discount_normal(self): self.order.apply_discount(0.2) self.assertAlmostEqual(self.order.total, 80) def test_apply_discount_max(self): self.order.apply_discount(1.0) self.assertAlmostEqual(self.order.total, 0) def test_apply_discount_zero(self): self.order.apply_discount(0.0) self.assertAlmostEqual(self.order.total, 100) def test_apply_discount_out_of_range_raises(self): with self.assertRaises(ValueError): self.order.apply_discount(1.5) with self.assertRaises(ValueError): self.order.apply_discount(-0.1) if __name__ == "__main__": unittest.main()这套用例覆盖了几个关键维度:初始状态是否正确、正常添加商品是否累计总价、非法参数是否抛错、折扣的边界情况(0%、100%、超范围)。尤其那个test_apply_discount_max,把折扣拉到1.0,总价直接归零,这种边界用例往往最容易暴露精度和逻辑问题。
3.3 用verbose模式观察测试执行顺序
当你用命令行运行时,推荐加上-v参数。比如:
python -m unittest test_order -v输出会是这样的:
test_apply_discount_max (test_order.TestOrderDiscount) ... ok test_apply_discount_normal (test_order.TestOrderDiscount) ... ok test_apply_discount_out_of_range_raises (test_order.TestOrderDiscount) ... ok test_apply_discount_zero (test_order.TestOrderDiscount) ... ok test_add_multiple_items (test_order.TestOrderAddItem) ... ok test_add_negative_price_raises (test_order.TestOrderAddItem) ... ok test_add_single_item (test_order.TestOrderAddItem) ... ok test_add_zero_quantity_raises (test_order.TestOrderAddItem) ... ok test_initial_items (test_order.TestOrderInitialization) ... ok test_initial_total (test_order.TestOrderInitialization) ... ok ---------------------------------------------------------------------- Ran 10 tests in 0.001s OK注意,测试方法执行顺序并不是代码里写的顺序,而是按方法名的字母序排列的。test_apply_discount_max排在前面,因为它按字符串规则排在最前。如果用例之间有顺序依赖,千万不要指望书写顺序,而是要用TestSuite.addTest显式控制,或者干脆重新设计用例消除依赖。
3.4 覆盖率报告与持续集成接入
测试好不好,覆盖率是一个重要参考。Python的覆盖率工具是coverage.py,命令行用法很简单:
pip install coverage coverage run -m unittest discover coverage report --show-missing这里coverage run会记录代码执行情况,--show-missing会列出哪些行没被执行到,方便你针对性地补用例。对于一个订单类,我一般要求核心逻辑行覆盖率达到90%以上,而不是只看总百分比。
再进一步,把测试接入CI(持续集成)流程。在GitLab CI或GitHub Actions里,Python项目的第一个Job通常就是“安装依赖并跑测试”。比如一个简单的GitHub Actions配置:
# .github/workflows/test.yml 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: | python -m pip install --upgrade pip pip install -r requirements.txt - run: python -m unittest discover -v接入CI之后,代码一旦推送,服务器自动帮你跑全量测试,任何失败都会在合并请求上标红。这比本地跑一次可靠得多,因为CI环境是干净的,能暴露出很多“在我电脑上是好的”问题。
4. 进阶玩法:mock、subTest与跳过策略
4.1 mock:把网络请求和外部服务挡在测试之外
真实的业务代码难免要发HTTP请求、操作数据库、调用消息队列。单元测试的一个重要原则是:不要真的去发起这些外部交互。一方面,测试速度会慢得离谱;另一方面,外部服务不稳定,会导致测试随机失败。unittest内置的unittest.mock模块就是为了解决这个问题。
我用一个场景来演示:订单支付后要调用支付网关。下面的代码如果有网络请求,测试时就会真的把钱打出去(如果有测试环境的凑合用,但生产环境你是绝对不想在测试时碰到的)。
# payment.py import requests def process_payment(order, card_number): response = requests.post( "https://api.payment-gateway.com/charge", json={"amount": order.total, "card": card_number}, timeout=5, ) if response.status_code != 200: raise ConnectionError("支付网关返回错误") return response.json().get("txn_id")测试时用mock.patch把requests.post替换掉,完全模拟成功和失败两种情况:
import unittest from unittest import mock from order import Order from payment import process_payment class TestPaymentProcessing(unittest.TestCase): def setUp(self): self.order = Order() self.order.add_item("苹果", 100) @mock.patch("payment.requests.post") def test_payment_success(self, mock_post): mock_post.return_value.status_code = 200 mock_post.return_value.json.return_value = {"txn_id": "TXN123"} txn_id = process_payment(self.order, "1234567890") self.assertEqual(txn_id, "TXN123") mock_post.assert_called_once() url = mock_post.call_args[0][0] self.assertEqual(url, "https://api.payment-gateway.com/charge") @mock.patch("payment.requests.post") def test_payment_failure_raises(self, mock_post): mock_post.return_value.status_code = 500 with self.assertRaises(ConnectionError): process_payment(self.order, "1234567890") if __name__ == "__main__": unittest.main()这里有几个经验值得说清楚。第一,mock.patch的路径是“被测试代码引用模块的路径”,不是“mock对象所在模块的路径”。本例中,payment.py里写的是requests.post,所以patch的是payment.requests.post。很多新手会写成mock.patch("requests.post"),那样是没用的,因为被测代码模块已经拿到了requests的引用,patch了顶层模块不会影响它。
第二,mock对象的return_value和side_effect是两个常用控制点。return_value设定正常返回值;side_effect可以设为异常,也可以设为一个可调用对象来动态决定返回值。
mock_post.side_effect = ConnectionError("超时")4.2 subTest:一个用例覆盖多组输入
有时候一个测试方法里,逻辑本身是同一个,只是输入数据有多组。如果直接写多行断言,第一行失败时后续代码直接中断,后面的场景等于没测。subTest就是为这种情况设计的,它把一次测试拆成多个子测试,每个子测试独立执行、独立报告。
import unittest class TestDiscountCalculation(unittest.TestCase): def test_multiple_discount_rates(self): test_cases = [ (0.1, 90), (0.25, 75), (0.5, 50), (1.0, 0), ] for rate, expected_total in test_cases: with self.subTest(rate=rate): order = Order() order.add_item("苹果", 100) order.apply_discount(rate) self.assertAlmostEqual(order.total, expected_total)运行结果里,每个子测试都会有独立的定位。如果其中一组数据失败,你会在输出里看到类似subTest (rate=0.5)的信息,直接定位到是哪组数据出的问题。这个特性在参数化测试需求很常见,比循环加断言的方式可维护性强得多。
4.3 skip机制:处理待办和平台差异
你的代码库不见得所有功能都写完了,有些测试可能还没实现,有些测试只在特定平台才能跑。unittest提供了三种跳过装饰器:
@unittest.skip(reason):无条件跳过。@unittest.skipIf(condition, reason):条件满足时跳过。@unittest.skipUnless(condition, reason):条件不满足时跳过。
还有一个@unittest.expectedFailure,用来标记“这个用例目前预期会失败,如果它竟然通过了,反而要提示”。
import sys import unittest @unittest.skip("功能还没实现,先跳过") def test_not_implemented_yet(self): ... @unittest.skipIf(sys.platform == "win32", "Windows平台暂不支持此特性") def test_linux_only(self): ... @unittest.expectedFailure def test_known_bug(self): # 已知bug,断言会失败,但允许失败 self.assertEqual(1 / 0, 1)跳过功能用好了,能让测试套件始终保持“可运行”状态。项目里如果有暂时无法修复的已知问题,与其删掉用例,不如用expectedFailure标记,至少后续修复时它能自动从“预期失败”变成“意外通过”,提示你该把标记清掉了。
对于跳过,我的建议是:reason参数必须写清楚原因,最好带上issue编号或者修复计划,否则过几个月你自己看到跳过都不知道当初为什么跳。
5. 常见问题与排查技巧实录
5.1 测试相互污染:共享状态的坑
有一次我在测试一个带缓存的工具类时,写了两个测试方法,一个给缓存塞了数据,另一个去读缓存。单独跑第二个测试时一切正常,但两个连在一起跑,第二个就失败了。原因是我在第一个测试里没有清掉缓存,第二个测试读到了残留数据。
这是单元测试最常见的坑之一:测试之间共享了状态。解决办法有两个方向。第一,尽量在每个测试的setUp里创建全新的被测对象,确保测试开始时的状态是确定的。第二,如果在tearDown里清理,一定要把共享资源恢复原样,比如删除临时文件、重置全局缓存、回滚数据库事务。
还有一个进阶坑:patch没有清理干净。用mock.patch时,如果测试方法里patch了某个模块函数,但测试因为异常提前退出了,patch可能没来得及恢复,导致后续测试被污染。新版Python里patch支持作为上下文管理器或装饰器,这两种方式都能保证最终恢复,尽量用它们,少用裸的start/stop。
5.2 偶发性失败:那些“跑几次挂几次”的用例
我最头疼的测试类型是那种“刚跑还好好的,再跑一次就红了”。这类偶发失败(flaky test)通常有几个来源。
第一个来源是外部依赖。测试里如果真的发生了网络请求,或者读取了动态变化的数据,结果天然不稳定。解决方式是mock掉所有外部依赖,让测试只关注纯逻辑。
第二个来源是时间依赖。例如测试里用了datetime.now()来判断“是否过期”,如果刚好在午夜边界运行,日期计算就会出现波动。解决方式是使用依赖注入,让被测代码接收一个时钟对象,或者用freezegun这样的库固定时间。
第三个来源是并发。如果测试代码里开了多线程或者多进程,执行先后顺序不确定,用例结果就会飘。要么把并发逻辑抽象成一个确定性函数来测,要么用mock替换并发调度部分,只验证核心行为。
排查这类问题时,建议先看失败的测试是不是都集中访问了同一个资源,再去看是不是有时间相关的逻辑,最后看有没有未清理的mock。
5.3 测试耗时过高:如何瘦身提速
测试跑得慢,开发人员就会懒得跑,最后沦为“反正上了CI再说”,等于失去了本地快速反馈的价值。提速的重点在于减少“真实工作”。真实IO是最消耗时间的元凶——文件读写、数据库操作、网络请求,这些都要尽量在单元测试层杜绝。
具体操作上,我有几个习惯:
- 把所有网络请求和数据库操作用mock替换。这会带来数量级的提速。
- 如果一定要操作数据库,优先用内存数据库或事务回滚方式,不要每次跑完都重新建表。
- 把不相关的测试类拆到不同的测试目录,运行时可以只跑当前改动模块对应的测试,而不是一上来就全量。
- 使用
setUpClass代替setUp做重量级初始化,但前提是能保证类内共享状态不被误改。
一个的实测数据:我维护过一个模块,刚开始测试要3分多钟,因为每个用例都真实连了一次数据库。改成内存数据库加事务回滚后,跑完全套只要4秒。差距就是这么明显。
5.4 常见问题速查表
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
python -m unittest找不到测试 | 模块命名不符合test*.py,或目录下没有__init__.py导致发现机制不递归 | 改文件名,或使用discover并指定start_dir |
| 测试方法没被运行 | 方法名没有以test_开头,或方法不在继承TestCase的类里 | 按命名约定改名 |
| 断言失败但看不出实际值 | 使用了assertTrue而不是assertEqual | 换用更具体的断言方法 |
| 浮点数比较总失败 | 直接用assertEqual比较浮点结果 | 使用assertAlmostEqual |
| 测试之间互相影响 | 共享对象或全局状态被修改 | 在setUp重建对象,在tearDown清理资源 |
| mock不生效 | patch路径写错了,写成了顶层库路径 | patch被测模块里引用的路径 |
| 测试随机失败 | 依赖网络、时间或并发 | mock外部依赖、固定时间、消除并发不定性 |
| 某用例本来该失败却没失败 | 没标记预期失败,或bug被修复了 | 用expectedFailure标记待修复问题 |
| 测试跑得很慢 | 真实IO操作过多 | mock网络和数据库,优先在内存操作 |
这个表其实是我自己踩坑的记录,每次遇到问题先翻一遍,能省下不少排查时间。
5.5 写在最后:把测试养成习惯
说实话,写测试这件事,最大的难点不是技术,是心态。我见过很多开发者说“这功能太简单了,不用测”“等有空了我再补”,结果一拖就是几个月,代码越改越多,越难补。我的做法是“先写测试再做实现”,也就是测试驱动开发(TDD)的思路。哪怕你不想严格按TDD来,至少在写一段逻辑的时候顺手把关键用例写了,三分钟的事,却能换来以后每次改动时的踏实感。
根据我这些年的经验,unittest的价值不在于它有多华丽,而在于它足够朴素、足够稳定。你不需要学一堆概念,不需要折腾插件,只要把TestCase、断言、setUp、mock这几样用熟,就已经能cover掉日常八成以上测试需求了。要是哪天你发现unittest的样板代码写起来有点烦,再考虑pytest也不迟——但到那时,你已经是带着清晰的测试思维去用工具,而不是被工具带着跑了。