ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

单元测试实战指南:从基础原则到常见陷阱

单元测试实战指南:从基础原则到常见陷阱 我刚开始写代码那几年对单元测试的态度一直是道理我都懂但实在没时间。功能能跑、页面能点、联调能通不就行了直到有一次我在某个公共库里改了一个日期格式化的工具函数。参数从必须要传改成带默认值自测的时候所有调用方我都跑过可上线第二天下游好几个服务的数据报表错位了。原因很简单有人依赖的是旧函数在非法输入下抛异常的行为我的改动把这个行为悄悄吞掉了。那一刻我才意识到手动测试覆盖的是我记得的路径而单元测试守护的是我没想到的路径。这篇文章我不打算讲虚的。我会把单元测试这件事拆成几个能立刻上手的部分它到底在防什么、一个合格用例有哪些基本要求、怎么选框架搭环境、真实业务规则怎么拆成测试用例以及新手最容易踩的哪几个坑。适合刚接触测试、或者写了几个测试但总觉得不得要领的开发者。1. 单元测试到底在防什么——先想清楚再动手1.1 手动测试的盲区很多人抗拒单元测试根本原因是觉得我明明手动验证过了为什么还要多写一遍。但手动验证有两个天然短板第一它依赖记忆。你今天改了A函数能想起所有调用过A函数的地方吗项目小的时候可以项目到一定规模之后靠脑子是不可能维护完整调用链的。第二它依赖纪律。上线前的手工回归清单写得再漂亮赶进度的时候谁都可能跳过去。而单元测试是用代码守住的契约每次改动一跑有没有破坏别人依赖的行为机器会告诉你。那次日期函数的教训就是这么来的。我改了默认值逻辑自测时只验证了传参和不传参两种正常情况完全没想到还有调用方依赖传非法格式时应该抛异常。如果当时那个函数有单元测试把几个关键行为固化成断言我大概率在本地跑一遍就发现问题了。1.2 三类测试各自管哪一段在动手写单元测试之前先分清边界否则很容易把单元测试写成不伦不类的小集成测试。我习惯用一张表来理解它们的分工测试类型验证对象运行速度定位问题的成本典型场景单元测试单个函数/类隔离外部依赖毫秒级最低直接定位到函数价格计算、权限判断、数据格式化集成测试模块与模块的协作秒级中等数据库读写、消息队列收发端到端测试完整用户流程分钟级高链路长难排查下单支付、注册登录全流程所谓测试金字塔是让单元测试占大头集成测试次之端到端测试最少。原因很简单单元测试跑得快、写起来便宜、坏了容易定位是性价比最高的防线。端到端测试虽然最接近真实用户但每次跑都要拉起一堆环境随便一个网络抖动就红反而容易让人对测试失去信心。1.3 一个务实的目标写单元测试不是为了覆盖率数字好看也不是为了应付公司的质量考核。它真正的价值有两个一是当你重构代码时有一个不眨眼的守卫在盯着你防止改一处崩一片二是一组命名清晰的测试用例本身就是一份活文档后人接手代码时看测试就能明白这个函数承诺了什么行为。想清楚这两个目标写的时候就不会跑偏——你不会去追求覆盖到每一行而是优先覆盖那些改坏了会出事的关键行为。2. 写一个像样的用例先过好这三关2.1 Arrange-Act-Assert 三段式很多测试写出来没法看是因为缺少结构感。我见过的几乎所有好测试都遵循同一个骨架准备数据Arrange、执行被测代码Act、验证结果Assert。三个部分之间最好用空行隔开一眼就能看出这段测试要干什么。以Python里一个典型的登录校验函数为例# 被测代码判断用户名和密码是否匹配 def validate_login(user_repo, username, password): user user_repo.find_by_username(username) if user is None: return False return user.check_password(password)对应的测试def test_validate_login_returns_false_when_user_not_found(): # Arrange user_repo FakeUserRepo(users[]) # Act result validate_login(user_repo, nonexistent, any-password) # Assert assert result is False注意Arrange阶段用的是替身对象FakeUserRepo没有真的去连数据库。这是单元测试和集成测试的关键分水岭单元测试要隔离外部依赖让被测函数只面对它自己的逻辑。2.2 断言的是行为不是实现细节新手最容易犯的一个错是断言函数内部某个中间变量变成了什么或者某个内部方法被调了几次。这类断言把测试和实现细节绑死了——你明明只是想确认用户不存在时登录失败却去测user_repo.find_by_username被调用了一次。一旦有人出于性能考虑加了缓存find_by_username没被调用但返回值依然正确你的测试就红了。这个红是假的它不代表功能坏了。正确的做法是只断言对外可见的行为返回值、抛出异常、对外部依赖产生的可观察影响。测试是黑盒不是白盒。把测试当白盒写等于把函数的内部结构焊死以后每次重构都要同步改测试本末倒置。2.3 用一个句子说清什么情况下应该发生什么测试命名决定了一份测试能不能当文档用。我建议用这种句式test_what_should_happen_when_condition也就是当XXX时应该发生YYY。上面那个例子就是这个风格。命名的价值在于测试失败时你不需要打开代码就能猜到问题方向。如果一条测试叫test_validate_login它红了你还要点进去看断言猜业务含义如果叫test_validate_login_rejects_invalid_password红的那一刻你就知道是密码校验这条路径出了事。这个习惯初期会让人觉得啰嗦坚持半年之后回头看你会感谢当初多写的那几个单词。还有一个配合习惯一个测试只验证一个行为。不要在一个测试函数里连写三四个断言覆盖完全不相关的场景。否则第一个断言挂了后面的根本不执行你只能看到一半的事实排查效率反而更低。3. 从零搭好测试环境框架、目录与运行链路3.1 选框架的三个标准市面上的测试框架很多很多语言甚至自带测试库。我选框架只看三点项目里用的人多不多、断言读起来顺不顺、便不便于做数据准备和参数化。以我日常用得最多的Python为例pytest是社区事实标准断言直接用原生的assert语句失败了会打印两侧的实际值定位非常直观。Java生态里JUnit 5是主流配合AssertJ的流式断言可读性也很好。Node.js生态我见过不少团队直接用内置的node:test模块或者选择Vitest这类现代工具。选哪个不关键关键是你团队里其他人也会用否则只有你一个人能维护的测试迟早变成没人管的历史包袱。3.2 目录结构与命名约定我惯用的做法是在项目根目录建一个tests包内部结构尽量和源码目录保持镜像。比如源码里有app/services/order.py对应测试就放tests/services/test_order.py。好处有两个一是找测试快按路径就能定位二是相对导入路径清晰不容易出现测试导入不到被测模块这种莫名其妙的报错。命名上测试文件统一用test_前缀测试函数也用test_前缀。这样测试框架的自动发现机制能直接工作不需要手动配置用例集合。好的约定应该让机器跑起来省心而不是让每个新人都去研究测试配置文档。一个容易忽略的点不要把测试文件和源码混放在同一目录里。经历过一次打包发版把测试代码带上生产的尴尬之后我就老老实实分目录管理了。隔离测试代码既是为了构建产物干净也是提醒自己测试是被测代码的外部观察者不是它的一部分。3.3 让测试在一条命令里跑起来环境搭好之后第一件事不是写用例而是确保一条命令能把所有测试跑完并返回正确的退出码。以pytest为例pip install pytest pytest-cov pytest --covapp --cov-reportterm-missing跑完你会看到每个文件的覆盖率和没覆盖到的行号。覆盖率这个数字建议看但别当成KPI硬追后文我会专门说这个坑。在持续集成里我是这么接的push代码自动触发测试任务任何一条用例失败都直接阻断合并。这比任何口头上的测试很重要都管用——它把质量门槛变成了机器强制执行的关卡而不是靠人自觉。早期阶段宁可让流水线多红几次也要把红就必须处理的规矩立住不然测试很快就形同虚设。4. 拿一个真实业务场景走完整流程4.1 场景购物车结算的阶梯折扣光讲原则容易飘我拿一个具体业务来演示。假设电商项目里有个结算函数规则是这样的订单金额满500元打9折满1000元打8折如果用户是会员折后价再打95折。这个函数是典型的业务规则密集、出错影响大的代码值得用单元测试层层锁死。一个直接但错误的实现可能是把判断塞在函数里堆if。而配合测试的正确姿势是先列行为清单再写用例逼着实现变清晰。行为清单长这样未满500元不打折原价返回恰好500元打9折边界值最容易写错1000元及以上打8折会员身份在打折基础上再乘0.95金额为0或负数应该拒绝抛异常或返回错误4.2 边界值的计算与整理边界值测试是我在实战中抓bug最狠的手段。阶梯折扣这种规则bug几乎都出在临界点上——if total 500写成了if total 500500元整和499.99元就是天堂和地狱的差别。我把这个场景的测试数据整理成一张表用例名称订单金额是否会员期望结果为什么不测普通值价格不达门槛499.99否499.99验证低于阈值的路径恰好第一档500.00否450.00锁定边界第一档内700.00否630.00验证中间路径恰好第二档1000.00否800.00锁定第二档边界会员叠加500.00是427.50验证两段折扣串联写测试时这些边界用例一个都不能省。你可能会觉得499.99和500.00差不多但线上翻车恰恰都是差在这零点零一上。边界值测试是投入产出比最高的一类用例没有之一。4.3 参数化测试避免复制粘贴如果按上面的用例一个一个写函数代码会臃肿到不想维护。大多数主流测试框架都支持参数化把数据和期望值压进一个用例模板。pytest的写法是这样的import pytest from app.services.order import calc_price pytest.mark.parametrize( amount,is_member,expected, [ (499.99, False, 499.99), (500.00, False, 450.00), (700.00, False, 630.00), (1000.00, False, 800.00), (500.00, True, 427.50), ], ) def test_calc_price_apply_discount(amount, is_member, expected): assert calc_price(amount, is_member) expected参数化的好处不只是省代码更重要的是把数据变成一张可以持续追加的表格。以后改了折扣规则往列表里加一行就行测试逻辑一行不用动。我维护过的项目里这种参数化用例经常被人当作规则文档来查——某个价格规则到底是怎么算的看测试数据一目了然。4.4 第一版测试跑完后的复盘把上面的用例跑绿之后有个容易被忽略的环节重新审视被测代码本身。如果测试写起来很别扭、大量依赖mock才能跑通往往不是测试的问题而是实现函数耦合了太多职责。一个理想的被测函数应该是数据进来、结果出去像数学题一样无副作用。如果它内部既要查数据库、又要记日志、还要调外部优惠券接口那单元测试就会被迫搭一堆替身测试的隔离性大打折扣。这时候应该做的不是硬写测试而是先动手拆分函数把纯逻辑部分抽出来单独保持把外部依赖留在边界层。我先拆代码再写测试整个过程会顺畅得多。这算是单元测试带来的隐藏福利——它是衡量代码可测试性的一面镜子。5. 新手最容易踩的五个坑5.1 测试写了实现细节一重构就碎前面说过断言行为、别断言实现这里展开痛一次。我见过一个项目测试里断言了某个列表的内部排序算法调用了几次后来为了性能把排序换成了另一种实现所有测试瞬间红一片。其实功能完全没变但测试和实现深度绑定导致了一次假失败。判断测试是不是绑死了实现细节有个简单标准只改代码内部结构、不改对外行为时测试应该保持全绿。如果这时红了就是测试越界了。我定的规矩是测试里出现的内部函数名越少越好断言越接近用户能感知的结果越好。5.2 过度Mock测了个寂寞Mock技术用于隔离外部依赖比如数据库、网络服务这没问题。但有人把mock用成了万能药被测函数内部的普通工具类也mock甚至连纯函数计算都mock这就本末倒置了——你把被测逻辑本身都替换掉了测试通过只能说明mock对象的返回值符合你的预期和真实代码没有半点关系。我常用的判断标准是依赖越远、越不稳定、越慢越值得mock依赖越近、越简单、越快越应该用真实实现。比如测一个订单服务时支付网关要mock因为那是外部系统但是同样是这个服务里用到的金额格式化函数就别mock直接测真实逻辑。过度mock的测试有个典型症状断言里全是验证某某方法被调用没有一条在验证最终结果。这种测试维护成本高但几乎没有守护能力。5.3 忽视隔离测试之间互相影响单元测试最忌讳的是用例之间共享可变状态。比如某个测试往全局配置里塞了一个临时值忘了清理下一个测试读到的配置就变了表现就是测试单独跑全绿、全量跑随机红。这种flakey测试最消磨团队信心跑十次红一次时间一长大家宁可不跑。解决方案说起来简单做起来要自律每个测试自己准备自己的数据用完及时清理能用局部变量的就别动全局状态涉及时间的逻辑不要用真实时钟而是注入一个可控的时钟对象。我一般会在测试基类里统一处理清理逻辑而不是指望每个程序员每次记得手动reset。一致性要靠机制不靠自觉。5.4 只测happy path边界全靠侥幸很多新手写的测试把核心路径覆盖得漂漂亮亮正常输入、正常返回、正常结束。但线上事故往往发生在参数为空超过上限恰好卡在临界值这些边角料场景。你说他是没时间写呢其实更多是没想到。可测试的价值恰恰在于替你想到了那些没想到。我整理了一个检查单每次写完核心路径的用例后照着补齐空值、负值、超大值、临界值、重复值、权限不足、外部依赖超时、返回空集合。这套单子不是银弹但能强制自己跳出正常思维往边界和异常方向多走两步。做完这件事之前我不会认为自己真的写完了这个函数的测试。5.5 为了覆盖率数字硬凑用例我见过团队把覆盖率目标定到90%以上然后程序员为了保证数字达标给getter、setter、纯展示函数都写了无意义的测试。没错覆盖率数字是好看了但真正复杂的业务逻辑该测的地方还是漏着。覆盖率这个指标更像一个提醒这里有代码没测的路牌而不是这里必须测满的命令。我现在的做法是先保住关键业务规则和分支逻辑的覆盖用覆盖率报告找出完全没碰过的代码判断它们是不是低价值代码、值不值得测。如果一段代码只是简单的字段映射我宁可不测把时间留给更复杂的判断逻辑。记住测试的价值体现在能兜住多少真实风险而不是数字多好看。6. 让单元测试真正融入日常开发6.1 从错误最多的地方开始别一口吃成胖子如果你在一个完全没有测试的存量项目里别想着一个月内把所有模块补齐这不现实而且大概率会半途而废。我的经验是先挑三类代码下手纯逻辑的算法函数、与钱或权限相关的规则判断、以前出过线上bug的地方。把这几个地方锁住性价比最高也能最快建立测试确实有用的体感。等体感建立了你自然会想往更多模块铺。6.2 用红绿节奏倒逼代码设计写测试有两种时机先写测试再写实现TDD或者先写实现再补测试。新手我建议从后者开始但不要离实现太久——写一个函数顺手写它的测试别憋一周再来补那时候连你自己都可能忘了当时的边界条件。等熟练之后可以试试TDD先写一个会失败的红测试再用最短的代码让它变绿。这个过程会强迫你思考我这个函数到底要承诺什么行为思路会清晰很多。6.3 保持测试足够快测试跑得慢人就会懒得跑这是人性。我给自己定的红线是核心单元测试要控制在几秒内跑完。一旦发现测试慢到影响日常运行先看是不是混进了集成测试或者测试里真的起了数据库、连了外部服务。该拆就拆该把慢测试挪到单独的标识里就挪。测试只有足够快才能成为你每次保存代码之后的例行动作而不是一周才想起来跑一次的仪式。让测试融入日常节奏比任何响亮的口号都管用。最后再分享一个小观察写单元测试这件事真正难的不是语法、不是框架而是心态的转变——从证明我写的代码能跑变成让代码在将来也一直能按承诺地跑。迈过这个坎之后测试就不再是负担而是你重构时敢下手的底气。遇到那种删一段代码担心出bug、保留又觉得多余的时刻你就会明白我为什么对单元测试这么较真了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进