ARTICLE DETAIL

资讯详情

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

Python unittest单元测试实战:从TestCase到Mock的工程实践

Python unittest单元测试实战:从TestCase到Mock的工程实践 1. 为什么我坚持让你把写测试变成写用例的肌肉记忆先讲一段真实经历。早几年我做一个数据处理服务上线前改了一个正则表达式自信满满地提交了代码。结果灰度了不到两小时线上日志出现大量解析失败数据直接写入脏库。那晚我坐在工位上反复看那段正则怎么都想不通——逻辑明明没有变化只是把贪婪匹配改成了非贪婪。后来排查出来罪魁祸首不是正则本身而是另一个模块里我顺手重构的一个字典初始化方式。一个小小的改动在没有测试保护的情况下直接带崩了整个流程。从那以后我对单元测试这四个字的理解彻底变了它不是KPI、不是流程负担而是你对自己代码安全的底线保障。很多Python开发者对unittest的态度很有代表性我的脚本跑一遍就完事了写测试的时间比写代码还长业务逻辑那么复杂哪能全测到。这些想法我都能理解但真到线上出问题时你会发现单元测试的价值根本不是测一遍确保能跑而是给你一张能随时回头修改代码的底气网。这篇指南会围绕Python内置的unittest框架展开从测试的基本理念、框架的核心组件、断言和Mock的使用到测试代码的组织结构、真实项目中容易踩的坑以及如何让测试成为你的开发效率放大器而不是负担。无论是刚接触Python的新手还是已经写了一阵子业务代码、想开始补测试的老手都能从中找到可以直接落地的做法。先说一个核心观念单元测试测的不是代码的语法而是代码的行为契约。你要验证的是当输入X时代码是否符合预期地输出了Y并且在异常场景下是否有正确的兜底。unittest帮我们把这件事做成了标准化的流程剩下的就是我们怎么把用例写得不别扭、跑得不痛苦、维护得不抓狂。2. unittest不是锦上添花而是代码安全网的基座2.1 测试金字塔与单元测试的边界聊unittest之前得先搞清楚它在整个测试体系里的位置。经典的测试金字塔把自动化测试分成三层最底层的单元测试Unit Test数量最多、速度最快、粒度最细往上依次是服务层测试Service/Integration Test和端到端测试E2E Test。单元测试针对的是一个函数、一个类、一个方法级别的行为验证不依赖网络、数据库、文件系统这些外部环境。这一层的特点是跑得快成百上千个用例几秒钟跑完、好定位失败时直接指向具体函数、容易隔离依赖外部服务的地方用Mock替代。unittest就是Python标准库里负责这一层工作的主力框架它最大的优势是零依赖、随解释器发布、在任何Python环境里都能直接跑。很多人有一个误解觉得单元测试必须覆盖所有代码分支覆盖率不到90%就不好意思说自己写了测试。实际上单元测试的核心价值在于保护那些容易出错的、经常改动的、有复杂逻辑的关键代码。它更像一个看门人守在你每一次重构和改动的前面而不是一个全知全能的事后审查员。2.2 unittest的核心组件TestCase、TestSuite、TestRunner、TestLoaderunittest框架的设计模仿了JUnit核心组件可以拆成四块TestCase测试用例的基类你写的所有测试类都要继承它每个以test_开头的方法就是一个独立的测试用例。TestSuite测试套件用来聚合多个测试用例或测试类可以控制测试执行的顺序和范围。TestRunner测试运行器负责执行测试并输出结果最常用的是unittest.TextTestRunner在命令行里显示点号和F/E。TestLoader测试加载器负责从模块、类或目录中发现并加载测试用例和unittest discover命令配合使用。实际编码时绝大多数人打交道最多的就是TestCase和TestLoader。TestSuite和TestRunner通常由测试框架在后台自动处理除非你要做自定义报告输出或者批量管理测试用例否则不需要主动创建。下面是一个最简单的unittest案例先感受一下整体结构import unittest def add(a: int, b: int) - int: 一个非常简单的加法函数用来演示测试用例的结构 return a b class TestAddFunction(unittest.TestCase): def test_add_two_positive_numbers(self): # 测试两个正整数相加 self.assertEqual(add(1, 2), 3) def test_add_positive_and_negative(self): # 测试正数和负数相加 self.assertEqual(add(5, -3), 2) def test_add_zero_to_number(self): # 测试加零的情况 self.assertEqual(add(7, 0), 7) if __name__ __main__: unittest.main()这个例子里TestAddFunction继承自unittest.TestCase里面三个方法都以test_开头它们会被unittest自动识别并执行。unittest.main()是命令行入口当你在终端里运行这个脚本时它会自动加载当前模块里所有继承TestCase的测试类并执行。你可能会问为什么不直接写几个assert然后运行因为unittest做的不只是执行断言它还会收集每个用例的执行结果、记录成功和失败、追踪错误堆栈、统计耗时并提供一套完整的断言比较机制和测试隔离机制。测试用例之间互相独立、失败信息清晰可追溯这些才是框架真正的价值。3. setUp与tearDown测试夹具的正确打开方式3.1 为什么需要测试夹具写测试时你会发现很多用例在开始前都需要准备一些共同的数据比如初始化一个对象、创建一个临时文件、连接一次数据库虽然单元测试里不推荐真连数据库。如果每个测试方法里都重复写一遍初始化代码不仅冗余还会让用例变得难以维护。unittest提供了setUp和tearDown这两个钩子方法来解决这个问题。setUp()每个测试方法执行之前都会调用一次。tearDown()每个测试方法执行之后都会调用一次。类级别的对应方法是setUpClass()和tearDownClass()整个测试类执行前后各调用一次需要用classmethod装饰器修饰。它们之间的关系可以这样理解setUp和tearDown是围绕单个测试方法的前后置逻辑适合做轻量级的准备和清理setUpClass和tearDownClass是整个测试类级别的前后置逻辑适合做重量级的资源准备比如启动一次数据库连接池、加载一次模型文件。3.2 一个带setUp和tearDown的完整示例假设我们要测试一个用户管理类这个类负责把用户信息保存到一个临时文件里。每个测试方法运行时都需要一个干净的临时文件和独立的用户对象import os import tempfile import unittest class UserManager: 一个简单的用户管理器支持保存用户信息和读取用户数量 def __init__(self, file_path: str): self.file_path file_path def save_user(self, username: str) - None: with open(self.file_path, a, encodingutf-8) as f: f.write(username \n) def count_users(self) - int: with open(self.file_path, r, encodingutf-8) as f: lines f.readlines() return len([line for line in lines if line.strip()]) class TestUserManager(unittest.TestCase): def setUp(self): # 每个测试方法运行前创建一个临时文件和UserManager实例 self.temp_dir tempfile.TemporaryDirectory() self.file_path os.path.join(self.temp_dir.name, users.txt) self.manager UserManager(self.file_path) def tearDown(self): # 每个测试方法运行后清理临时目录 self.temp_dir.cleanup() def test_save_user_creates_file(self): self.manager.save_user(alice) self.assertTrue(os.path.exists(self.file_path)) def test_count_users_after_saving(self): self.manager.save_user(alice) self.manager.save_user(bob) self.assertEqual(self.manager.count_users(), 2) def test_count_users_with_empty_file(self): self.assertEqual(self.manager.count_users(), 0) if __name__ __main__: unittest.main()这段代码里setUp在每个测试方法执行前都会清理出全新的环境tearDown则确保临时文件不会残留在磁盘上。这样做最大的好处是测试方法之间完全隔离每次运行结果都一样不会因为你先跑了一个用例再跑另一个用例而产生不同的结果。3.3 使用setUp和tearDown最容易犯的三个错误第一个错误是在setUp里做重量级操作。比如你真的去连一个数据库、调用一次外部API、加载一个500MB的模型文件。每个用例执行前都重复做一次测试套件的耗时会在你毫无感知的情况下膨胀到不可接受。解决方法是把重量级操作放到setUpClass里最多在整个测试类生命周期里执行一次。第二个错误是以为setUp一定会被调用、tearDown一定会被清理。实际上如果setUp在执行过程中抛出异常tearDown不会被调用测试会被标记为错误。因此setUp里的资源创建逻辑要尽量简单并且做好异常兜底。第三个错误是混淆了setUpClass和setUp的使用场景。setUpClass里创建的实例变量需要通过cls.xxx来访问不能在实例方法里直接用self.xxx。这个细节很容易踩坑我见过不少人在setUpClass里写了self.file_path然后测试方法里报AttributeError排查了半天才明白是类级别和实例级别的变量作用域搞混了。4. 断言方法、fail与自定义断言让失败信息真正帮你定位问题4.1 常见断言方法的适用场景对比unittest最让人舒服的地方之一就是内置了一整套语义化非常明确的断言方法。很多人以为assertTrue加一个表达式就能走遍天下其实选对断言方法失败时看到的错误信息会清晰得多排查效率也高得多。断言方法用途失败信息示例assertEqual(a, b)判断a和b是否相等1 ! 2assertNotEqual(a, b)判断a和b是否不等1 1assertTrue(x)判断x是否为TrueFalse is not trueassertFalse(x)判断x是否为FalseTrue is not falseassertIs(a, b)判断a和b是否为同一对象1 is not 2assertIsNone(x)判断x是否为None1 is not NoneassertIn(item, container)判断item是否在container中a not found in [b, c]assertNotIn(item, container)判断item是否不在container中a unexpectedly found in [a, b]assertRaises(Exception, func, *args)断言调用func时会抛出指定异常Exception not raisedassertAlmostEqual(a, b, places7)判断浮点数在指定小数位上是否相等0.1 ! 0.2浮点数比较是单元测试里很经典的一个坑。直接assertEqual(0.1 0.2, 0.3)大概率会失败因为浮点数的二进制表示存在精度问题。unittest提供了assertAlmostEqual专门用于浮点数比较你也可以指定places参数来控制在某位小数上比较精度或者使用delta参数来判断两个数的差的绝对值是否小于某个阈值。4.2 assertRaises的正确写法两种风格assertRaises用于断言代码会抛出指定的异常这在你测试异常分支时几乎是必须的。它有两种写法——上下文管理器和回调风格。比较推荐的是上下文管理器风格因为它的表达更清晰还能同时检查异常对象的内容import unittest class TestExceptionAssertions(unittest.TestCase): def test_divide_by_zero_raises(self): with self.assertRaises(ZeroDivisionError): # 故意执行一个会抛出ZeroDivisionError的操作 result 10 / 0 def test_custom_exception_message(self): def validate_age(age: int) - None: if age 0: raise ValueError(age cannot be negative) with self.assertRaises(ValueError) as context: validate_age(-1) # 检查异常信息是否包含预期内容 self.assertIn(cannot be negative, str(context.exception)) if __name__ __main__: unittest.main()注意第一种写法里result这个变量其实是不存在的因为10 / 0在赋值给result之前就已经抛异常了。有些初学者会写result 10 / 0然后在下文里使用result结果发现代码根本走不到。原因就是异常在表达式计算阶段就被抛出赋值操作根本不会执行。这个细节不是大问题但理解了会让你对异常机制的认识更清晰。4.3 自定义断言当内置断言不够用的时候业务逻辑越来越复杂时内置的断言方法可能无法表达你要验证的行为。比如说你要判断一个列表是不是严格递增的、一个字典的某个嵌套字段是否包含特定前缀。这时你可以自定义断言方法。自定义断言方法的本质也是扩展现有的断言只是把重复的比较逻辑封装起来方便多个测试方法复用import unittest def is_strictly_increasing(lst) - bool: return all(prev curr for prev, curr in zip(lst, lst[1:])) class CustomAssertionsMixin: def assertStrictlyIncreasing(self, lst): if not is_strictly_increasing(lst): self.fail(f列表 {lst} 不是严格递增的) class TestCustomAssertions(CustomAssertionsMixin, unittest.TestCase): def test_value_sequence(self): values [1, 2, 3, 5, 8] self.assertStrictlyIncreasing(values) def test_value_sequence_should_fail(self): values [1, 3, 2] # 下面的断言会失败因为我们传入的列表并非严格递增 with self.assertRaises(AssertionError): self.assertStrictlyIncreasing(values) if __name__ __main__: unittest.main()这里关键点在于self.fail()。当你自定义的断言发现条件不满足时调用self.fail()会立刻终止当前测试方法并抛出一个AssertionError测试结果会标记为失败。有人说这不就是写个assert吗其实不同点在于self.fail()的输出会进入unittest的结果收集体系和框架自带的断言一样统一展示在测试报告中。5. Mock与patch把外部依赖从你的单元测试里拆干净5.1 为什么要Mock——单元测试的隔离性单元测试最核心的追求是只测当前代码不测外部环境。如果你的代码里有一个函数需要调用第三方API、读取数据库、访问另一个微服务的接口直接跑测试时要么依赖网络环境要么依赖测试库里的数据。这些依赖引入的不确定性会让你的测试结果变得不可复现——今天网络通就全绿明天网络抖动就一片红。Mock模拟对象就是用来解决这个问题的。它的思路很简单在测试环境中用一个假的对象替换掉真实的外部依赖这个假对象可以被你预设返回值、预设抛异常、记录调用次数和参数。unittest内置了unittest.mock模块其中最重要的两个东西是Mock类和patch函数。Mock类用来创建模拟对象patch用来在测试期间替换目标位置的属性测试结束后自动还原。5.2 各种场景下Mock的使用示例先看一个最常见的场景被测函数内部调用了外部服务的客户端。import unittest from unittest.mock import Mock, patch def send_notification(notifier, user_email: str, message: str) - bool: 调用通知服务发送消息返回是否成功 return notifier.send(user_email, message) class TestSendNotification(unittest.TestCase): def test_send_notification_success(self): # 创建一个Mock对象预设其send方法返回True mock_notifier Mock() mock_notifier.send.return_value True result send_notification(mock_notifier, aliceexample.com, hello) self.assertTrue(result) # 验证send方法确实被调用了一次且参数正确 mock_notifier.send.assert_called_once_with(aliceexample.com, hello) def test_send_notification_failure(self): # 让send方法抛异常验证业务层是否正确捕获 mock_notifier Mock() mock_notifier.send.side_effect ConnectionError(network down) # 假设业务函数应该捕获异常并返回False with self.assertRaises(ConnectionError): send_notification(mock_notifier, aliceexample.com, hello) if __name__ __main__: unittest.main()这个例子里有两个关键操作mock_notifier.send.return_value True设置了模拟方法的返回值。mock_notifier.send.side_effect设置了一个副作用它可以是异常对象、可迭代对象或者可调用对象。当side_effect是异常时每次调用该方法都会抛出这个异常当side_effect是一个列表时连续调用会依次返回列表里的各个值这个特性在模拟分页接口或者多次调用返回不同结果的场景里非常有用。再来看patch的典型用法。patch可以以装饰器、上下文管理器的方式使用还可以用字符串指定要替换的目标路径。它的路径参数是模块路径.对象名注意要替换的是对象被使用的位置而不是对象定义的位置。这是一个非常容易踩坑的点我先在这个示例里展示正确的写法import unittest from unittest.mock import patch class PaymentService: 一个付款服务调用外部网关 def charge(self, user_id: str, amount: int): # 假装在调用外部支付网关 gateway self._get_gateway() return gateway.charge(user_id, amount) def _get_gateway(self): # 真实代码里会实例化一个第三方支付sdk客户端 raise NotImplementedError class TestPaymentService(unittest.TestCase): patch.object(PaymentService, _get_gateway) def test_charge_success(self, mock_get_gateway): mock_gateway Mock() mock_gateway.charge.return_value {status: success, transaction_id: T123} mock_get_gateway.return_value mock_gateway service PaymentService() result service.charge(user_001, 500) self.assertEqual(result[status], success) mock_get_gateway.assert_called_once() mock_gateway.charge.assert_called_once_with(user_001, 500) if __name__ __main__: unittest.main()patch.object(PaymentService, _get_gateway)这条装饰器的含义是在测试方法执行期间把PaymentService._get_gateway这个属性替换成一个Mock对象测试方法结束后自动恢复。装饰器传入的mock对象会被作为额外参数传给测试方法注意这个参数必须加而且顺序上它是最靠后的装饰器从上往下依次传参多个patch是这样如上面的示例参数会多出一个且位置在第一个参数之前。5.3 Mock的三大坑spec、patch路径和过度Mock使用Mock时会踩的最多的坑是patch路径错误。举一个最简单的例子说明# module_a.py import time def format_current_time(): return time.strftime(%Y-%m-%d %H:%M:%S)如果测试代码这样patchfrom unittest.mock import patch # 错误写法patch了time模块的strftime但format_current_time里的time.strftime是模块引用 with patch(time.strftime, return_value2025-01-01 00:00:00): from module_a import format_current_time print(format_current_time())这样的写法有时候有效有时候无效取决于time模块是不是在module_a里被直接导入的。如果你在module_a里写了import time然后调用time.strftime那么你patch的是全局time.strftime这个操作会影响所有使用time模块的代码。最好的做法是patch目标模块里被调用的位置——也就是module_a.time.strftime这样只影响被测模块的调用不影响其他模块。第二个坑是过度Mock。Mock用多了之后测试会变成测试Mock本身而不是在测试真实逻辑。一个常见场景是你mock了内部几乎所有的函数然后底层逻辑改动时测试还是一路绿灯因为Mock对象根本没跟真实代码产生交互。判断标准是如果你删除了被测函数里的关键逻辑测试仍然全部通过说明Mock的粒度太粗测试已经失去了保护作用。第三个坑是Mock对象的spec参数。Mock()不限制属性和方法的调用范围你调用一个不存在的属性它也能成功这会让拼写错误在测试阶段无法被发现。使用spec真实类名可以让Mock对象只允许访问真实类里存在的属性和方法一旦访问了不存在的属性会立刻抛出AttributeError。from unittest.mock import Mock class RealService: def process(self, data): return data # 使用spec后mock对象只允许process方法不允许调用明明不存在的方法 mock_service Mock(specRealService) # 下面这行会抛出AttributeError mock_service.nonexistent_method()6. 子测试subTest与参数化测试同样的逻辑不用重复写用例6.1 什么时候应该用subTest有时候你会遇到这样一个场景一个函数要处理多组输入每组的断言逻辑完全一样只是数据不同。最直接的做法是每个数据写一个测试方法但数据一多代码就极度冗余。另一个做法是把所有数据放到一个测试方法里循环断言但如果第一组数据失败了后面的数据就不会执行你也就看不到后续数据的问题。subTest就是解决这个两难问题的最佳工具。它允许你在一个测试方法里声明多个子测试每个子测试独立执行、独立记录结果。即使某个子测试失败其他子测试也会继续执行测试报告里会看到失败的那个子测试的具体参数。import unittest def timestamp_to_date(timestamp: int) - str: 时间戳转日期字符串的简化版本 from datetime import datetime return datetime.fromtimestamp(timestamp).strftime(%Y-%m-%d) class TestTimestampToDate(unittest.TestCase): def test_multiple_timestamps(self): test_cases [ # (时间戳, 期望的日期字符串) (0, 1970-01-01), (1672502400, 2023-01-01), (1735689600, 2025-01-01), (1767225600, 2026-01-01), ] for timestamp, expected_date in test_cases: with self.subTest(timestamptimestamp, expected_dateexpected_date): self.assertEqual(timestamp_to_date(timestamp), expected_date) if __name__ __main__: unittest.main()with self.subTest(timestamptimestamp, expected_dateexpected_date)这一行是关键。传入的timestamp和expected_date是子测试的标识参数会在子测试失败时显示在错误信息里能让你一眼看出是哪组数据出了问题。我始终觉得subTest最实用的场景不是简单参数化而是当你有一组必须全部检查、不允许中途中断的断言时。比如你要验证一个配置字典里所有配置项都有默认值且类型正确用subTest逐个检查一旦某个配置项缺失其他配置项依然会被检查到测试报告会一次性给出所有问题而不是你修完一个跑一次再发现下一个。6.2 subTest与循环断言的对比拿上面时间戳的例子来说如果不用subTest直接在一个for循环里写self.assertEqual第一组数据失败时后面三组数据就不会执行。你可能修好第一组问题重新跑第二组又失败再修再跑效率极低。subTest带来的不只是失败后继续执行还有更清晰的语义分区。每个子测试的成功或失败状态互不干扰生成HTML测试报告时还会有独立的统计信息。这一点在CI持续集成环境里尤其重要——你可以在一次构建里看到所有失败案例的全貌而不是被一个失败挡住后面的检查。6.3 需要注意的边界subTest并不是万能的。如果一组数据量大且耗时高比如几百个子测试都要调用一个耗时函数那么测试总时长会变得很可观。这时候建议做一次筛选只挑核心边界值放到subTest里其余用例放到专门的慢速测试套件。另外subTest里的断言如果失败Python 3.11及以下版本不会停止该组subTest内的后续代码3.12以上版本引入了failfast参数可以控制是否在子测试失败时立即停止当前测试方法。如果你在用老版本Python想在子测试失败后立刻跳出当前subTest循环可以在断言外手动加if判断并break。7. 测试的组织结构、discover发现机制与运行策略7.1 目录结构和命名约定怎么摆放测试文件和被测代码很多人第一个Python项目里只有一个main.py后来功能多了拆成几个模块测试还是堆在根目录下。代码少时无伤大雅但项目膨胀后混乱的测试文件位置会让unittest discover的配置变得异常痛苦。推荐的目录结构是这样的project/ ├── mypackage/ │ ├── __init__.py │ ├── user_manager.py │ └── utils.py └── tests/ ├── __init__.py ├── test_user_manager.py └── test_utils.py被测代码放在mypackage/包目录里测试代码放在顶层的tests/目录下。测试文件统一以test_开头或用_test.py结尾这是unittest默认的文件名匹配规则能保证discover自动发现。测试目录里的__init__.py不是必须的但在某些场景下加上会方便导入被测模块。特别要注意的是如果你直接在tests/目录里写from mypackage import user_manager运行测试脚本时必须把项目根目录加入sys.path否则解释器找不到mypackage。7.2 运行测试的三种方式以及discover的用法运行unittest测试我最常用的有三种方式方式一直接运行单个测试文件。这是开发阶段最省事的做法python tests/test_user_manager.py前提是测试文件底部有if __name__ __main__: unittest.main()。运行时还可以加-v参数输出更详细的信息。方式二用discover批量发现并运行所有测试python -m unittest discover -s tests -p test_*.py -v-s指定测试文件的起始目录-p指定文件名匹配模式-v显示详细输出。这个命令适合在CI里一口气跑完所有测试也适合本地提交代码前做一次全局回归。方式三指定模块路径运行特定测试类或测试方法python -m unittest tests.test_user_manager.TestUserManager.test_save_user_creates_file这个写法用来精确打击某一条用例非常高效当你只需要验证某个刚修好的bug时不需要跑完整套测试。unittest discover有一个很容易被忽略的细节如果目录中存在__init__.pydiscover会尝试按包的方式导入测试模块如果测试模块之间有相互依赖建议都用相对导入。还有一种常见问题是discover无法找到测试文件多半是被测目录的层级关系不对或者文件名没有匹配上-p参数指定的模式。7.3 测试运行速度优化与快速失败策略当测试用例积累到几千条时一次全量回归可能要跑好几分钟。这时候有几个很实用的优化思路一是按标签分组。你可以用自定义装饰器给测试方法打标签比如fastslownetwork然后在discover之外自己写一个loader来按标签筛选用例。二是快速失败。unittest本身没有全局的--failfast参数但使用python -m unittest -f可以打开快速失败模式遇到第一个失败或错误就停止整套测试。本地开发阶段开-f能帮你快速迭代CI阶段就别开了否则容易漏报一批回归。三是并行执行。unittest标准库本身不支持并行但可以用unittest-parallel这类第三方工具把多个测试模块分发到不同进程执行充分利用多核CPU。8. 真实项目中最容易踩的测试坑我最想拦你的一条8.1 坑一测试之间互相依赖顺序一变就崩这是我在评审别人的测试代码时最常看到的问题甚至可以说是资深菜鸟版的问题。表现形式是某个测试类里的测试方法A创建了数据测试方法B默认A已经跑过、数据一定存在。单独跑B通过不了但完整的测试套件跑一遍又全绿因为A先执行了。unittest默认按测试方法名称的字母序执行——是的不是按你写的先后顺序而是按test_后面内容的ASCII码排序。所以你以为先写A后写B就是先跑A再跑B实际上A和B的顺序由它们的名字决定。我在测试类内部需要共享状态时最推荐的方案是每个测试方法保持完全独立自己准备自己的数据不依赖其他方法执行的结果。如果多个测试方法确实需要一样的初始化逻辑放进setUp或setUpClass而不是放在某一个测试方法里顺带完成。如果数据准备成本很高可以使用unittest里的addClassCleanup和addModuleCleanup或者直接使用setUpClass配合tearDownClass来管理共享资源。8.2 坑二测试代码污染了生产环境的全局状态有些代码在模块级别保存了全局配置比如# config.py DEBUG True def get_config(): return {debug: DEBUG}测试里改了全局变量跑完不还原后续的测试方法甚至其他测试模块都会受到污染。Python的全局变量状态在不同测试间的漂移是单元测试不稳定的头号原因。解决思路很简单用patch来修改和自动还原千万别手动改完再手动改回去容易忘记改回。比如from unittest.mock import patch with patch(config.DEBUG, False): # 在这个上下文里config.DEBUG的值是False pass # 从这个上下文出来后config.DEBUG自动恢复8.3 坑三测试中直接调用真实网络请求或数据库这个问题常出现在集成测试和单元测试边界模糊的时候。如果你的测试方法里直接写了requests.post(...)或者mysql.connector.connect(...)那它严格来说已经不是单元测试了因为执行结果完全取决于网络和数据库的可用性、数据和状态。正确做法是把这些外部依赖抽象成接口或者客户端对象在被测代码里通过依赖注入的方式传入测试时传入Mock对象。前面讲Mock时已经展示了这个思路这里再强调一遍单元测试一旦开始依赖真实外部环境你跑的测试就已经不是单元测试而是集成测试会面临非常多不稳定因素。想跑真正的集成测试应该单独分组、单独标记不要把它混进日常回归的单元测试套件里。8.4 坑四只测快乐路径完全不管异常和边界很多测试写起来特别快乐——传的正常参数、期望的正常返回值边界值不测、异常分支不测。这种测试在代码重构时基本起不到保护作用。一个良好的单元测试用例组合应当覆盖正常输入、边界输入、异常输入、和关键状态流转正常输入典型业务数据验证主流程正确。边界输入比如列表为空、字符串长度为空、数字为0、时间戳为闰秒等极端情况。异常输入非法类型、超范围数值、空对象、None验证异常处理分支。状态变化对象执行某方法后的内部状态是否符合预期比如队列长度是否减少、缓存是否被更新。你不需要对每一个函数做这种全覆盖但业务逻辑的核心函数、公共工具函数、容易被别人反复调用的函数这三类值得多花点时间把边界条件补齐。8.5 坑五测试里的魔法数字和复制粘贴我见过一种很痛苦的维护场景测试代码里写满了assertEqual(result, 1637596800)这种魔法数字每个数字都没有注释也没人知道它代表什么业务含义。后来需求改了期望值变了整个团队花了很多时间在几百个断言里搜索这些数字逐个判断要不要改。正确的做法是把期望值用有意义的变量名或常量表达出来# 这些常量名本身就构成了测试文档 EXPECTED_FIRST_DAY_OF_2026 2026-01-01 EXPECTED_FIRST_DAY_OF_2027 2027-01-01另一个建议是不要从被测代码里抄一个表达式过来当作期望值。比如被测函数返回a b * 2测试里直接写assertEqual(func(a, b), a b * 2)这种测试没有任何意义——它只是在复制实现一旦实现写错了测试也会正确地跟着错。期望值应该是你想出来的、独立于实现的计算结果哪怕你用手算一遍也行。9. 测试覆盖率、持续集成与团队协作里的最佳实践9.1 测试覆盖率工具coverage.py的使用聊完测试代码本身的写法再说说怎么衡量测试写得到不到位。最常用的工具是coverage.py它统计测试执行过程中哪些代码行被执行过哪些没被执行过。安装和使用的步骤很简单pip install coverage coverage run -m unittest discover -s tests coverage report -mcoverage report -m会在终端输出每个文件的覆盖率百分比和未覆盖到的行号。你还可以生成HTML格式的报告coverage html浏览器打开htmlcov/index.html就能看到非常直观的文件源码覆盖情况未覆盖到的行会以红色标出。覆盖率指标有一个非常常见的认知误区覆盖率只是代码被执行的覆盖面并不等于所有行为都被验证过。一个函数可能所有行都跑到了但断言是错的、或者断言根本没有验证关键返回值那么覆盖率100%也不能说明测试有效。我在实际项目里通常这样使用覆盖率指标业务核心模块的覆盖率目标设为90%以上。外部封装层、启动脚本、代码生成器这类代码覆盖率低一点可以接受。覆盖率主要用来发现完全没测到的模块和临时拼凑的待删除代码而不是机械地为了100%而填充无意义用例。9.2 CI配置中的unittest执行在持续集成流水线里unittest的执行一般长这样python -m unittest discover -s tests -p test_*.py -vCI环境下测试失败时进程会返回非零退出码流水线自动失败。有几个细节值得注意CI里建议设置随机化的用例顺序来暴露测试代码之间的隐藏依赖。Python自身没有直接打乱unittest执行顺序的参数但可以通过自定义suite或者使用第三方插件实现。CI里的多条Python版本矩阵测试比如3.9、3.10、3.11、3.12、3.13能大幅提升兼容性信心。可以用tox或者nox搭建多版本测试环境。如果你同时使用pytest可以直接用pytest来跑unittest风格的测试因为pytest天然兼容unittest.TestCase。这是很多团队平滑迁移到pytest的路径不需要重写已有测试只改运行命令就行。9.3 测试即文档让用例成为代码行为契约的载体我越来越倾向于把测试代码当成一份可执行的文档。每一条测试方法的命名不是在表达测试一下函数是否正确而是在描述在这个条件下应该发生什么行为。对比一下会发现两种命名的信息量差距很大差test_utils、test_1、test_hello好test_save_user_creates_file、test_count_users_after_saving、test_divide_by_zero_raises好的测试命名读起来就像是评审者在问代码如果你输入是这些你能不能保证你输出是那些。当你三个月后回来看自己的代码不用读实现细节光看测试名称就能回忆起设计意图。围绕这个思路我在维护测试时还有一个习惯每次修完一个bug我都会顺手提炼出一条针对性的回归测试用例命名带上bug编号或简短描述例如test_issue_482_negative_age_rejected。这样测试随着bug修复一起增长长期下来会形成一张针对历史问题的安全网。任何一次重构如果碰坏了之前修过的问题测试都会第一时间拦住你。10. 从单元测试到测试思维我给初学者的三条行动建议如果你现在还在犹豫要不要给自己的Python项目补上单元测试我建议从这三个动作开始会比较容易进入状态。第一个动作不要一上来就追求覆盖率先给项目里最核心的、你最近改过的那几个函数补测试。选择标准是最近这个函数出过错、或者心里没底。给它写三个用例正常输入、边界输入、异常输入跑通就行。这一步的核心是让测试先跑起来让你尝到写测试和稳了这两件事之间的关联。第二个动作把测试驱动开发从口号变成小步实践。写一个稍微复杂的函数时先写一条测试再写最小实现让它通过再写一条测试再补实现如此循环。不需要每次都用TDD但在你犹豫逻辑有点绕的时候TDD会帮你把思路理清楚——因为你得先想清楚输入和输出才能写出测试。第三个动作找一个Python包或开源项目看看别人是怎么组织测试的。GitHub上有很多高质量项目它们的tests/目录本身就是最好的教材。你可以看看别人是怎么用setUp、怎么用patch、怎么命名测试方法、怎么组织测试数据然后模仿着在自己的项目里应用。这三个动作做完你大概率会感受到一件事测试代码虽然多写了一些但调试时间真的在减少。尤其是当你需要重构一段老代码时手上有一组可以信赖的测试那种随便改、跑一遍就知道有没有改坏的体验是用什么文档和review机制都替代不了的。最后再分享一个我个人的小习惯每次写完一个函数顺手写一条最小测试然后立刻运行。如果这个函数逻辑特别简单可能只需要几秒钟。但就是这每次几秒钟的积累让我的代码和测试始终保持着同步而不是等模块做完了再回头一股脑补测试。你自己试过之后就会明白这种边写边测的节奏比先写完代码再补测试最终花的时间要少得多。
返回列表