ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python单元测试:unittest实战指南与Mock隔离技巧

Python单元测试:unittest实战指南与Mock隔离技巧 写单元测试这件事我觉得在Python圈子里一直处于一种微妙的状态大家都知道好但真正动手的人不多。很多跑起来没问题又不涉及钱的项目代码质量靠的是手感而不是测试兜底。但一旦代码开始超过两三千行或者你开始用pytest这类框架接受别人的代码没有测试的滋味真的很难受。今天不聊虚的就聊Python自带的标准库单元测试模块unittest它到底怎么上手、能解决什么问题、以及老手在项目里是怎么真正用它兜底的。先说清楚我的立场unittest不是所有场景的最优解但它确实是Python生态里最适合作为第一套测试框架的东西。理由很简单——不用装任何额外依赖写起来是啰嗦了一点可它把测试的组织、收集、运行、断言、跳过、Mock这一整套测试基础设施都给你备齐了。你先学会用它再切换到pytest或者扩展到doctest都是一条非常平滑的路。我打算按这个思路来讲先讲测试的价值与适用范围再讲TestCase类的基本写法、运行与工程组织方式、Mock与隔离然后专门用一节讲我踩过的坑最后拿一个完整案例把知识点串起来。不管你之前有没有写过测试照着这篇文章的路径走一遍基本就能在自己的项目里独立写出能跑的、有实际意义的测试套件。1. 写单测之前先把为什么写这件事想透很多人对单元测试最大的误会是觉得它等于写一些代码来验证另一些代码。这句话没错但它没说到点子上。单元测试真正的价值不是验证而是约束。它约束的是你后续每次改动时的心理预期我改了这里到底有没有把别的地方弄坏这种安全感靠print大法和人肉回归是真的没法长期给的。1.1 哪些代码适合用unittest来测不是所有代码都值得写单测。我自己的一个经验判断是优先给下面这三类代码上测试纯函数和计算逻辑输入输出明确不依赖外部状态比如日期解析、金额计算、数据结构转换这类。这种代码最好测收益也最高。有分支条件的核心流程比如订单状态流转、用户权限判定里面的if-else多了之后人脑很难穷举所有组合但测试可以。高频修改的模块越是你经常动的地方越需要测试当哨兵。反之那些半年都不变一次的胶水代码暂时不测也不会有太大损失。你就记住一个感觉凡是改起来心虚的代码都是该写测试的代码。心虚本身就是信号。1.2 不写测试的时候代价具体体现在哪我见过太多项目死在重构这两个字上。重构之前代码能跑重构之后功能还是那几个功能但零散的问题全冒出来了——某个调用方传参顺序变了没注意到某个返回值类型从列表变成了生成器某个全局配置文件被多初始化了一次。没有测试兜底这些问题只能在线上或者验收的时候暴露那感觉可就不太舒服了。反过来如果核心逻辑都有测试重构就是一个改代码→跑测试→看红灯的快循环。红灯亮了就该庆幸还好问题是在测试里发现的而不是在用户那边发现的。1.3 和print调试、以及doctest的区别print调试的本质是观察它告诉你此刻发生了什么但不告诉你应该发生什么。doctest适合挂在文档里的简单示例但它把测试逻辑散落在docstring里没法做复杂的setUp、复杂断言和mock。unittest和pytest这类测试框架则是把预期写成了代码本身——输入、执行、断言三件事清清楚楚地放在一起这种结构本身就是一种文档。等你的测试文件多了之后你会发现自己记不住的那些业务细节反而是测试文件替你记着的。2. TestCase类从零开始写一个能跑的测试不管是什么框架测试的基本单元都是同一个套路构造输入执行被测代码断言输出。unittest把这个套路封装成了TestCase类你只需要继承它写以test_开头的方法剩下的收集和调度交给框架。2.1 最精简的测试文件长什么样先来一个最简单但五脏俱全的例子保存为test_math_utils.pyimport unittest def add(a, b): return a b def divide(a, b): if b 0: raise ValueError(除数不能为0) return a / b class TestMathUtils(unittest.TestCase): def test_add_positive_numbers(self): self.assertEqual(add(1, 2), 3) def test_add_negative_numbers(self): self.assertEqual(add(-1, -1), -2) def test_divide_normal(self): self.assertEqual(divide(10, 2), 5) def test_divide_by_zero(self): with self.assertRaises(ValueError): divide(10, 0) if __name__ __main__: unittest.main()然后在这个文件所在目录执行python -m unittest test_math_utils -v你就能看到每个用例的执行结果通过或失败一目了然。这里有个细节想提醒你执行命令用的是python -m unittest而不是python test_math_utils.py。两者的差别在于前者由unittest的测试加载器来收集用例你执行的是框架的完整入口后者则是在文件里手动调用unittest.main()写法上等价但前者在一个目录有多个测试文件时明显更方便可以python -m unittest discover批量发现。我建议从一开始就习惯用python -m unittest这种方式。2.2 断言方法知道工具箱里有什么assertEqual只是其中之一unittest自带一整套断言选对了能让报错信息友好很多。比如assertTrue / assertFalse判断真假条件适合布尔逻辑。assertIsNone / assertIsNotNone专门判断是否为None比assertEqual(x, None)语义更清晰。assertIn / assertNotIn判断成员关系测列表、集合、字典键时非常好用。assertAlmostEqual比较浮点数比如self.assertAlmostEqual(0.1 0.2, 0.3, places7)避免浮点精度问题。assertRaises捕获异常前面已经演示过了。assertIsInstance判断类型适合测试工厂函数或转换函数的返回类型。还有一类容易被忽视的assertWarns测警告、assertLogs测日志输出、assertGreater比较大小。这些在特定场景里能把测试写得非常精准。我的建议是不要死记用到的时候翻一下官方文档的assert方法列表比自己在测试里用if判断更规范。2.3 setUp与tearDown用例的前奏与收尾如果每个测试方法都需要先准备同样的数据比如初始化一个对象、连一个临时数据库、造一批测试文件——这些重复代码就该放进setUp里。它的机制是每个测试方法运行前都会执行setUp运行后如果定义了tearDown也会执行。import unittest import tempfile import os class TestFileProcessor(unittest.TestCase): def setUp(self): # 每个测试方法执行前都会新建一个临时文件目录 self.temp_dir tempfile.TemporaryDirectory() self.data_file os.path.join(self.temp_dir.name, data.txt) with open(self.data_file, w, encodingutf-8) as f: f.write(hello,world\n) def tearDown(self): # 每个测试方法执行后清掉临时目录 self.temp_dir.cleanup() def test_read_lines(self): with open(self.data_file, encodingutf-8) as f: lines f.read().strip().split(\n) self.assertEqual(len(lines), 1) self.assertEqual(lines[0], hello,world) def test_file_exists(self): self.assertTrue(os.path.exists(self.data_file))这里有一个新手特别容易踩的坑setUp和tearDown是每个测试方法运行一次而不是整个类运行一次。如果你有一批用例共用同一个很耗时的初始化逻辑比如加载一个大模型、建立数据库连接setUp会拖慢整批测试。这种场景应该用setUpClass和tearDownClass它们是类级别的钩子整个测试类只执行一次。不过要注意类级别共享状态时用例之间的隔离性会变差改共享状态时要格外小心这就是典型的用隔离换速度的tradeoff。3. 让测试跑起来、查得清命令行运行与工程组织单个文件里的测试跑通不难难的是测试文件一多整个工程的测试组织就显形了。unittest在这方面给的方案很朴素但非常实用目录约定加TestLoader自动发现。3.1 目录结构应该怎么摆以最常见的项目布局为例project_root/ ├── mypackage/ │ ├── __init__.py │ ├── calculator.py │ └── data_loader.py └── tests/ ├── __init__.py ├── test_calculator.py └── test_data_loader.py注意两个点。第一tests目录下要有__init__.py否则在顶层用discover时unittest可能因为导入路径的问题报错。第二被测模块的导入要经得起从项目根目录执行。测试文件里一般直接写from mypackage.calculator import ...所以你要在项目根目录运行测试命令而不是进到tests目录里跑。在项目根目录执行python -m unittest discover -s tests -v-s指定测试目录不写的话默认当前目录。discover会递归扫描tests下所有符合test*.py模式的文件收集所有TestCase子类然后统一运行。它的内部逻辑是用TestLoader扫描模块、提取TestCase、再组装成TestSuite——这条链值得你记住因为后续如果要做只跑失败用例按标签跑用例这类高级操作都是通过控制这个装配过程实现的。3.2 从TestSuite到自定义加载按需组装用例有些场景你不想全量跑。比如你只改了calculator模块就想快速验证它相关的用例。可以写一个专门的runner脚本import unittest from tests.test_calculator import TestCalculator if __name__ __main__: suite unittest.TestSuite() suite.addTests([ TestCalculator(test_add), TestCalculator(test_divide_by_zero), ]) runner unittest.TextTestRunner(verbosity2) runner.run(suite)TestSuite是unittest里用来组合用例的容器它可以嵌套也可以把多个TestCase类加进来。TestLoader则负责从模块或目录里找用例。这种手动组装的方式可能更接近底层逻辑适合写持续集成脚本时精确控制测试范围。3.3 skip与expectedFailure测试不是非黑即白测试用例会面临三种非正常通过的处境暂时没实现的功能、环境不支持的特性、已知但没修的bug。unittest提供了skip系列装饰器和expectedFailure专门处理这几种情况。import unittest import sys class TestPlatformFeature(unittest.TestCase): unittest.skip(Windows环境暂不支持该特性) def test_windows_specific_behavior(self): ... unittest.skipIf(sys.platform.startswith(win), 跳过Windows) def test_posix_behavior(self): ... unittest.expectedFailure def test_known_bug(self): # 已知bug当前会失败但先记在账上 self.assertEqual(1, 2) unittest.skipUnless(hasattr(sys, getallocatedblocks), 需要CPython特性) def test_memory_stats(self): ...关于skip我的看法是它是必要的沟通工具但也要有债意识。每一条skip都应该在注释或issue里说明原因和计划否则时间一长skip的用例就变成无人认领的僵尸代码了。expectedFailure则适合那种我知道它在失败、但暂时没空修、先标记下的场景至少让报告里明确区分预期失败和意外失败排障时不会把所有红叉都混为一谈。还有一个容易被忽略但关键时刻能救命的工具是subTest——它解决的是循环数据里一条断言挂了导致后续全挂的问题def test_multiple_cases(self): test_data [ (1, 2, 3), (2, 3, 5), (10, 20, 31), # 这一条会失败 (100, 200, 300), ] for a, b, expected in test_data: with self.subTest(aa, bb): self.assertEqual(add(a, b), expected)普通写法下第三条失败后整个测试方法就地退出第四条永远跑不到。用subTest的话每条数据是独立计数的子用例失败信息里会带上a、b的取值一次跑完能看到所有失败点这对数据驱动态的测试非常友好。4. 让测试真正独立隔离、Mock与外部依赖处理写测试的初衷是隔离出问题。但被测代码往往会碰到数据库、网络请求、系统时间、环境变量这些外部依赖。如果测试真的去连数据库、发HTTP请求那它就不再是单元测试了而是脆弱的集成测试——稍微一点环境差异就能让它全挂。把外部依赖替换成可控的替身正是Mock的用途。4.1 Mock对象的基本用法Python标准库的unittest.mock模块提供了Mock、MagicMock和patch。Mock的核心能力是你调用它的任何方法、访问它的任何属性它都会返回另一个Mock不会报错。这让它非常适合造一个还没实现的对象或者挡住外部调用。from unittest.mock import Mock # 创建一个mock对象 m Mock() # 调用它不会报错返回新的mock result m.any_method(1, 2, keyvalue) print(result) # Mock namemock.any_method() id... # 设置返回值 m.answer.return_value 42 print(m.answer()) # 42 # 设置副作用side_effect可以抛异常也可以根据参数返回不同值 def fake_divide(a, b): if b 0: raise ZeroDivisionError(mock的异常) return a / b m.divide.side_effect fake_divide print(m.divide(10, 2)) # 5.0MagicMock是Mock的子类额外支持魔法方法如__len__、__iter__、__getitem__需要模拟容器行为时用它更省事。4.2 patch的使用姿势三种方式实际测试里你通常不是直接new一个Mock而是用patch去替换被测代码里某个名字。记住一个原则patch要打在使用的地方而不是定义的地方。如果你的业务代码在mypackage.service.fetcher里调用了requests.get那patch的目标是mypackage.service.requests.get或者requests.get——但如果是from requests import get的导入方式就得patchmypackage.service.get。方式一装饰器from unittest.mock import patch class TestService(unittest.TestCase): patch(mypackage.service.requests.get) def test_fetch_data(self, mock_get): mock_get.return_value.status_code 200 mock_get.return_value.json.return_value {ok: True} data fetch_data() self.assertTrue(data[ok]) mock_get.assert_called_once_with(https://api.example.com/data)方式二上下文管理器class TestService(unittest.TestCase): def test_fetch_data(self): with patch(mypackage.service.requests.get) as mock_get: mock_get.return_value.status_code 200 ...方式三setUp/tearDown里用start/stopclass TestService(unittest.TestCase): def setUp(self): self.mock_get patch(mypackage.service.requests.get).start() self.mock_get.return_value.status_code 200 self.addCleanup(patch.stopall) def test_fetch_data(self): ...第三种方式适合很多测试方法都要patch同一个对象的情况。addCleanup(patch.stopall)则是为了确保即使测试失败mock也会被还原。Mock除了设置return_value之外side_effect还有两个很常用一是传异常类或异常实例让被测代码走异常分支二是传一个可迭代对象让多次调用依次返回不同值mock_get.side_effect [Mock(status_code200), Mock(status_code500)]测第一次调用成功、第二次调用失败这类重试逻辑这个写法太常用了。4.3 常见的外部依赖怎么隔离时间用patch(模块名.time.time)或者patch(module.datetime.datetime)把当前时间固定下来测试明天过期三分钟超时这类逻辑就不用心惊胆战。环境变量用patch.dict(os.environ, {API_KEY: test-key})只影响with块内部的代码。数据库单测里不该开真连接而是把repository层或cursor层mock掉。集成测试再上真库各司其职。网络请求优先mock请求客户端但如果你要测的是请求格式是否正确可以用专门录制的响应数据来验证而不是随手mock一个永远返回200的假对象。每次看到Mock能解决所有问题的说法我都想说mock是手段不是目的。它换掉的应该是你不关心的周边依赖而不是被测代码自身的逻辑——如果一次测试里mock了被测对象本身那这个测试基本等于没写。5. 实测最容易踩的八个坑这部分是我最想写的。unittest用起来门槛很低但很多坑是你在单个文件里跑demo时根本遇不到的非得等测试文件多了、项目复杂了才会撞见。我把遇到过的坑列出来每一条都附上规避方式。5.1 setUp放文件路径换个机器/CI直接挂很多人习惯在setUp里硬编码一个路径self.file_path C:\\Users\\me\\test_data.txt这换一台机器就挂。应该用tempfile、os.path.join结合项目根目录或者用pathlib.Path(__file__).parent来定位测试数据目录保证在任何环境下都能找到。5.2 测试依赖运行顺序unittest默认按字母顺序执行测试方法但这是实现细节不是规范。如果你写了test_a创建数据、test_b消费数据这种隐式依赖那未来一旦有人改了方法名、或者你加了subTest顺序变了测试就挂。用例之间必须完全独立——这正是setUp存在的意义。5.3 assertEqual比较字典时报错信息不清晰如果两个大字典不一样assertEqual的diff输出在控制台里看得很累。建议用assertDictEqual里的消息参数或者先用pprint.pformat把两个字典打出来对比。复杂数据结构可以用self.assertDictEqual它会提供更细的差异。同样列表用assertListEqual集合用assertSetEqual报错信息会友好很多。5.4 import路径问题这个坑90%的人都会遇到在tests目录里跑python跑不通回到项目根目录跑就通了——这是很多人第一次接触discover时的共同记忆。根因是Python的模块搜索路径从根目录跑时根目录在sys.path里from mypackage import ...就能成功。解决方案就是约定所有测试命令都从项目根目录执行或者把根目录加进PYTHONPATH但最好别这么做搞乱进度的包搜索会引发更隐蔽的问题。5.5 Mock的return_value和side_effect混用出错当side_effect是一个可迭代对象时它只负责按顺序返回或抛异常return_value同时设置了也不会被使用。很多人边设return_value边设side_effect最后一脸懵。记住side_effect优先它能是函数、异常、可迭代对象如果不是这三种就去用return_value。5.6 死循环一样的mock掉了一切mock得太狠测试就失真了。比如你mock了requests.get但没验证返回值处理逻辑那这个测试对接口数据结构变化完全无感。正确姿势是mock返回值要按照真实接口的结构来构造最好留一条真实请求的集成测试做补充。5.7 断言不够具体红灯变绿灯一个测试方法里只写了self.assertTrue(result is not None)这能拦住什么什么都拦不住。断言最好精确到具体的值、具体的调用参数、具体的异常类型和消息。测试的价值就藏在断言的强度里断言含糊等于测试白写。5.8 跑完测试后没用cleanup测试数据污染生产环境单元测试要干净用完的临时文件、连过的数据库测试库、写过的目录都要清理。不要觉得无所谓CI跑多了之后磁盘被测试文件占满、测试库被垃圾数据塞满都是真实发生过的惨剧。用tearDown、addCleanup或者临时目录对象自带的清理机制别偷懒。6. 完整实战结合上面的所有点写一个有嚼头的测试套件到这里前面的知识点是分散的。我用一个尽量接近真实业务场景的模块把它们织成一张网。假设我们要实现一个简单的用户服务模块它负责读取环境变量里的配置、调用一个外部API验证用户身份、然后记录到数据库这里用Python内置的sqlite3来演示不用真连外部服务。6.1 被测模块# mypackage/user_service.py import os import sqlite3 from datetime import datetime, timedelta class UserService: def __init__(self, db_path:memory:): # 使用内存数据库方便测试 self.conn sqlite3.connect(db_path) self._init_db() def _init_db(self): self.conn.execute( CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, created_at TEXT) ) def validate_token(self, token): 模拟调用外部验证服务真实项目里不写死逻辑但这里为了演示直接实现 # 这里假设有一个外部服务测试时需要mock掉真正的调用 raise NotImplementedError def save_user(self, name, token): if not self.validate_token(token): raise ValueError(token无效) created_at datetime.utcnow().isoformat() self.conn.execute( INSERT INTO users (name, created_at) VALUES (?, ?), (name, created_at), ) self.conn.commit() def get_user_count(self): row self.conn.execute(SELECT COUNT(*) FROM users).fetchone() return row[0] def delete_user(self, user_id): self.conn.execute(DELETE FROM users WHERE id ?, (user_id,)) self.conn.commit()为了方便演示validate_token的逻辑就埋了一个NotImplementedError——这在真实项目里通常意味着依赖还没就绪测试里我们就要把它mock成一个可控行为。6.2 测试套件# tests/test_user_service.py import unittest from unittest.mock import patch from datetime import datetime, timedelta from mypackage.user_service import UserService class TestUserService(unittest.TestCase): def setUp(self): # 每个用例独立的内存库真正隔离 self.service UserService(db_path:memory:) def tearDown(self): self.service.conn.close() def test_save_user_raises_with_invalid_token(self): with patch.object( self.service, validate_token, return_valueFalse ) as mock_validate: with self.assertRaises(ValueError) as ctx: self.service.save_user(张三, bad_token) self.assertEqual(str(ctx.exception), token无效) mock_validate.assert_called_once_with(bad_token) def test_save_user_success(self): with patch.object( self.service, validate_token, return_valueTrue ): self.service.save_user(张三, valid_token) self.assertEqual(self.service.get_user_count(), 1) def test_delete_user(self): with patch.object(self.service, validate_token, return_valueTrue): self.service.save_user(张三, token1) self.service.save_user(李四, token2) self.assertEqual(self.service.get_user_count(), 2) # 拿到第一条记录的id演示查询写法 row self.service.conn.execute(SELECT id FROM users WHERE name 张三).fetchone() self.service.delete_user(row[0]) self.assertEqual(self.service.get_user_count(), 1) def test_save_user_created_at_utc(self): # 假设我们需要验证created_at的格式而不是太大意的今天判断 fake_now datetime(2025, 1, 15, 12, 0, 0) with patch(mypackage.user_service.datetime) as mock_datetime: mock_datetime.utcnow.return_value fake_now # 要对datetime模块的静态方法有效需要把这个patch用好 with patch.object(self.service, validate_token, return_valueTrue): self.service.save_user(王五, token3) # 保存后再查出来检查格式 row self.service.conn.execute(SELECT created_at FROM users WHERE name 王五).fetchone() self.assertEqual(row[0], fake_now.isoformat()) def test_save_user_db_isolation(self): # 再次确认setUp每次新建内存库用例间不互相污染 self.assertEqual(self.service.get_user_count(), 0) if __name__ __main__: unittest.main()这个套件里validate_token被打上了mock数据库也隔离了时间相关的测试用了patch datetime。跑一下看看结果应该是全部通过。你把它放进自己的项目里改成自己的业务逻辑就能直接当模板用。7. 在CI里让测试真正卡住质量而不是摆设写完测试不让它在合适的时机自动运行价值就少了一半。比如很多团队都用GitHub Actions或GitLab CI每次push或者merge request的时候跑一遍测试。unittest的退出码是现成的全部通过返回0有失败或错误返回非0CI里只需要一条命令。把测试作为合并代码的前置条件这会让很多看起来能跑但经不起细看的问题在进主干之前就暴露出来。另外一个建议是在团队里配合覆盖率工具一起用比如coverage.py。它和unittest没有任何冲突只是一个旁路统计工具。别追求100%覆盖率那是数字游戏。我的经验是核心模块往70%-80%走非核心的胶水代码放过重点保证改到哪测到哪。写测试这件事最难的不是学会API而是养成每写一段逻辑就顺手写一条测试的手感。我现在如果写一个函数超过十行没有测试的话是不太敢提交的。unittest虽然啰嗦但它把整个测试基础设施都稳稳地放在标准库里你唯一需要投入的就是时间。等你身边全是跑一遍测试全绿的安稳感时就会明白这个投入非常值。
返回列表