虽然之前也写了快二十年代码了,对于很多现代软件工程的 buzz word 如敏捷,持续集成,DevOps 等都耳熟能详。但这两年在项目上碰到的各种挑战,以及跟一些开发者同僚交流下来发现,其实软件工程的很多最佳实践并没有被广泛接受和应用,在软件质量领域,我们持续受到了很大的挑战。 过去十多年,中国的互联网软件得到了蓬勃的发展,其中有两个看起来比较突出的挑战:一是如何能快速推出一个产品原型,迅速尝试一些新想法,抢占市场知名度;另一个是当产品赢得大量用户之后,随之而来对于底层基础设施的大规模计算执行,大数据量存储与处理方面的挑战。在这两个挑战下,形成了很多具有“互联网特色”的软件工程实践,例如早年很有名的 Facebook 的口号“Move fast and break things”,以及各种技术分享中最火热的一般都是基础系统架构的演进之类的话题。 这两年国内的 SaaS to B 领域逐渐开始升温,很多“传统”软件开发的挑战也随之而来,跟互联网类软件的问题还不太一样。例如很多 SaaS 软件的数据量没有那么大,对底层基础设施没有很高的要求,如果照搬“internet scale”的设计就很容易自己给自己添麻烦。商业软件的演进路线也相对比较明确,后续持续维护的时间一般也更长,所以碰到所谓 legacy system 的情况也会更常见。另外就是很多公司内部的业务需求与流程非常的复杂,由此而来对于软件“领域模型”的复杂度要求也上升了很多。 这些挑战即使是比较有经验的互联网软件开发者也不一定有积累的技能可以直接解决,也经常让我们感叹国内的软件人才为何如此稀缺。所以我们还是需要回到软件工程角度,去看看适合此类企业级软件开发的最佳实践到底是怎么样的。 现代软件工程当年在计算机硬件飞速发展时,人们就发现与之配套的软件开发的增长速度却极其有限,经常碰到无法按时交付,超出预算,品质低下,不符合需求,难以维护等等问题。著名的《人月神话》也是在那个时代撰写出版的,描述了大型软件开发项目中所遇到的困境。 为了解决这个问题,人们非常自然的一个思路就是借鉴我们其它大型工程中已经应用过的实践,最典型的就是“瀑布式开发”模式。 在这个模型中,每一个步骤有着严格的先后顺序之分,希望能严格把需求和设计确定好后再开始开发,然后测试验收后部署上线。这套方法在很多其它类型的工程里都得到了大量的应用,比如如果家里做过装修的话就会切身体验一遍这个流程。 瀑布式模型之所以有效,一个很重要的原因是它很好地解决了“实体化生产”带来的挑战。比如我们要做装修,把所有实体部件构建出来,像水管,电线,开关,地板,橱柜,家居,电器等等,需要花费非常大的代价。同理如果我们要做硬件的生产,如芯片,汽车等,批量化制造的成本也是非常高的。这些最终实体生产出来后,如果发现有问题,需要返工修改,几乎就代表了项目的失败。 但对于软件来说,其本身并不存在“实体化生产”的问题。我们知道任何一份代码写出来,做批量化的复制运行可以说是成本很低的。所以瀑布式模型后来受到很多诟病的原因也不难理解了,因为它想要解决的问题,在软件工程领域并不存在(至少不那么严重)。 所以我们在思考软件工程时,需要抵制住套用人类千年来的传统工程思维的影响,从软件本身的特性出发去寻找更加匹配的方法论。没有“实体化生产”的包袱,可以快速做修改迭代,需要服务于现实需求,但又会随着环境,技术的演变发生丰富而又难以预料的变化,是不是可以联想到一个有些类似的活动:科学研究。 跟软件一样,科学研究很多时候会在理论和设计层面进行推演,即使涉及到实验,一般也是相对来说较低成本的形式。科研的探索方向是明确的,但具体的路径和实现方式有着很大的不确定性,因此需要不断地提出假设,设计实验,进行实验,获取反馈,迭代认知,再提出新的假设这样循环往复。这与后续出现的“敏捷”开发模式有着很高的相似度。 所以当前主流的现代软件工程思想,就是把软件开发行为的本质理解为假设,行动,反馈,学习这几个步骤。大家应该也经常听到类似的说法,比如“架构是演进出来的,而不是设计出来的”。与科学研究不同的是,软件工程一般有更长的持续性,因此在设计与行动这块,会更加注重“控制软件熵(复杂度)”。这篇文章会更多从软件质量角度出发,来讨论软件工程中的反馈与学习。如果我们能做到高质量的反馈与学习,就能打造出更符合用户需求,功能完备性和质量都更好的软件产品。同时我们也会看到,反馈学习本身对于复杂度的控制也有很大的协同促进作用。 软件质量的综合指标如何评价软件的质量呢?如果我们以传统眼光来看,一件物品的质量的衡量方式可能是其广大使用者在一定时间内遇到的故障问题数量和严重程度。但对于软件来说,质量其实是一个动态的过程,因为客户的诉求和使用场景会发生变化,而软件本身也会不断升级版本来满足相应的诉求。 在查阅了一些资料后发现,业界对于软件质量的定义主要集中在四个指标上:
前两个指标主要是从稳定性的角度来看,我们发布软件的问题多不多。而后两个指标是从吞吐量角度来看,我们发布软件的速度快不快。 绝大多数软件工程师都会觉得,速度和稳定性这两点是个 trade-off。如果我希望发布更稳定,那么一般来说就需要花更长的时间做质量保障相关工作,例如各种测试验证。而如果用户急着要某个功能,那么必然会影响发布版本的质量,可能引入不少 bug。 但有意思的是,从《Accelerate[1]》这本书中我们可以看到来自 Puppet 与学术机构合作的“State of DevOps”报告中指出,具有更高发布稳定性的团队组织,往往其发布的吞吐量也会更高。而发布稳定性低的组织,可能因此引入了更多的流程,人工审核,或者引起了客户对其新版本的不信任,进一步拖慢了其发布的吞吐量。也就是说,这两者非但不是非此即彼,而是相互促进的,真正的 trade-off 是在“更好更快”与“更差更慢”这两者之间。 从逻辑上来说也好理解,如果我们想有更多的学习与认知迭代,打造出更符合用户需求的软件,更快的发现问题并修复,那么就需要有更快的发布速度获取反馈。如果我们想要有更快的发布速度,那么本身的质量也需要过硬避免复杂的流程(书中也提到,流程通常对质量没什么帮助),发现问题要尽可能提早减少返工等。而且代码设计上需要更加合理,才便于做修改与演进,后面我们也会看到代码设计与可测试性之间也有着协同促进的关系。 所以还是回到前面所说的,为了提升软件质量,还是回到反馈学习这个核心上。而这其中最有意思的,莫过于测试了。传统的看法会把测试等价于做其它实体产品的“质量检测”环节。但在软件工程中,测试不仅仅只是保障软件没有问题的一个步骤,而是我们获取反馈,学习知识最重要的一项基础手段。如果没有高质量,即时的反馈,那我们就不得不在过程中带入更多的猜测,这在复杂的软件项目上往往是很不靠谱的。 就像在科学研究中,我们会通过实验来获取理论在真实世界中的反馈一样,测试就是软件开发中获取反馈的方式。如果以玩游戏来类比,做实验有点像回合制游戏,玩家会通过缜密的思考推演,来下一步棋,然后再等待对手的反馈。而软件中的测试,则是实时化的,我们指挥兵力前进过程中,能即时地感知到周围环境的变化,对手的动作反应等。如果网络卡了,你很可能就无法发挥出很好的水平,因为反馈速度变慢了。 同理我们在 IDE 里写代码时,都是有实时的静态代码分析与提示,告诉你这里可能写的有问题。进一步,我们也需要添加相关的测试来丰富我们的“感知”,实时地了解这里的改动可能会破坏某些已有功能。顺便一提很多人推崇 讲到这里大家应该可以理解为何测试与软件开发应该是一个整体了。在自动化测试这个重要的基础手段之上,后续才演化出更多的如持续集成,持续部署,更全面的 DevOps 等实践。 为何需要研发来写测试前面我们已经提到测试与开发应该是一体的,而不应该独立开来。这个想法可能会受到很多传统工程思维或者旧行为模式习惯的挑战,例如:
综合这些点来看,要成为一名合格的现代软件工程师,自己的功能自己写测试是非常有必要的。且在绝大多数的先进公司,开源项目中,都已经成为了一种主流实践方式。 软件测试基本概念前面聊了这么多,终于要来上点干货了。以下内容很多来自于《Software Engineering at Google[2]》,《Effective Software Testing[3]》,《Unit Testing[4]》这三本书。尤其是最后一本,是我从业以来看过最好的一本关于测试的技术书籍,值得强烈推荐。 什么是好的测试写测试也是需要有很多深入思考的,并不是大家印象中的“周边工作”。相信很多人就没有深入想过,到底什么才是一个好的测试?为什么有些项目也写了很多测试,但好像经常挂掉也没人去看,维护又要花很多时间,线上的 bug 也没有多少被抓到,好像完全成了摆设? 为了找到好测试的标准,我们先来看一下测试的目标是什么。如果没有测试或者测试的质量很差,会导致随着项目的推进,开发迭代速度出现剧烈下降。这样的现象被称为软件熵,也即代表了软件的腐化。好的测试的目标是保证软件的可持续发展,在长期开发之后,依然可以持续演进。 具体来说,测试的目标包括:
如果仅仅只是增加测试用例的数量,并不能很好地实现控制软件熵的目标,同时也需要结合测试成本考虑总体的 ROI。一个好的自动化测试应该符合四条标准:
其中前两点分别对应了测试中的 false negative(测试没有报错,但其实有 bug)和 false positive(测试报错了,但其实没有 bug)这两类情况。对于成熟项目来说,因为测试的数量会非常大,重构的频率也会更高,所以控制 false positive 一般会更加重要。反复出现 false alarm,会很快让开发者丧失对测试的信任,从而失去其存在价值。 我们可以对上述四条标准打个分,而总体的测试价值是这四个得分的乘积。这意味着如果有任意一个维度得分为零,那么整体测试的价值就会迅速降到零。 是否存在完美的测试呢?其实上面提到的前三点之间,是有一些互斥性的,需要做一些权衡。举例来说:
考虑到实际中,是否重构友好往往是个非零即一的选项,所以一般无法在这一点上做妥协,我们只能在剩下的两点里做选择。例如 e2e 测试会更偏向防止代码功能失效,而典型的单元测试则更偏向于快速反馈。 测试的种类既然聊到了不同测试在测试价值上的取舍,那自然就引出了不同测试的类型的问题。很多同学都会问,到底什么才算是单元测试?是测试一个具体的函数/方法,还是测试一个具体的类?这个粒度要有多细才能算单元测试呢? 个人认为有两种定义比较好理解和执行:
这两种定义下的单元测试都有比较明确的特点,如:
在这个基础上,集成测试会引入外部的依赖进行验证。而端到端测试可以算是集成测试的最极端的情况,完全使用真实组件来完成测试,例如直接在网页端进行点击操作,验证结果。Google 提到的中型和大型测试也基本可以对应上这两类测试。 有了这三种测试的分类,就可以引出很多人都听说过的经典测试金字塔了: 由于单元测试本身专注于足够小的业务单元,执行又快,而且没有外部依赖更加稳定,因此很自然的一个想法就是在可能的情况下,都应该使用单元测试来覆盖功能验证。 例如一个系统有 3 个模块,每个模块又有 10 种不同的场景,如果我们只能通过端到端集成测试来做,需要的测试用例可能就需要 10 * 10 * 10 这个量级。而且不同的场景可能需要复杂的准备,例如有些需要保持数据库里没有数据,有些又需要预先有些数据,有些还需要特定组件抛出错误等。这些准备工作进一步加大了测试准备所需要的耗时,同时也会让这些测试之间很难共享依赖并发执行。集成测试还可能碰到环境不稳定的问题,也容易提升测试误报的可能。种种问题叠加起来,也不难理解即使集成测试会使用更多的“真实组件”,但在实践过程中仍然不是一个好的选择。 当然测试金字塔也有例外情况,例如业务逻辑越简单,则单元测试能发挥的作用就越小。或者外部依赖较少,使用真实依赖的成本较低时,也可以增加集成测试的数量。所以不同项目的测试金字塔构成比例也会因项目特性而有所不同。 什么是测试替代为了在更多的场景下能够使用单元测试来覆盖场景,我们就需要引入一些测试替代(test doubles)来帮助我们模拟外部依赖。很多书里会区分 dummy,stub,fake,mock,spy 等概念,再加上很多框架自己就叫 mockxxx,经常让人看的有些云里雾里不明所以。 Unit Testing 这本书里给出了很系统化的分类方法,主要就分成两类:
为什么 mock 需要做调用的检查,而 stub 却不需要呢?这又回到了我们之前提到过的对重构友好的重要原则:应该测试可观测的行为状态,而不是实现细节。对外发送一封邮件,这件事情最终是会被用户感受到的,所以属于“可观测的行为状态”,需要进行检查。而从数据库中获取用户信息,则不属于用户可以观测到的行为,而是实现细节。我们完全可以改成从不同的数据库中获取,或者从缓存中获取等等其他实现方式。 所以在这里,选择 mock 还是 stub 就有了个比较明确的标准:当被测组件向外调用的行为属于可观测行为状态时,才使用 mock,其它情况下使用 stub 即可。 不过问题到这里也还没结束,两本书中都提到了对于测试替代,业界还有两种不同的流派,分别是伦敦派与经典派。前面提到单元测试应该是相互隔离可以独立运行的,而这两个流派之间的主要分歧就在于对测试隔离范围的区别。 对于测试的外部依赖,我们可以分为两大类,分别是共享和私有。对于共享的依赖,如数据库,消息队列,第三方 API 需要被独立开来(测试替代),使得测试可以独立执行。这点对于两个流派来说都是一致的。 而私有依赖这部分,又可以区分为可变依赖和只读依赖。例如我当前正在测试用户购买行为,其中涉及到两个外部依赖,一个是商品,一个是库存。当进行购买时,商品只提供名称信息,是个只读对象;而库存则需要在购买成功后进行库存扣减操作,是个可变对象。这里就出现了两种流派的区别:伦敦派会替代掉私有依赖中的可变对象,而经典派则倾向于在私有依赖中都使用真实对象而不是测试替代。 在 Google 的那本书中,也提到他们对于测试替代的态度在两者之间游走。当前他们更倾向于完全不使用 mock,偶尔考虑使用 stub/fake,主要原因也是怕过多依赖 mock 会有 over specification 的问题(例如检查具体发出的邮件是什么),使得测试变得更加脆弱。可以说他们的实践是一种更加严格的经典派做法。 如何看待覆盖率前面提到测试的价值标准,很多人可能会想到代码覆盖率这个经常被提起的概念。这里的覆盖率又分成好几种,包括:
不过目前绝大多数工具提供的还是代码行覆盖,辅助会带一些条件覆盖的指示(部分条件覆盖会显示黄色)。所以我们一般讨论的也是以行覆盖率指标为主。 覆盖率本身与测试价值是一个相关性指标,我们总体的评判标准是:低的代码覆盖率,一般来说是不好的,但高的代码覆盖率并不一定表示测试是优秀的。例如极端的例子如果测试中只有逻辑执行,没有 assertion 检查,那么 100% 覆盖率也是没有意义的。同理回到前面测试价值的评判标准,如果某些逻辑简单,或者非核心业务逻辑的代码,我们可能也没有必要去追求高覆盖率。 软件测试最佳实践前面梳理了一下软件测试的基本概念,接下来我们来看下一些最佳实践,具体怎么来写高价值的自动化测试,以及测试本身对于优良架构设计的促进作用。 单元测试的构成一般来说,单元测试的构成分三个部分:
Given-When-Then 模式与上述的 AAA 模式是等价的。 一般来说,一个单测里应该只有一组 AAA 的流程,保证验证场景的聚焦与清晰。在集成测试中,可能一些环境的 arrange 开销会比较大,所以有时候会考虑将之前的 arrange/act 给后续操作复用,例如先创建用户,验证用户创建成功,然后继续验证该用户的其它操作场景。 写测试中经常碰到的一个问题是 arrange 部分比较复杂,可以考虑引入一些设计模式,例如 object mother 和 test data builder 等,抽取一些共用方法来提效。这也属于对 test infra 的投入,会提升开发者写测试的效率。单元测试一般不太需要公共的 setup 和 teardown,共享的 arrange 可能会导致测试间的高度耦合,破坏测试之间的独立性。但在集成测试中很多 setup 是可能共用的,而且集成测试一般没法并行执行,可以在集成测试的基类中定义这些可复用的 fixture。 Act 部分一般一行代码调用就足够了,如果发现这部分的代码行数比较多,需要注意是否是 public API 的设计有问题。还是以测试购买行为为例,如果在操作时需要分别调用购买操作和库存扣减操作,那么显然就破坏了封装性。如果客户端只调用了购买,而忘了调用库存扣减,就会导致状态的不一致(invariant violation)。 Assertion 部分倒没有严格要求只用一行调用来验证,因为可观测的行为状态可能是多方面的,例如返回值,对象状态,外部调用行为等。要注意我们应该对可观测行为和状态进行检查,而不是实现细节。例如我们对返回 json 结构做原封不动的检查,可能就是不太好的实践,其中可能有些信息属于实现细节,这部分做重构优化时就很容易 break 测试。 另外要注意的是当 assertion 失败时的报错信息是否友好,很多时候只看到一个 expected False actual True 这样的信息显然是很难理解和定位问题的。有一些类似 AssertJ 的库可以帮助我们提升代码本身的可读性以及报错/callstack 的友好性。 除了结构外,也需要格外注意测试代码的可读性,包括测试函数的命名应该更贴近场景描述的自然语言,而不是跟具体的类名、方法名挂钩。同时也需要考虑减少重复代码,其它变量名,方法名的可读性等,这个跟功能代码是一致的。Google 的书中提到测试代码应该逻辑简单,不要有 if,for 循环这类,甚至可以允许一定的代码重复,一切以可理解性为前提。个人同意尽量减少测试代码的圈复杂度,但还是觉得也需要考虑一下可维护性,抽取一些共用方法,或者使用 property based 测试等。除了验证功能是否正常,可读性良好的测试也可以作为一种可以执行的文档帮助新人快速上手项目。 如何系统性地写测试Effective Software Testing 中,给出了系统化的进行测试的步骤: 主要关注右边方框里的内容。首先是基于软件的功能 spec 来开发测试: 个人理解具体展开包括:
这里尤其重要的是特殊值与边界条件的分析,也是最容易出现 bug 的地方。例如判断条件是 完成 spec testing 步骤后,我们可以进一步引入 structual testing,即前面提到的代码行覆盖的分析,辅助我们去完善测试用例。 通过代码行覆盖工具(例如 IntelliJ 中自带的,或者 JaCoCo 等),可以检查我们当前的测试用例覆盖度情况。对于还没有覆盖到的部分,分析是否需要添加用例来进行覆盖。我个人的标准是对于领域核心文件,一般要求达到 100% 的覆盖率。 在高代码覆盖率的基础上,我们还可以引入 mutaition testing。这是一个很有意思的技术,通过分析代码结构,去随机对功能代码做出修改,然后再执行相关的测试用例。如果修改后的代码(称为变种人)导致测试失败了,那么说明测试对于预防代码功能失效有着比较高的覆盖率。而如果修改后的代码如果还是成功跑通了,那就有可能说明测试用例不够完善。 一般来说这三个步骤下来,我们的单元测试覆盖应该已经能达到一个及格线以上的水平了。后续像发现 bugfix 应该同步带上相关的单元测试等也是很多项目的常规做法,在此就不多做赘述。关于集成测试和端到端测试,我们放到后面再展开。 测试与代码设计的关系前面我们已经提到很多次,如果发现测试不好写,可能跟代码设计不够好有关系。还有一些常见的问题包括,我是否应该测试 private API?什么情况下我应该用单元测试来覆盖,什么时候应该写集成测试?接下来我们就来展开看下这个话题。 现代软件工程的两个重要目标是控制软件整体的复杂度和快速获取反馈适应变化。前面我们看到测试可以在获取反馈方面给我们提供巨大的帮助,而且事实上测试对于控制复杂度方面也有很大的作用。 很多同学在写测试时会碰到一个问题,被测对象的依赖非常多,需要把它构建出来就很困难。这可能是我们在设计这个类时,没有很好地遵循单一职责原则。一个类如果要做太多事情,不够内聚,那么很可能就会变成一个边界模糊的“大杂烩”,依赖了一堆其它类来完成各不相同的工作,导致系统的耦合度也过高。对于我们的核心领域模型来说,应该尽可能做清晰的职责划分,减少依赖交互方。 也有同学会说,感觉 private API 比较复杂,需要做测试,但发现很多框架不支持 private API 的测试。这也是因为测试需要验证的应该是可观测行为和状态,而 private API 一般都属于实现细节,是很容易做重构改动的,那样的话测试的维护成本就会大大提高。如果发现 private API 很复杂,或者一个 public API 里涉及了很多步骤,其实也是缺乏单一职责设计的表象。可能更好的做法是把这些 private API 拆成单独的领域模型,去协同完成某个具体场景。 另外像测试的验证比较复杂,不好写,可能也是类似的问题,没有做好职责分离和架构分层。举个极端的例子,我写了个 总结一下上面的问题,提出可测试性的几个直观要求:
这就很自然引出了很多讲架构的书中的典型分层方式,如六边形架构,clean architecture 等。其主要思想就是架构的内层是领域模型,包含所有的业务逻辑。外层是 service/controller 层,负责与外界的交互以及串联领域模型调用,尽可能不带业务逻辑。依赖关系永远是外层依赖内层,而不是反过来。典型的例子如我们不应该在领域模型里去建立数据库连接,依赖外部的 infra。直观的理解例如数组这样的领域对象,可以有访问,追加等操作,但如果它还有个方法是写入数据库,就会感觉有些“违和”了。 书中还介绍了将这种风格推向极致的一种 functional architecture,把各类 side effects,exceptions,对内外部状态的依赖,都挪到业务逻辑的“外边缘”,让领域核心成为“纯函数”,也是控制代码复杂度的一种手段。不过这样做也会带来很多额外开销,例如性能和一些实现复杂度方面。 可测试性会“强迫”我们做出更好的架构设计,反过来,我们也可以通过架构设计来思考测试的分类。从上面六边形架构的示意图中我们也可以看出来,对于 controller 层测测试来说,领域模型之间的交互就属于实现细节,是不需要进行测试的。这其实就天然区分了单元测试和集成测试所负责的范围。 具体来说,我们以复杂度、领域相关度,和协作方(依赖)数量两个维度,来看领域模型与 controller 层的区别: 这张图可以说是非常精髓了。我们前面提到的测试难写,很多都落到了职责划分,架构分层不够清晰上,所以产生了很多过于复杂的代码(右上角)。为了提升可测试性,我们应该通过重构,把复杂代码拆分成领域模型和 controller 层两部分,前者负责复杂业务逻辑,后者负责大量的编排工作。因为领域核心的依赖交互方少,所以比较好写单元测试,而且这部分测试的价值也会比较高。而 controller 层因为没有复杂业务逻辑,但交互依赖众多,所以可以通过少量的集成测试来进行覆盖。有些同学会有疑问,我要测试一个用户购买的场景,是不是同样的功能测试要在单元测试和集成测试写两遍呢?这里就给出了比较清晰的指导原则。 如果我们有比较良好的架构和职责划分,那么测试在不同层级上,是会具有这种“分形”特质的。书中还有一张图对 controller 层和领域模型层的特性给出了很形象的 code depth vs. width 说明: 当然在现实情况中要比这个理想情况复杂很多,我们通常很难把业务逻辑清晰地切割成“获取信息→执行业务逻辑→写回数据”这样的三步,很多时候会在业务过程中动态判断要读取哪些信息或者要写入哪些信息。这时候有几种选项:
这里具体的判断选择也有几个评估标准:
相比来说,性能和领域模型的可测性都比较重要。可以通过一些手段来管理 controller 的复杂度:
实在不可行的时候,再考虑把依赖注入到领域模型中。总体的原则还是尽量分离领域逻辑和编排这两种职责在整体架构的不同层去完成。 既然提到了依赖注入,也顺带一提为了保证可测试性,也会天然要求我们做依赖注入,而不是在类的内部去自己创建对象。另外像时间,随机数等依赖,也可以通过显式依赖传入,提升可控制性。 而为了在测试中能够方便地 mock/stub 各种依赖,提升测试的重构友好度,也非常建议我们应该依赖抽象接口,而不是具体的实现。这显然也是良好架构设计的一大指引原则,可以降低耦合。当然这里也不是硬性规则,需要与“过度设计”进行权衡。 TDD前面一大块阐述了测试对于代码设计的促进作用,也正是因为测试不仅帮助我们保证质量,甚至还能促进我们做出好的设计,所以自然又诞生出了测试驱动开发(TDD)的思想。这不仅仅是把测试放在前面先写这么简单,而是会整体的改变我们的思维与行动方式,带来很多优点:
当然也有一些例外情况,例如对于一次性使用的代码,不一定要写相关测试。如果对于需要写的功能已经非常熟悉,完全了解实现方法,也可以不用 TDD 的方式。 总体来说,只要有测试的需求,即使不严格遵循 TDD,我们也应该尽可能缩短实现功能与写测试中间的时间间隔。不要写了一整天的功能代码,最后再去补测试。 关于 TDD 的具体操作方式,可以参考 James Shore 的演示视频[5]。 如何写大型测试前面在架构分层对应的测试部分已经提到,对于 controller 层的测试,我们一般会通过集成测试的手段来覆盖。当然这里也有不同的选择,例如我们也可以 mock/stub 所有的外部依赖,用单元测试的方法来覆盖 controller 层。或者尽量使用真实的依赖来做集成测试。与单元测试相比,集成测试有以下特性:
前面也提到,controller 层一般是没有什么业务逻辑,但是依赖方众多,所以的确天然也更倾向于使用真实的依赖来做它的核心职责的验证。因此这里的取舍原则是,尽可能使用单元测试来覆盖各种复杂/边界场景,用集成测试来覆盖一条主要的 happy path(尽可能覆盖所有外部依赖),以及单元测试无法覆盖的边界场景。 集成测试中的外部依赖,也并不是完全不需要使用测试替代。我们可以从是否是应用自身管理的角度(典型特征就是看是否在同个代码 repo 里管理)来把它们分为两类:
集成测试的极端情况就是全部使用真实依赖的端到端测试。不过整体来说端到端测试的质量保障范围与典型的集成测试接近,因此数量会更少。Google 一书中也提到,他们的端到端大型测试,一般都是在生产环境跑的(可以结合一些灰度发布手段),用于保障部署发布本身没有问题。此外一些特定需求的测试,如性能,可扩展性,稳定性,安全测试等,也经常会使用端到端的测试方法。 在自身管理的外部依赖方面,最常见的就是应用的数据库了。如何在集成测试中维护数据库呢?
最后总结一些大型测试的最佳实践:
如何使用测试替代测试替代永远是测试实践中的一个重要主题,前面我们已经提到了一些重要原则,例如应该测试可观测的行为状态,而不是实现细节。所以只有集成测试才需要使用 mock,并且应该仅被用在 unmanaged 依赖上,其它情况使用 mock 都会让测试变得脆弱。 延续这个思路,我们在验证与 unmanaged 依赖交互时,应该尽量在系统最外层的边界去做,这样能覆盖到更多的系统内部代码。 在 mock 验证交互行为时,应该跟其它 assertion 一样,越精确越好。不光要看是否发生了调用,还要看调用的次数是否正确,以及不应该做的调用没有发生等。 放宽到测试替代整体来看,stub 使用的场景可能会更多一些,例如:
对于测试替代整体来说,我们应该仅仅替代自己“拥有”的类型,而不是直接去模拟第三方库的类型和 API。因为这些第三方库是有可能发生变化的,我们也可能会选择切换第三方的实现。所以更好的方法是先做抽象封装,让我们的应用去依赖抽象好的接口,而不是某个具体实现。 为了能在测试时灵活切换测试替代对象,需要使用依赖注入手段。这里有个选择,依赖注入是放在类构造方法里,还是放在其它方法的参数里?前者对于类本身的复杂度有所增加,但对于调用方来说会更加友好。多数情况下,我们还是选择让调用方更友好的方式。 测试质量测试的代码也是我们项目代码的一部分,因此测试本身的质量也非常重要。这里简单列举一些基本的评判原则:
一些不好的测试代码 smell 表现与“反模式”:
如何改进“遗留代码”前面聊了很多测试的最佳实践,但绝大多数时候,我们接触的都是已有项目,不一定从一开始就注重单元测试这块的建设。对于没有测试覆盖的遗留代码,我们要如何入手呢? 这部分又是一个很大的话题,甚至有专门一本经典的书《修改代码的艺术[7]》来讨论。这里我们就简单介绍一些基本步骤和方法。 首先一个选择是,我们是否可以针对遗留代码来补充测试。这里测试的目标更多是希望保证原有系统在改造过程中不发生行为的变化,所以有个做法是先调用一下系统的功能,获取到输出后,就把这个输出作为检查点放到测试里。这种测试也被称为 characterization test。往往第一个测试是最难写的,因为要涉及到很多 setup,但有了第一个测试,在此基础上添加更多的用例就会相对容易不少。 但很多时候,由于一开始代码的设计并没有从可测试性角度出发思考,所以添加测试会非常的困难,而且写出来的测试在后续也有大概率会被修改掉。为了能让代码更容易进行测试,我们需要使用一些“安全重构”的手段来优化代码设计。很多 IDE 里都有这类功能的支持,如重命名,代码移动(拆分类),抽取方法等。注意在这个过程中只做搬运和重命名这类安全操作,不要同时修改逻辑,在没有测试覆盖情况下会很危险。 在遗留系统中添加新功能时,我们可以考虑创建一个新的类,同时对这个类加上单元测试。然后在旧有类中去调用这个新的类中的方法。以此来至少保证新功能是有测试覆盖的。 如果还需要对旧有代码做持续的 bugfix 等改动,那么也必然需要对遗留代码做重构和测试的补充。我们可以使用一些第三方工具来分析项目整体的依赖关系图,寻找明显问题,切入进行改造。这里的方法也是常见的重构手段,抽取类或者接口,把外部依赖做注入,实现多个实例化方法,在旧的类和方法里去调用逐渐抽取出来的新的类和方法。重构本身是没有完美状态的,我们只需要保证每次提交都比之前进步一些就可以。具体的衡量指标可以参考代码的测试覆盖率,SonarQube 等静态代码检查的结果等。 最后,如何培养整体团队的意识,让所有人都开始重视,学习,并掌握测试技术也是非常关键的。对于所有的新功能,bugfix,重构,都应该要求带上相应的测试代码覆盖。同时也需要在研发流程中提升测试的结合程度,完善测试相关的基础设施,让测试更好写,更容易执行并查看结果,逐渐让大家意识到测试带来的巨大价值。在赋能和培训方面,把这篇文章转发给团队,可能也是一个不错的开始 :) 参考资料Accelerate: https://book.douban.com/subject/30192146/ [2]Software Engineering at Google: https://book.douban.com/subject/34875994/ [3]Effective Software Testing: https://book.douban.com/subject/35817720/ [4]Unit Testing: https://book.douban.com/subject/34429421/ [5]James Shore 的演示视频: https:///nlGSDUuK7C4 [6]java-faker: https://github.com/DiUS/java-faker [7]修改代码的艺术: https://book.douban.com/subject/2248759/ |
|