
很多 Python 开发者都经历过这样一条学习曲线先学会写函数和类再学会 if/else 和循环接着开始处理文件、网络请求、数据清洗最后才在某次代码评审里被人问了一句“你的测试呢”。我第一次听到这句话的时候人有点懵——项目能跑、功能正常、日志也打出来了测试到底测什么后来我悟了。测试不是把代码跑一遍就完事它的核心目的用一句话就能概括验证代码逻辑是否符合预期。而 Python 内置的 unittest 框架干的就是这件事。这篇文章不打算重复教科书里的定义而是从我用 unittest 从入门到真正依赖它的过程出发把“验证代码逻辑是否符合预期”这句话拆开揉碎看看每个组件到底在帮你做什么以及哪些坑是文档上没写透的。如果你正处于“刚学会 Python、想给代码加一层保障但不知道从哪下手”的阶段或者已经在写测试、但对 unittest 的一些进阶用法只听说过没用过这篇内容应该能给你一份可以直接照着做的路线图。1. 先搞明白一件事测试的价值是“可验证”不是“可运行”1.1 没有断言的测试和没写没有区别如果你去翻 unittest 的官方文档第一眼看到的就是 TestCase 类和一堆 assert 方法。但很多人最初写测试时最容易犯的一个误区是以为“把函数跑一遍、没有报错”就算测试通过。import unittest def add(a, b): return a b class TestAdd(unittest.TestCase): def test_positive(self): add(1, 2) # 跑完了没报错 if __name__ __main__: unittest.main()这个测试能通过结果全是 OK。但它什么都没验证。就算add返回的是 10086这个测试也照样绿。测试之所以叫测试关键在于断言——你明确写出“我期望的结果是什么”然后让框架去对账。我自己刚入行时在这个问题上栽过跟头写了一个工具函数测试方法的判断条件是if result is not None:等于把断言写成了“只要返回的不是 None 就通过”。后来这个函数在某些参数下返回了错误值测试依然稳稳通过。那次之后我才明白测试的质量取决于断言的精度而不是执行的次数。1.2 断言方法选型表与失败信息unittest 的断言方法看起来很琐碎但每一条都对应一种具体的验证场景。以下是常用的断言方法及其适用场景断言方法验证内容常见错误用法assertEqual(a, b)a b手动if a ! b: raiseassertTrue(x)x为真assertEqual(x, True)assertFalse(x)x为假assertEqual(x, False)assertIsNone(x)x is NoneassertEqual(x, None)assertIn(item, container)item in container自己遍历容器再判断assertNotIn(item, container)item not in container同上assertRaises(Exc, func, *args)是否抛出指定异常用 try/except 包起来手动标记失败assertAlmostEqual(a, b)浮点数近似相等直接assertEqual比较浮点数assertIsInstance(x, cls)类型检查type(x) clsassertRegex(text, pattern)正则匹配自己re.search之后再判断选对断言方法不只是为了语义好看更重要的是失败时的报错信息。举例assertEqual(hello, hello )失败时输出会明确告诉你两边哪里不一致。assertTrue(hello hello )失败时只给你一个干巴巴的False is not true你还要自己回头猜。所以我的经验是能用具体断言就不用笼统的布尔断言。你写的是测试也是在写给别人看的预期文档。错误信息越清晰排查成本就越低。2. TestCase 是测试组织的核心但组织方式决定维护成本2.1 一个被反复修改的测试类应该怎么拆TestCase 是 unittest 的基础容器你继承它、写测试方法然后运行器会帮你把所有test开头的方法找出来执行。它们之间越独立你后续维护就越省事。import unittest def str_to_list(text): return text.split(,) class TestStrToList(unittest.TestCase): def test_normal_input(self): self.assertEqual(str_to_list(a,b,c), [a, b, c]) def test_empty_string(self): self.assertEqual(str_to_list(), []) def test_whitespace(self): self.assertEqual(str_to_list( a , b ), [ a , b ]) def test_single_item(self): self.assertEqual(str_to_list(one), [one])有人会问同样的函数写四个测试方法是不是太多了不多。这四个方法分别代表了四种输入边界任何一个出了问题你都能直接通过方法名定位到具体行为。如果把四个场景塞进一个方法里第一段断言失败后面的代码就不执行了你等于永远不知道后面的场景是不是也坏了。拆分的标准不是“函数个数”而是行为场景的个数。一个行为多输入多输出就应该拆成多个测试方法。2.2 测试方法的独立性是底线TestCase 的另一个特性是每个测试方法执行前都会构造一个新的类实例。这意味着你在一个测试里设置的实例属性不会带进另一个测试。这正是 unittest 推荐的做法——每一个 test 方法都是全新的起点。但注意实例属性独立不等于万事大吉。如果你在模块顶层定义了一个可变对象然后在测试里改它那这个改动会跨测试存在# 不要这么做 shared_cache [] class TestCache(unittest.TestCase): def test_append(self): shared_cache.append(1) self.assertEqual(len(shared_cache), 1) def test_again(self): # 这里 shared_cache 已经有一个元素了 self.assertEqual(len(shared_cache), 0) # 失败这种测试的失败非常难查因为问题不在被测代码而在测试代码自己污染了环境。解决办法很简单测试里需要的数据就在测试内部创建绝不用模块级可变对象共享。3. setUp、tearDown 的边界感夹具用好了是加速器用错了是隐性依赖3.1 三个级别夹具的区别unittest 的夹具fixture分布在三个层级上方法级、类级、模块级。它们解决的问题是一样的——在测试前后准备环境和清理环境区别在于执行频率和共享程度。方法级每个测试方法执行前都跑一次setUp执行后都跑一次tearDown。类级整个测试类只跑一次setUpClass和tearDownClass需要加classmethod装饰器。模块级模块里所有测试类共享一次setUpModule和tearDownModule。该怎么选有一个朴素的标准看你这个资源是被测试“共享读取”还是“写入修改”。如果多个测试只是读同一个配置、同一个文件内容放在类级或模块级可以省掉大量重复构造函数的时间。如果每个测试都要写数据、改状态那就必须放方法级否则上次测试的残留数据会把下一次测试的预期彻底打乱。3.2 一个真实数据库场景的夹具设计假设你测试一个操作数据库中 user 表的函数import sqlite3 import unittest class TestUserRepo(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_count_users(self): count self.conn.execute( SELECT COUNT(*) FROM users ).fetchone()[0] self.assertEqual(count, 1) def test_insert_user(self): self.conn.execute( INSERT INTO users (name) VALUES (bob) ) count self.conn.execute( SELECT COUNT(*) FROM users ).fetchone()[0] self.assertEqual(count, 2)这里用:memory:的 SQLite 数据库每个测试都拿到一个全新的、干净的库tearDown关掉它。两个测试之间没有任何残留数据谁来跑、跑多少次结果都稳定。我见过一种反面写法把连接放在类属性上所有测试共用同一个库然后某个测试删了某条数据另一个测试就失败。那已经不是测试是测试之间的“因果报应”。夹具的共享程度越低测试的稳定性越高共享程度越高你节省的是时间付出的是排查的代价。4. 跳过、预期失败与子测试把用例管理从“全跑”升级为“有策略地跑”4.1 skip 与 skipUnless 的三种写法真实项目里不是所有测试在任何环境下都能跑。比如某些测试依赖 Linux 系统命令某些依赖外部服务没启动的临时环境。这时候用跳过机制比把测试注释掉、或者让它一直红在那里专业得多。import os import platform import unittest class TestPlatformFeatures(unittest.TestCase): unittest.skipIf( os.environ.get(RUN_SLOW_TESTS) ! 1, 环境变量 RUN_SLOW_TESTS 未开启 ) def test_slow_feature(self): # 耗时的集成测试 ... unittest.skipUnless( platform.system() Linux, 仅 Linux 平台支持 ) def test_linux_only(self): ... def test_something(self): if not hasattr(self, optional_resource): self.skipTest(缺少可选资源跳过该测试) ...三种用法分别对应条件为真时跳过、条件为假时跳过以及测试执行中途发现条件不满足时主动跳过。跳过不只是为了“让测试变绿”。它的真正价值是把环境问题与逻辑问题分区。一个失败如果是环境引起的你看日志就知道是环境问题如果是逻辑引起的你会立刻定位到代码。杂糅在一起只会让你的排查路径变长。4.2 expectedFailure 的适用场景expectedFailure用于你明知道这个测试目前会失败、但有理由保留它的场景。典型的情况是你正在重构某段代码旧的测试断言的是旧行为新行为还没完全切换你不想让测试一直红破又不想删掉这个断点。class TestRefactorTransition(unittest.TestCase): unittest.expectedFailure def test_old_behavior(self): self.assertEqual(legacy_function(10), 100)如果这个测试意外通过了说明重构已完成unittest 会把它标记为 unexpected success提醒你该移除这个装饰器了。这里要提醒一句expectedFailure是过渡手段不是常态。如果一个测试被标记为 expectedFailure 直到项目维护结束都没人处理那它和一份过期注释没有区别。我在做遗留系统改造时通常会在装饰器上写一个注释标注需要移除它的版本号或里程碑。4.3 subTest 做数据驱动避免“一个失败挡住一片”数据驱动测试是 unittest 里很容易被忽略的一个功能。你想对同一个函数用多条数据做用例最直接的办法是 for 循环里写断言def parse_header(line): key, value line.split(, 1) return key.strip(), value.strip() class TestParseHeader(unittest.TestCase): def test_cases(self): for line, expected in [ (hostlocalhost, (host, localhost)), (port 8080, (port, 8080)), (timeout5, (timeout, 5)), ]: self.assertEqual(parse_header(line), expected)问题在于如果第二条数据失败了for 循环直接从异常退出第三条数据根本不会执行。你想知道三条里到底几条过、几条挂只能改数据重跑。subTest解决的就是这个问题class TestParseHeader(unittest.TestCase): def test_cases(self): cases [ (hostlocalhost, (host, localhost)), (port 8080, (port, 8080)), (timeout5, (timeout, 5)), ] for line, expected in cases: with self.subTest(lineline): self.assertEqual(parse_header(line), expected)这样跑失败时unittest 会明确告诉你失败的是哪一组line并且其他组照常执行。这个功能非常适合大量配置化输入的边界测试。5. 测试发现与命令行很多人只会 unittest.main错过了一半功能5.1 命令行的核心参数必须掌握unittest.main()写多了之后你会发现其实命令行才是更常用的入口。特别是测试文件多起来之后靠python test_xxx.py一个文件一个文件跑效率低得让人怀疑人生。# 递归发现 tests 目录下所有 test*.py 的测试 python -m unittest discover -s tests -p test_*.py -v # 指定测试模块 python -m unittest tests.test_math # 指定测试类 python -m unittest tests.test_math.TestMath # 指定测试方法 python -m unittest tests.test_math.TestMath.test_positive几个参数值得记住-s指定测试发现起始目录-p匹配测试文件的命名模式-v输出更详细的结果信息-f碰到第一个失败就停下来fail fast-k按名称过滤测试比如python -m unittest -k math_我个人的习惯是写一个 Makefile 或者 shell 脚本把常用命令固化下来。比如python -m unittest discover -s tests -p test_*.py -v这样无论谁来接手项目一行命令就能跑全量测试不用去记各种参数。5.2 推荐的目录组织方案测试文件不是随便往项目里一丢就完事。我见过把测试文件和企业级代码放在同一个目录里的项目测试文件多了之后连import路径都分不清楚。比较省心的组织方式是这样project/ ├── mypackage/ │ ├── __init__.py │ ├── utils.py │ └── service.py ├── tests/ │ ├── __init__.py │ ├── test_utils.py │ └── test_service.py └── run_tests.sh加一个tests/__init__.py可以让你用python -m unittest discover时避免命名空间冲突。同时每个测试文件对应一个被测试模块比如utils.py对应test_utils.py哪种逻辑有问题就进哪个文件排查路径清晰。5.3 和 CI 集成时的注意事项把测试挂到 CI 上时有几个细节很容易忽略固定随机种子如果测试涉及随机数要在测试启动前random.seed(...)否则偶发失败会让你痛不欲生。设置超时CI 环境的网络、磁盘和本地不一样一个本来 1 秒的测试可能变成 10 秒。要给集成测试设置明确的超时时间。输出测试报告CI 上跑完看结果时-v的输出会被日志淹没。可以考虑输出到 XML 或 HTML 报告让结果在 CI 界面上一目了然。下面是 CI 里常见的一段命令python -m unittest discover -s tests -p test_*.py -v如果哪天 CI 构建挂了不要怪 CI 平台先看是不是测试自身的随机性或外部依赖问题。这也是“测试可信”的一部分一个不可复现的失败等于一个不存在的结果。6. unittest.mock 使用经验当测试需要隔离外部依赖时这是唯一省心的内置方案6.1 patch 的基础用法与作用域单元测试追求的是隔离——只测当前函数的逻辑不关心它依赖的第三方服务返回什么。unittest 从 Python 3.3 起内置了 mock 模块更早需要单独安装最常用的就是patch装饰器。from unittest.mock import patch import unittest def fetch_user(session, user_id): resp session.get(f/users/{user_id}) return resp.json() class TestFetchUser(unittest.TestCase): patch(module_name.Session) def test_fetch_user(self, MockSession): mock_session MockSession.return_value mock_session.get.return_value.json.return_value { id: 1, name: alice, } result fetch_user(mock_session, 1) self.assertEqual(result[name], alice) mock_session.get.assert_called_once_with(/users/1)这里把module_name.Session这个类整体替换成了 Mock然后通过return_value链构造了调用链的返回值。测试中还能用assert_called_once_with验证参数是否按预期传递。有一个关键点patch 的路径要传“被测代码里引用的名字”而不是传“定义处的名字”。比如你在a.py里import b然后在a.py里调b.func()patch 就应该是patch(a.b.func)因为被测模块a里引用的就是a.b.func这个名字。传成patch(b.func)基本不会生效。6.2 side_effect 与应用场景side_effect比return_value灵活得多。它可以是一个异常、一个可调用对象甚至是一个列表。抛出异常让被测代码进入异常分支。可调用对象根据调用参数动态决定返回值。列表每次调用依次返回列表里的值。from unittest.mock import patch import unittest class TestRetry(unittest.TestCase): patch(module_name.send_request) def test_retry_then_success(self, mock_send): mock_send.side_effect [ ConnectionError(first failed), {status: 200, data: ok}, ] result do_with_retry() self.assertEqual(result[data], ok) self.assertEqual(mock_send.call_count, 2)我经常用side_effect来模拟“第一次失败、第二次成功”的网络重试场景这比造真实的外部服务再等它失败要快得多、稳定得多。mock 的另一个价值是验证交互而非只验证返回值。比如某个日志模块应该被调用且应该记录某个级别你不需要真的扣日志只需要断言 mock 是否被正确调用mock_logger.error.assert_called_once_with(connection timeout)这一点对排查“函数没报错但逻辑没跑对”的隐性 bug 尤其有用。带 mock 的测试本质上是在告诉别人这个函数不仅结果要对过程中的协作也要对。7. 和 pytest 相比unittest 输在体验赢在零依赖7.1 两个框架各有各的取舍写 Python 测试绕不开一个现实问题pytest 已经很流行为什么还要了解 unittest因为它有两个优势是很多人忽略的内置不需要额外安装标准库自带。在无法随便装第三方包的离线环境、受限服务器、或者刚初始化好的 Docker 镜像里unittest 是唯一肯定可用的测试方案。兼容大量老项目、教学场景、企业标准库代码里用的都是 unittest。你读别人代码、维护历史项目时看不懂 unittest 等于基本测试都读不懂。pytest 的优势也很明显fixture 更灵活、断言失败的信息更漂亮、插件生态丰富。但它的依赖是重了一点。如果你在一个纯净环境里只为了写一个函数的测试就去 pip install pytest我认为没有必要。同样一个测试pytest 看起来确实简洁不少# pytest 写法 def test_positive(): assert add(1, 2) 3unittest 写法稍显啰嗦但它严格内置、无需安装、无版本兼容问题。所以我的建议是把 unittest 当作基本功把 pytest 当作提升体验的工具而不是二选一。7.2 什么时候可以不用 pytest我自己维护一个小工具库时会用 pytest 跑得非常爽但在公司生产环境里有一个服务因为安全原因禁用了外部 Python 包那个服务的测试就只能用 unittest。结论是你不用给项目加没必要的依赖。如果现有项目的测试已经全部用 unittest 写好了没有特殊原因不要去“为了迁移而迁移”。测试框架只是工具测清楚代码才是一切。8. 实测中的那些坑从“测试通过”到“测试可信”的细节修正8.1 测试方法没有以 test 开头unittest 运行器默认只执行以test开头的方法。如果你写了一个verify_positive()它不是test开头unittest 会静默地跳过它测试仍然显示通过。这种“未测故通过”的情况比测试失败更可怕。解决的办法有两个文件名统一用test_*.py。在测试方法命名上用test_行为_场景_预期这种格式。比如test_add_positive_numbers_returns_sum。命名一旦写死运行器的收集逻辑和代码可读性都受益。8.2 断言浮点数时直接 assertEqual这是新手最容易踩的一个坑。浮点数在二进制中的表示并不精确0.1 0.2 0.3的结果是False。如果直接assertEqual(0.1 0.2, 0.3)测试会失败。正确做法是用assertAlmostEqualimport unittest def calc_interest(principal, rate): return principal * rate class TestCalc(unittest.TestCase): def test_interest(self): self.assertAlmostEqual(calc_interest(1000, 0.1), 100.0, places6)places参数指定小数位精度。对于金额计算这类场景也可以先在业务代码里用Decimal处理再把断言精度的问题留给业务逻辑去解决。8.3 测试顺序依赖unittest 并不保证测试方法的执行顺序。不同版本的 Python、不同的目录遍历顺序测试顺序都可能变。如果你的测试里出现了“第二个测试依赖第一个测试写入的某个全局变量”恭喜你你已经给自己埋了一个随机炸弹。正确的姿势只有一个每个测试都是完全自包含的它需要的数据只能在自己的 setUp 或测试方法内准备。8.4 测试之间通过模块状态产生因果这个坑比较隐蔽。你在测试文件顶部import了一个模块该模块有个全局变量测试 A 修改了它测试 B 再测试时发现结果和预期不符。这类问题通常表现成一回儿绿一回儿红让人抓狂。排查思路是先看被测函数内部有没有“写全局变量”的行为再看 setUp 里有没有把它重置。如果没有重置逻辑测试就不可信。对这部分我有两个处理习惯全局变量一律通过依赖注入传参而不是让函数内部直接消费模块级别的状态。测试里只要碰到“模块级对象”就在 setUp/tearDown 里进行明确的备份和恢复。8.5 滥用 mock把实现细节锁死mock 用得太多也会让测试变得脆弱。比如下面这种断言mock_service.get_data.assert_called_once_with(1, 2, 3)如果业务代码把参数从get_data(1, 2, 3)改成了get_data(1, 2, 3, timeout5)测试就会失败尽管业务逻辑本身没有变。这就是过度断言了。我现在的标准是mock 对外部依赖的返回值进行控制对关键交互进行断言但对具体传参保持宽容。与其assert_called_once_with精确匹配每一个参数不如只断言必要参数或者用mock_service.get_data.call_args去看实际调用即可。测试要验证的是行为本身不是实现细节的复制品。最后再分享一个我自己的实操习惯每个新的测试文件写完后我会先故意改坏一行被测代码跑一遍测试确认它会红灯再改回来确认它恢复绿灯。这个动作看起来笨但它能有效防止“测试写了但根本没测到东西”的情况。别觉得多余我靠这个小习惯抓住了不少注释掉的 assert、判断条件写反、以及根本没被运行器收集的测试方法。unittest 不算惊艳但它一旦在你项目里稳稳跑起来那种“改代码不再提心吊胆”的安全感是很值钱的。