ARTICLE DETAIL

资讯详情

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

Python单元测试实战:用unittest构建可靠测试体系

Python单元测试实战:用unittest构建可靠测试体系 单元测试这个东西我在Python项目里写了快十年从最开始“为了凑覆盖率而写测试”到后来“没有测试根本不敢重构”心态完全变了。很多人说unittest是Python标准库里的“老古董”不如pytest好用但恰恰是这个“老古董”零依赖、零配置、语法稳定在不少生产级项目里依然站得稳。这篇文章不是教你背API而是把我这些年用unittest踩过的坑、总结出的模式和想明白的原理一次性讲透。无论你是刚接触Python单元测试的初学者还是已经写过一段时间测试但总感觉“哪里不对劲”的开发者这篇文章应该都能给你一些实打实的启发。1. 单元测试到底在测什么先搞懂边界再动手写1.1 为什么很多团队的单元测试成了“交差代码”我在不少项目里见过这样的场景领导要求单元测试覆盖率必须达到80%于是团队开始疯狂补测试专门挑那些好测的工具函数来测真正核心的业务逻辑反而没有覆盖。测试倒是写了一大堆跑起来全绿但产品上线该出bug还是出bug。这背后的根本原因是用测试数量、覆盖率数字来考核而不是用测试质量来考核。单元测试的价值不在于“有”而在于它能不能在你改代码的时候替你拦住回归问题。要搞清楚单元测试到底该测什么先得明确它的边界。单元测试的目标是一个“单元”在Python里通常是一个函数、一个类的方法、或者一个独立模块的行为。它不测数据库连接、不测外部API调用、不测用户点按钮的流程那些是集成测试和端到端测试的活。单元测试的核心假设是被测对象周围的一切依赖都是“假的、可控的”我只看这一个函数在当前输入下输出对不对、行为是否符合预期。1.2 判定标准什么样的代码值得写测试我在项目里一直用一个判定标准一条代码逻辑如果满足下面任意一条就应该有对应的单元测试它包含复杂的条件分支比如多个if-elif、多个and/or组合这种地方最容易漏判断。它有过往的bug修复记录凡是修过一次的bug必须用测试钉死防止回归。它会被多个地方调用越是被复用的工具函数越值得测试因为一个隐性bug会扩散到整个项目。它是核心业务逻辑比如订单状态流转、金额计算、权限判断这些一旦出错直接影响线上。反过来有些代码不急着写测试纯粹的getter/setter属性Python里通常是property或普通属性没有额外逻辑、简单的类型转换一行代码、还有那些几乎不可能模拟的UI渲染代码。1.3 一个简单的例子从需求到测试用例假设你有一个函数根据用户等级和消费金额计算折扣def calculate_discount(level: str, amount: float) - float: if not isinstance(amount, (int, float)) or amount 0: raise ValueError(amount must be a non-negative number) if level vip: return amount * 0.8 elif level normal: return amount * 0.95 else: raise ValueError(funknown level: {level})这个函数有三个条件分支两个异常分支一个正常分支。按照上面的标准它完全值得写测试。测试用例我会这样列普通用户消费100期望返回95VIP用户消费100期望返回80金额为0期望返回0金额为负数期望抛出ValueError传入未知等级期望抛出ValueError金额传字符串100期望抛出ValueError这种从需求直接推导测试用例的过程就是测试设计的基本功。很多人写测试没头绪其实就是不会做这步拆解。2. unittest核心机制拆解TestCase、断言与运行器是怎么配合的2.1 TestCase子类化的背后test_前缀发现机制unittest的用法看起来简单继承unittest.TestCase写以test_开头的方法然后用unittest.main()跑起来。但你有没有想过它是怎么知道你写了哪些测试方法的这背后是Python的反射机制。unittest.TestLoader在加载测试用例时会用dir(testCaseClass)列出所有属性筛选出以test开头的方法然后逐个包装成TestCase实例。所以方法命名必须是test_xxx或者testXxx如果你写了一个check_something方法loader不会把它当成测试用例它只会是一个普通方法。这个机制有两个实际影响第一不要在测试类里写很多不以test_开头的辅助方法除非你明确知道它们不是测试第二如果一个测试方法内部调用了另一个测试方法执行顺序和调用顺序会不一样很容易让人困惑。所以辅助逻辑应该提取成普通方法或者放到setUp里不要写成test_前缀的方法。2.2 断言API盘点高频使用与容易忽略的unittest的断言方法很多但我日常高频使用的其实就这几个断言方法作用使用频率assertEqual(a, b)判断a与b相等极高assertTrue(x)/assertFalse(x)判断布尔值高assertIsNone(x)判断是否为None高assertRaises(Exc, func, *args)断言抛出指定异常高assertAlmostEqual(a, b, places7)判断浮点数近似相等中assertIn(item, container)判断成员关系中assertIsInstance(obj, cls)判断类型中assertDictEqual/dicts比较字典还输出diff中这里要特别提两个容易忽略的assertAlmostEqual用于浮点数比较在Python里0.1 0.2 0.3是False因为浮点数的二进制表示有误差。你用assertEqual(0.10.2, 0.3)必挂正确写法是assertAlmostEqual(0.10.2, 0.3, places7)。另一个是assertRaises。很多人用try/except包裹来断言异常其实assertRaises做得更干净它还可以用上下文管理器形式with self.assertRaises(ValueError): calculate_discount(unknown, 100)这种方法还能拿到异常对象进一步断言异常信息with self.assertRaises(ValueError) as ctx: calculate_discount(unknown, 100) self.assertIn(unknown level, str(ctx.exception))2.3 三种运行方式命令行、模块执行与IDE集成unittest提供了灵活的启动方式我按推荐程度排列python -m unittest discover推荐在项目根目录运行它会自动递归查找当前目录下所有test*.py文件。python -m unittest tests.test_calc指定模块路径适合只想跑某一个测试文件时用。python test_calc.py在测试文件内部用unittest.main()兜底适合单文件调试。还有更细粒度的运行方式python -m unittest tests.test_calc.CalcTest.test_discount可以精确到某个测试类里的某个方法排除问题时特别好用。IDE方面PyCharm对unittest支持很成熟右键点击测试方法可以直接运行还能显示每个测试的耗时和失败原因。Visual Studio Code配置后也可以做到类似效果。不过我的建议是在本地开发时用IDE跑测试很舒服但最终判断测试是否通过一定要以命令行的结果为准因为CI环境里没有IDE。3. 测试固件setUp/tearDown的正确打开方式3.1 fixture的执行顺序很多人没注意到的细节setUp和tearDown是unittest里最常见的测试固件机制。一个测试类的执行顺序是这样的class MyTest(unittest.TestCase): classmethod def setUpClass(cls): # 整个测试类只执行一次在所有测试方法之前 pass def setUp(self): # 每个测试方法执行前都会执行一次 pass def test_a(self): pass def test_b(self): pass def tearDown(self): # 每个测试方法执行后都会执行一次 pass classmethod def tearDownClass(cls): # 整个测试类执行完毕后执行一次 pass执行顺序是setUpClass→setUp→test_a→tearDown→setUp→test_b→tearDown→tearDownClass。这里有三个关键点第一setUp和tearDown是成对出现的如果setUp里申请了一个资源文件句柄、临时目录、mocktearDown里必须释放。Python的unittest会在每个测试方法结束后自动调用tearDown即使测试方法抛出异常也会调用所以资源释放放在tearDown是安全的。第二setUpClass是类级别的只执行一次适合创建耗时资源比如数据库连接、大型测试数据、mock服务。但它有一个隐患一旦setUpClass失败整个测试类的所有测试都会报setUpClass异常而不是单独报每个测试失败排查问题时信息量会少很多。第三setUp和tearDown的执行顺序是针对同一个测试类的如果你有多个测试类unittest默认按照类名排序执行类与类之间没有固定的fixture共享关系。3.2 每个测试必须独立同一份数据不同测试互不干扰单元测试的一个核心原则是测试之间不能有依赖关系。也就是说test_b不依赖test_a的执行状态。这等于你写测试时默认每个测试都是从一个干净的状态开始。实际项目中最典型的问题就是共享可变状态。比如你在setUpClass里建了一个临时目录往里写了一个配置文件但test_a把配置文件改了test_b再读的时候读到的是修改后的内容。这种情况在多次运行时结果可能不一样有时候过了有时候挂非常棘手。我的经验是能放到setUp里的就不要放到setUpClass。setUpClass只放那些“创建成本高且不会在测试中被修改”的资源。如果你不确定某个资源在测试中会不会被修改最稳妥的是在setUp里每个测试前都重建一份。我还遇到过一种很隐蔽的依赖测试类里类级别的属性被修改。Python的类属性是共享的如果test_a执行了self.some_flag True而这个修改同时影响了类本身test_b可能就受到影响了。这种问题很难从代码里一眼看出来所以我在项目里约定测试方法里不允许直接给self上不属于setUp的属性。3.3 资源释放与异常安全一个容易被忽视的坑tearDown里释放资源是常识但很多人忽略了tearDown本身如果抛异常会造成什么影响。根据我的实测tearDown抛出的异常会覆盖测试方法本身的测试结果导致原来应该通过的测试显示为失败。这会让排查变得非常混乱因为你看不到真正失败的测试了只看到一个tearDown里的清理异常。所以tearDown里的清理逻辑尽量不要写复杂判断。如果需要可以用try/except捕获并记录日志但不要让异常冒出去。另外一个常见场景是文件句柄的关闭setUp里打开了文件tearDown里记得close或者直接在setUp里使用with语句来管理。Python的with语句比手动close更安全因为即使中间出了异常文件也会被关闭。4. mock与依赖隔离让测试不再看外部脸色4.1 为什么要mock外部依赖让测试变得脆弱当你写的函数依赖外部HTTP接口、数据库查询、系统时间、随机数生成器时直接跑测试会面临两个问题一是外部服务可能不稳定测试时好时坏二是外部数据难以构造比如你想模拟数据库返回一条特定记录你没法控制数据库内容。unittest.mock模块正是为了解决这个问题。它能用模拟对象替换真实的依赖让你在测试中精确控制外部依赖的返回值、副作用、调用次数。我在项目里最常见的mock对象是外部API客户端requests.post、httpx.Client等数据库查询结果系统时间time.time()、datetime.now()随机数生成器第三方SDK的复杂对象支付、短信、邮件4.2 patch的用法细节路径字符串、autospec与常见错误unittest.mock.patch有几种用法我挑最关键的说。第一种是装饰器形式from unittest.mock import patch patch(module_a.requests.post) def test_api_call(mock_post): mock_post.return_value.status_code 200 result module_a.call_api() self.assertEqual(result, ok)第二种是上下文管理器形式适合在setUp/tearDown中使用with patch(module_a.requests.post) as mock_post: mock_post.return_value.status_code 200 ...第三种是patch.object针对某个对象的属性进行替换with patch.object(module_a, process_data, return_value42): ...这里有一个非常关键的约束patch的路径字符串必须是“被测代码中引用该对象的路径”而不是对象定义时的路径。什么意思呢假设yourmodule.py里写了import requests然后在函数里调用requests.post(...)你要patch的就是yourmodule.requests.post而不是requests.post。因为测试运行时你的模块里的requests已经指向真实的requests模块了patchrequests.post影响不到已经import到yourmodule里的那个引用。我用一个表格来总结代码中使用了应该patch的路径理由import requests且调用requests.postyourmodule.requests.post因为你的模块已经引用了requestsfrom requests import post且调用post(...)yourmodule.post因为你的模块里变量名是post类方法里调用self.client.getyourmodule.YourClass.client.get因为实例通过属性访问client还有autospecTrue参数。它让mock对象自动匹配真实函数的签名传错参数时就会报错。这个参数我强烈建议用因为它能防止mock对象的“过度宽松”问题。想象一下被mock掉的函数原本要求def process(a, b, c):但你的mock不校验参数测试里写process(1, 2)也能过等到真实环境运行时就会报TypeError。加了autospecTrue这类问题在测试阶段就能暴露。4.3 模拟外部HTTP接口与数据库的完整示例模拟HTTP请求我常用responses或者直接结合mock来做。这里用mock演示一个完整案例import requests import yourmodule from unittest import TestCase from unittest.mock import patch class TestApiClient(TestCase): patch(yourmodule.requests.get) def test_fetch_user_return_username(self, mock_get): mock_response mock_get.return_value mock_response.status_code 200 mock_response.json.return_value {id: 1, name: Alice} result yourmodule.fetch_user(1) self.assertEqual(result, Alice) mock_get.assert_called_once_with(https://api.example.com/users/1)下面这个示例中我用了assert_called_once_with这比只检查返回值重要得多。它断言了你的代码确实用正确的参数调用了外部接口如果参数拼错了即使返回值对了也说明逻辑有bug。模拟数据库我用freezegun库配合mock来固定时间或者直接用mock替换数据库层对象。一个典型的模式是class TestOrderService(TestCase): def test_create_order_saves_record(self): fake_db mock.Mock() fake_db.insert.return_value 1 service OrderService(fake_db) order service.create_order(user123, 199) self.assertEqual(order.id, 1) fake_db.insert.assert_called_once()这里OrderService的设计是依赖注入数据库对象通过构造函数传入而不是在内部自己创建连接。这个设计模式让测试变得极其容易——你不需要真的去连一次数据库只需要传一个mock对象进去。4.4 过度mock的反模式测试变成“自说自话”mock虽好用但用多了也会出问题。过度mock最典型的症状是测试里所有依赖都被mock了被测函数内部调用的每行代码都在被mock这时候测试跑起来全绿但真实环境完全不是这个行为。我见过最极端的例子是一个测试函数mock了被测函数内部的6个方法然后断言这些方法被调用了。这个测试的价值几乎为零——它只是在验证“mock对象被调用了”而不是验证“代码逻辑正确”。我的经验法则是mock的粒度应该控制在“外部依赖”这一层也就是被测函数真正接触的外面世界。函数内部的私有方法、计算逻辑不应该去mock应该让它们真实执行。这样才能测到真实的内部逻辑。如果你发现mock的层数太多那往往意味着被测函数做了太多事情应该拆分成更小的函数。5. 参数化测试与子测试用数据驱动消灭重复代码5.1 subTestunittest自带的轻量参数化方案当一组测试用例只有输入和期望输出不同、测试逻辑完全一样时最笨的写法是复制粘贴一堆测试方法。这种做法不好一是代码冗余二是如果第一个用例失败了后续用例可能不会执行到你无法一次看到所有失败用例。unittest提供了subTest来处理这种场景class TestDiscount(TestCase): def test_discount_with_multiple_inputs(self): cases [ (vip, 100, 80), (normal, 100, 95), (vip, 0, 0), ] for level, amount, expected in cases: with self.subTest(levellevel, amountamount): result calculate_discount(level, amount) self.assertEqual(result, expected)这里with self.subTest(levellevel, amountamount):会创建一个子测试。当一个子测试失败时它不会中断整个测试方法而是继续执行后续的子测试最后统一输出失败结果并且在失败信息中带上你传入的参数方便定位。有人问过resubTest和循环里直接assert的区别答案是直接assert时第一个失败的用例会让整个test方法中断后面的用例根本不会执行你看不到全貌而subTest会执行完所有用例把所有失败都报告出来。这对排查多次失败场景非常有用。5.2 第三方参数化库对比什么时候选ddt什么时候用subTest就够了之前有个同事问过我为什么不用parameterized或ddt这类库我的回答是subTest已经能覆盖80%场景。唯一用第三方库的动力是测试报告更美观、参数列表更清晰但代码里加一堆装饰器反而不好读。如果你确实需要更复杂的参数化能力我的推荐是parameterized库它的API更简洁from parameterized import parameterized class TestDiscount(TestCase): parameterized.expand([ (vip_positive, vip, 100, 80), (normal_positive, normal, 100, 95), (zero_amount, vip, 0, 0), ]) def test_discount(self, name, level, amount, expected): result calculate_discount(level, amount) self.assertEqual(result, expected)而ddt是更老的库语法稍微繁琐一些但如果项目里已经在用ddt也可以继续用。需要提醒的是第三方参数化库生成的测试报告可能和unittest本身的报告格式融合得不够好在CI日志里定位失败用例时推荐优先用subTest因为它的输出格式和unittest无缝集成。5.3 测试数据管理写测试用例的输入和期望值有哪些讲究参数化测试解决了“逻辑相同、数据不同”的重复问题但测试数据本身也需要管理。我在实际项目中的做法是测试数据尽量内联在测试函数旁边用元组或字典表示让阅读测试的人一眼能看到输入和期望输出。如果数据量大比如超过10组单独放在一个模块级的TEST_CASES列表里并在每个用例上标注业务含义。期望值永远要手写不要用函数计算出来再和自己比。不然有可能实现和测试一起错测试形同虚设。我还见过一个蠢做法从线上数据库导出真实数据当测试数据还带上了用户邮箱、手机号等敏感信息。测试环境应该一律用脱敏的伪造数据否则一旦测试报告泄露出去会带来合规风险。6. 覆盖率与质量门禁数字怎么用才不骗人6.1 coverage.py的基础使用统计什么、怎么统计覆盖率是测试领域绕不开的指标。Python里最常用的是coverage.py它可以跟unittest配合coverage run -m unittest discover coverage report -m coverage htmlcoverage run执行测试并收集代码覆盖数据coverage report在终端输出每个文件的覆盖率coverage html生成网页版报告可以点进每个源文件查看哪一行没被覆盖。这里的一个基本知识点是覆盖率统计有“行覆盖率”和“分支覆盖率”两种口径。行覆盖率是代码行被执行的百分比分支覆盖率是if/else、try/except等分支路径被执行的百分比。默认情况下coverage只统计行覆盖率需要加--branch参数才会统计分支覆盖率。我强烈建议在团队项目里开启分支覆盖率因为只算行覆盖率很容易“骗人”。一个函数有6行代码5行被跑过行覆盖率是83%但那个关键的if-else分支的else没走真正的业务逻辑可能完全没有被验证到。6.2 覆盖率数字的误区80%未必比60%好覆盖率越高越好这个说法看似正确实则幼稚。两类问题让高覆盖率变得没有意义一是不加断言的测试。有些代码被测试执行到了但测试里没有任何断言这行代码即使跑过一次也不代表它的行为被验证过。覆盖率统计不会分辨“执行过”和“被验证过”的区别。所以我见过有些项目覆盖率90%但改一个业务逻辑测试全绿线上照样崩。二是“为覆盖率而写测试”。为了凑覆盖率把一些简单的赋值语句、打印语句、初始化代码也翻来覆去地测。这些测试执行了代码但没有任何实际价值。所以我一直主张覆盖率只是体检报告里的一项指标代表“代码被跑过的范围”不代表“代码被验证的质量”。它应该结合代码审查、测试断言数量一起看。硬性规定覆盖率必须达到某个阈值比如80%往往会让团队去钻空子。6.3 把覆盖率接入CI配置一个可以守住的质量门禁虽然覆盖率数字不能迷信但它作为质量门禁还是有必要的。我的做法是在CI里跑一遍测试然后指定一个覆盖率阈值低于阈值就构建失败。这样至少能保证核心代码有被测试触达。一个最小可行的coverage配置是放在setup.cfg或.coveragerc里[run] branch True source yourpackage [report] exclude_lines if __name__ .__main__.: if TYPE_CHECKING:然后在CI流程中coverage run -m unittest discover tests/ coverage report --fail-under80--fail-under80表示如果总覆盖率低于80%命令就返回非零退出码CI就会标记失败。这里有个经验阈值不要一开始就定高。从覆盖率为0的项目直接要求80%会导致团队写一堆水分很大的测试反而不如从60%开始逐步提升。我见过最健康的项目覆盖率大概在75%-85%之间剩下的15%-25%可能是异常处理分支、UI渲染代码、或者第三方接口的胶水代码硬冲高反而会拖慢迭代速度。7. 实战排雷unittest项目里最常见的几个隐形坑7.1 测试顺序依赖为什么测试“时好时坏”在unittest中测试方法的默认执行顺序是按名称的字典序排列的test_a会排在test_b前面。这就引发了一个经典问题如果test_a里创建了某个临时文件而test_b依赖这个文件存在那么test_a执行完清掉文件后test_b就会失败。但如果你单独跑test_b它又是过了的。这类问题我称之为“测试顺序依赖”。它之所以隐蔽是因为在本地全量跑测试时能过但CI并行跑测试时挂了。或者反过来本地能过CI挂了查半天发现是环境状态不同导致的。解决办法就是前面第3节强调的每个测试都从干净状态开始测试之间不共享可变状态。如果你发现必须共享某个数据那也应该在setUpClass里创建而不是在某个测试方法里创建。还有一种做法是让测试执行顺序随机化强制你发现依赖——但是注意unittest没有内置这个功能可以用第三方插件unittest-random或者自定义加载器实现。7.2 时间和随机数带来的不稳定测试是不是“运气好才过”另一个常见的不稳定来源是时间。假设你的函数里有这样的逻辑def is_expired(created_at): return datetime.now() - created_at timedelta(days30)如果你直接测它结果取决于测试运行时的真实时间时间隔得久了测试结果就变了。这就是“测试随时间漂移”的经典问题。解决办法有两种一是依赖注入把now作为参数传入def is_expired(created_at, nowNone): now now or datetime.now() ...二是用mock替换datetime.now()。我推荐freezegun库它写起来非常直观from freezegun import freeze_time freeze_time(2025-06-01 12:00:00) class TestExpired(TestCase): def test_is_expired_false(self): created_at datetime(2025, 5, 20) self.assertFalse(is_expired(created_at)) def test_is_expired_true(self): created_at datetime(2025, 4, 20) self.assertTrue(is_expired(created_at))随机数的问题类似。如果被测逻辑依赖random.randint测试之前先set seed保证随机序列可预测import random def test_random_based_logic(self): random.seed(42) result yourmodule.random_based_function() ...7.3 全局状态污染环境变量、模块级缓存、单例对象这是最容易让人抓狂的问题之一。之前我曾经花了一整天排查一个测试失败最后发现原因竟然是另一个测试函数修改了环境变量os.environ[TZ]导致后续测试里的时间解析全乱了。环境变量是典型的全局状态任何测试都可以改它且修改后影响所有后续测试。我的建议是被测代码里尽量少依赖环境变量如果非要依赖测试中修改环境变量时一定要用try/finally还原或者用mock的patch.dict来修改这样测试结束后会自动恢复with patch.dict(os.environ, {API_KEY: test-key}): result call_api()模块级缓存也是类似的。一个函数里有cache {}这种模块级字典测试A往里面塞了数据测试B可能就命中缓存了。这种问题建议被测代码提供清理缓存的接口或者测试里显式清理。单例模式在Python里通常是指某个类只有一个实例测试中构造了第二个实例会破坏全局状态。我以前测过一个配置管理器第一次构造时从文件读取配置后面就直接用缓存结果测试B想换一份配置发现根本不起作用。解决办法是给单例类加一个reset()测试辅助方法在setUp里调用。7.4 中文输出与编码问题测试结果乱码怎么处理Python 3中字符串默认是Unicode理论上不会乱码。但我在Windows环境下遇到过测试输出乱码的情况尤其是打印repr包含中文的对象时控制台报UnicodeEncodeError。原因是控制台的编码不是UTF-8。解决方法是尽量在测试代码里避免print中文信息如果一定要输出可以先encode成UTF-8再打印。另外在项目根目录创建一个pytest.ini或使用环境变量PYTHONIOENCODINGutf-8也可以规避大部分编码问题。这里还有个细节是断言失败时unittest输出对比信息如果包含中文在某些终端上会出现截断或乱码建议统一在CI环境执行测试且CI环境配置UTF-8。8. TDD之争先写测试还是先写功能代码8.1 TDD的理想流程红-绿-重构很多团队在讨论“先写单元测试还是先写功能代码”这个话题的核心就是TDD测试驱动开发。TDD的经典流程是三步循环写一个失败的测试红先写下你期望的行为此时功能代码还不存在或者不完整测试是失败的。写最少量的代码让测试通过绿只写让这个测试通过的最简单实现不做任何多余优化。重构在测试的保护下优化代码结构、消除重复重构结束后测试仍然全绿。这个循环的威力在于它让你始终以“可验证”的方式前进。每一小步都有一个测试证明你的代码满足预期。当功能积累到一定量时这些测试就形成了一张安全网。8.2 什么时候TDD是优秀解什么时候别硬套TDD听起来很美好但在我实际经历中它并不是所有场景都适合。适合TDD的场景业务规则明确的需求比如金额计算、状态流转、权限判断。算法类代码边界条件清晰可以精确地用测试描述。缺陷修复先写一个能复现bug的测试再修复代码这是最有效的TDD实践。不适合TDD的场景探索性代码比如你在调研一个第三方库的API怎么用此时接口行为还不确定写测试会阻碍探索速度。UI原型/一次性脚本这类代码迭代速度极快且验证成本高测试收益低。性能调优这个阶段改的是底层算法测试更关心耗时和资源占用而不是行为正确性。我见过一个项目强制所有需求必须TDD结果团队成员为了“TDD”而把测试写得假大空先写了一堆没有断言的测试来“证明他们在做TDD”然后开发代码时再重写。这本末倒置了。TDD是工具不是教条。8.3 我的实践建议测试先行不一定是TDD但必须有测试我的建议很明确在大部分业务项目中先写测试确实能帮你想清楚需求边界但不必严格遵守“只写让测试通过的最少代码”这条纪律。我个人的做法是拿到一个需求先在文档或注释里列出测试用例清单比如输入正常业务数据 → 期望正确返回输入空值 → 期望返回默认值或报错输入异常值 → 期望抛出指定异常输入边界值 → 期望正确处理想清楚这些用例后开发功能代码时边实现边补测试。这样写的测试密度很高而且不会出现“为了TDD而TDD”的尴尬。另外我想特别强调一件事单元测试不是万能的。unittest测的是你代码里的单点和分支但它替代不了端到端测试和人工验收。我见过项目里单测覆盖率95%但上线前两天的联调测试中两个模块拼起来数据格式不一致导致整个流程跑不通。这种模块集成的bug单元测试是发现不了的。所以测试金字塔应该是底层大量单元测试中层适量接口测试顶层少量端到端测试。说到“agent加了约束”那个热搜词其实也是这个道理——你不能指望单靠单元测试一种手段就让系统变得可靠。单元测试、契约测试、集成测试、质量指标每一层都有自己的职责配合起来才能形成一个完整的质量保障体系。而这份质量保障体系的最底层就是你得把单元测试写好。如果在写unittest时遇到任何奇怪的失败记住先怀疑这三点测试之间是否共享了可变状态被测代码是否依赖了外部时间、随机数、环境变量mock的路径是否指向了正确的模块把这三个方向查一遍90%的诡异问题都能定位到根因。踩过的坑多了你会慢慢发现单元测试其实是在给你自己写“放心交代码”的证明。
返回列表