收藏 分销(赏)

阿里开发者手册-单元测试专题.pdf

上传人:Stan****Shan 文档编号:1241087 上传时间:2024-04-19 格式:PDF 页数:115 大小:9.25MB 下载积分:25 金币
下载 相关 举报
阿里开发者手册-单元测试专题.pdf_第1页
第1页 / 共115页
阿里开发者手册-单元测试专题.pdf_第2页
第2页 / 共115页


点击查看更多>>
资源描述
封面页(此页面将由下图全覆盖,此为编辑稿中的示意,将在终稿 PDF 版中做更新)卷首语 阿里开发者手册每期聚焦一个当下热门的技术领域,收纳阿里技术专家的实战精华,手册共分乘云上、正当时和创新汇三个栏目。乘云上为开发者提供提供体系化、集成式的实操案例和解决方案;正当时主打纯技术分享,帮助开发者突破技术难题;创新汇聚焦技术发展的未来,带领开发者学习新技术,为开发者分享新产品、解读新趋势。本期阿里开发者手册以单元测试为主题,由光酒、悬衡、铭楚、六瓶、思兼、桃符、问元 7 位优秀的阿里程序员带广大开发者深入探究单元测试。纵观优秀的项目工程,完备的单元测试总是必须的条件。通过单元测试,技术人员可以充分了解代码中相关类和方法的作用与核心逻辑,熟悉各种场景的运行情况。单元测试不仅可以有效地保证代码正确运行,还能为软件/项目的稳定运行提供保障。本期阿里开发者手册将带开发者们探究单元测试的运行原理,让开发者不仅可以学习到如何写出高质量单测并运用到实际应用,更有淘系用户平台技术团队现身说法,看优秀技术团队如何提升自测和提测质量。阿里开发者手册旨在帮助开发者了解到多种开发技术,聚焦专业的技术领域。让读者了解并学习到该技术创建以来的生态环境、设计思想、开发模式和习惯用法,助力开发者实际工作中的开发与运维。目录 乘云上.5 如何写出有效的单元测试.6 代码重构:面向单元测试.24 正当时.48 单元测试运行原理探究.49 卓越工程实践之前端高质量单测.70 客户端单元测试实践C+篇.78 创新汇.91 基于链路思想的 SpringBoot 单元测试快速写法.92 淘系用户平台技术团队单元测试建设.108 乘云上 5 乘云上(此页面将由下图全覆盖,此为编辑稿中的示意,将在终稿 PDF 版中做更新)如何写出有效的单元测试 6 如何写出有效的单元测试 作者:光酒 1.什么是单元测试 单元测试的艺术中对单元测试的定义:一个单元测试是一段自动化的代码,这段代码调用被测试的工作单元,之后对这个单元的单个最终结果的某些假设进行校验。单元测试几乎都是用单元测试框架编写的;只要产品代码不发生变化,单元测试的结果是稳定的。2.为什么需要单元测试 在我看来,单元测试的意义可以总结如下三点:单元测试是保证你写的代码是你想要的结果的最有效办法。单元测试帮我们塑造设计。单元测试是最好的文档之一。单元测试描述了代码的预期行为,可以最有效地保证代码正确运行,减少代码缺陷;由于单元规模较小,当因为代码变更出现问题的时候,可以帮助我们快速定位问题;有单元测试覆盖的代码,让我们更有信心,敢于放心做代码重构。写单元测试的过程往往伴随着代码重构,如果发现一段代码单元测试很难写,就需要反思我们的设计,进而重构促进代码设计的优化,帮助我们塑造设计。同时单元测试也是一个最佳的、自动化的、可执行的文档;没有单测覆盖的代码,是很难被维护的。如何写出有效的单元测试 7 3.什么是有效的单元测试 可读、可维护、可信赖、快速执行!单元测试的艺术中描述优秀单元的特性:它应该是自动化的,可重复执行。它应该很容易实现。它应该第二天还有意义。任何人都应该能一键运行它。它应该运行速度很快。它的结果应该是稳定的(如果运行之间没有进行修改的话,多次运行一个测试应该总是返回同样的结果)。它应该能完全控制被测试的单元。它应该是完全隔离的(独立于其他测试的运行)。如果它失败了,我们应该很容易发现什么是期待的结果,进而定位问题所在。1)可读性 “一般程序员写得出计算机能读懂的代码。优秀程序员写得出人能读懂的代码”马丁 福勒 可读的代码才是可维护的;难以阅读和理解的测试用例,最终的结果就是删掉它,因为维护成本过高。可读性高于纯粹的性能。2)可维护性 团队内使用一套范式的结构,有助于使之更好用,快速定位问题;消灭代码中的坏味道。如何写出有效的单元测试 8 3)可信赖 可信赖的含义:测试可重复。测试与依赖环境隔离。只测试不进行验证是不可靠的测试。在测试类中不要依赖与测试的顺序。测试的结果是精准的:校验的精准以及错误问题的精准定位。4)快速执行 保证单测快速执行,缩短反馈时长。4.为什么有效的单元测试如此重要 无效的单元测试是没有意义的,反而会增加维护成本,最终导致单元测试的失败!如何写出有效的单元测试 9 如上图所示,坐标中任意一个点,其与横纵坐标垂直线所形成的矩形面积代表 CI 为团队带来的价值,那么在我看来有两个关键的因素:横坐标是单元测试的基础能力建设,纵坐标则是有效的单元测试。没有有效的单元测试,基础能力做出花来也毫无意义!完善的基础能力同时也帮助我们更低成本的写出有效的单元测试。5.如何写有效的单元测试 我们以 Flutter 为例,来一起讨论如何写有效的单元测试。使用测试框架 Flutter 官方提供的测试框架:flutter_test integration_test 统一的编码约定 不论是 AAA(Arrange-Act-Assert)还是 GWT(Given-When-Then),统一的编码约定帮助保证测试代码的可读性、可维护性。如何写出有效的单元测试 10 使用测试替身 测试替身帮助我们隔离被测试代码,加速执行速度,保证测试代码是可信赖的。Dummy:一种什么也不做的实现方式。接口中的每个方法什么也不做,如果方法有返回值,返回的值尽量接近 null 或者 0。Stub:Dummy 的一种,Stub 的函数并不返回 null 或 0,而是返回能推动函数沿预定路径被测试的值。Spy:Stub 的一种,它返回测试所需的特定值,推动系统沿着我们期望的路径前行。然而,Spy 能记住对它所做的事,并允许测试询问。如何写出有效的单元测试 11 Mock:Spy 的一种,它返回测试所需的特定值,推动系统沿着我们期望的路径前行,而且还会记住对它所做的事。不过,Mock 还知道我们的预期,基于这些预期,判断测试是否通过;换而言之,Mock 中写明了测试断言。Fake:Fake 是一种模拟器,它实现基础业务规则,这样测试就能要求该 Fake按需要的路径执行。一个测试应当只检查一件事 明确测试意图,一旦出错可以精准定位问题。一个测试只有一个模拟对象 避免过多模拟对象,一个测试用例的校验内容尽量简单。避免冗余测试 冗余测试会提高维护成本。如何写出有效的单元测试 12 避免条件逻辑 条件逻辑会让你的单元测试更难以维护,出问题不容易排查,不够精准。单测需要确定性 避免脆弱测试,Mock 不确定的依赖:时间、随机数、并发性、基础设施、现存数据、持久化、网络等等。如何写出有效的单元测试 13 测试快速执行 避免 sleep 等操作,导致测试执行缓慢。避免过度指定 对于过度指定的讨论,其核心问题就是要我们判断哪些是单元测试应该覆盖的,哪些是应该留给其他测试手段的。如果一个场景,单元测试覆盖之后,导致经常单测失败,需要不断更新维护,那就可以考虑不做单元测试覆盖。像素完美是一个典型的、经常拿出来讨论的例子,Flutter 的 Golden Test 就是一个golden master testing 的例子;有效的单元测试中关于像素完美的讨论:像素完美:顾名思义,是一种特定于图形和图像生成的测试坏味道。它混杂了魔法数字和基本断言,使得测试极难阅读也极其脆弱。这种测试几乎无法阅读,因为即使测试在语义上是处于高层概念的,却仍然会针对硬编码的底层细节例如像素坐标和颜色来进行断言。指定坐标上的像素是黑还是白,与两个图形是否相连或堆叠的概念是有区别的。这种测试极其脆弱,因为即使很小的和不相关的输入变化是否是另一个图像,或图形对象的渲染方式都足以影响输出、打破测试,谁让你非要精确地检查像素坐标和颜色呢。同样的问题在采用 golden master 技术时也会遇到,其做法是事先将图像录制下来,并手工检查其正确性,以后再进行测试时就将渲染出的图像与之进行比对。如何写出有效的单元测试 14 这些可不是我们愿意去维护的测试。我们不希望带着这种脆弱的精确度去编写测试,而是使用模糊匹配和智能算法来代替繁琐的数值比较。对于特定场景,Golden Test 是一个非常有效的手段,但需要非常谨慎的评估;慎用Golden Test!不要写永不失败的测试,不要写没有校验的测试 单测需要对明确的逻辑校验,永不失败的测试或者没有校验的测试是不可信赖的。测试不要名不副实 避免测试的描述与测试内容不符;测试结果必须精准;测试该失败的时候一定要失败!如何写出有效的单元测试 15 测试私有或者受保护的方法 解决思路:将方法变成公共方法;将方法抽取到新类;将方法变成静态方法;将方法成为测试可见方法;避免强制的测试顺序 依赖测试顺序导致测试可靠性变得脆弱,未来维护成本变高。清理测试环境 在 teardown 阶段清理测试环境,例如还原全局的 Config、清理创建的文件目录等等。如何写出有效的单元测试 16 统一的单测命名、变量命名 统一的单测命名可以提高可读性、可维护性。使用有意义的断言 断言的错误信息要有意义,出现问题能够明确错误的原因。把单元测试视为“一等公民”测试用例应该被视为“一等公民”:同样需要代码评审,同样需要代码质量检查,确保单元测试的有效性。单元测试代码评审的过程,也是团队同学互相学习的过程,沉淀最佳实践的过程。加速执行速度 日常对单测执行时间进行监控,对测试进行性能分析,优化执行时间过长的测试用例。测试金字塔 测试金字塔是 Mike Cohn 在他的著作Succeeding with Agile一书中提出了这个概念。测试金字塔是一个比喻,它告诉我们要把软件测试按照不同粒度来分组。它也告诉我们每个组应该有多少测试。如何写出有效的单元测试 17 为了维持金字塔形状,一个健康、快速、可维护的测试组合应该是这样的:写许多小而快的单元测试。适当写一些更粗粒度的测试,写很少高层次的端到端测试。注意不要让你的测试变成冰淇淋或者沙漏那样子,这对维护来说将是一个噩梦,并且跑一遍也需要太多时间。如何写出有效的单元测试 18 避免测试重复 在实现测试金字塔时,你也应该牢记这两条基本法则:如果一个更高层级的测试发现了一个错误,并且底层测试全都通过了,那么你应该写一个低层级测试去覆盖这个错误。竭尽所能把测试往金字塔下层赶。如果你已经在低层级测试里覆盖了所有情况,那么再维护一个高层级的测试就没有必要了。警惕沉没成本的思维陷阱,果断摁下删除键。没有理由在不再提供价值的测试上浪费宝贵时间。6.补充单元测试应该从哪里开始 单元测试应该及时编写,就算没有实践 TDD,也应该在代码实现之后尽快编写单元测试,避免写出不可测试的代码,也可以让 bug 尽早暴露。但很不幸的,我们很多时候在刚开始卓越工程,推广单元测试的时候,不得不面对补充单元测试的情况;这绝对是一个有挑战的事情。补充单元测试应该从哪里开始?参考测试金字塔,对于基础组件库来说,可以根据具体情况来定。对于业务库来说,第一步建议从金字塔顶端的测试:优先覆盖回归测试用例中 P0 级别的用例。避免过度指定的端到端测试。适当的契约测试。接下来,从金字塔中间层开始,不断向上、向下补充。如何写出有效的单元测试 19 7.可测试的设计 应当容易、快速地为一段代码编写单元测试;可测试的设计,使我们写出模块化的设计。行动指南 为了写出可测试的代码,需要注意以下几点:避免复杂的私有方法 避免 final 方法 避免 static 方法 使用 new 要当心 避免构造函数中包含逻辑 避免单例 组合优于继承 避免服务查找 基于接口的设计 可测试的代码是否违背了 SOLID 中的开闭原则?可测试的代码设计,有的时候需要避免复杂的私有方法或者受保护的方法,因为这些意味着不可测试。这样的话是不是意味着可测试的设计违反了开闭原则呢?在代码重构的时候,可以认为给对象模型增加了另外一种最终用户测试用户。另外如果一部分代码实在不希望暴露,也可以使用visibleForTesting 修饰。8.单元测试与重构 写单元测试的过程往往伴随着重构,代码重构同样需要单元测试保证代码正确运行。重构需要遵守的纪律:无测试重构无意义,频繁重构、果断重构、坚决重构。如何写出有效的单元测试 20 持续重构 将麻烦扼杀在摇篮。果断重构 敏捷编程的名言之一。规则很简单:重构时要勇敢。勇敢尝试,勇敢修改,不用害怕代码。让测试始终能通过 建一个绿色安全区,不允许破窗出现。留条出路 仓库打好 tag,以便在需要的时候能够回滚。可测试的代码 可测试的代码就是解耦了的代码;可测试的代码帮助我们实现更好的抽象。9.做不到 TDD,可以做到测试先行 下图是遵循 TDD 三大法则的实践过程,TDD 很强大,但不一定适用所有的团队,推广难度很大,学习曲线很高。如何写出有效的单元测试 21 TDD 事实上由两个方面组成:测试先行,以及演进式设计;测试先行是非常重要的工程实践,做不到 TDD,可以做到测试先行。在 Kent Beck 的经典名著解析极限编程中,提到:尽早测试,经常测试,自动测试!测试先行的本质能力要求是接口的设计能力能否清晰的定义出设计单元的边界。10.如何理解单元测试代码覆盖率 不要把它们变成管理的指标。这就是你使用覆盖率数字的目的:使用它们作为衡量标准来帮助你改进,而不是用它们作为惩罚团队和使构建失败的棍棒。匠艺整洁之道 代码覆盖率的一大忌讳:为了追求代码覆盖率,只测试不进行验证。一味追求代码覆盖率,往往写出无效的单元测试,额外增加了维护成本,最终不得不放弃以失败告终。与其追求代码覆盖率,不如将重点关注在确保写出有意义的测试。11.沉淀最佳实践 必须承认单元测试有一定的成本,成本曲线来看,前期比较高;恰恰是这前期的门槛,让很多人望而却步。在团队内推广的时候,最难的就是写出第一个单元测试;我们需要沉淀最佳实践,帮助降低写单元测试的成本,让我们更容易地写出有效的单元测试。我觉得沉淀最佳实践最好的方法,就是 Code Review;正如我们前面所说的,要把单元测试当成是“一定公民”,在 Code Review 的过程中,互相学习、分享最佳实践,消除无效的单元测试。12.隔离单元测试与集成测试 集成测试是对一个工作单元进行的测试,这个测试对被测试的工作单元没有完全的控制,并使用该工作单元一个或多个真实依赖,例如时间、网络、数据库、线程或随机数生成器等。如何写出有效的单元测试 22 任何测试,如果它的运行速度不快,结果不稳定,或者要用到被测试单元的一个或多个真实依赖,就是集成测试。在日常开发过程,我们需要建一个绿色安全区:单元测试与集成测试隔离;集成测试不够稳定,运行时间长等问题,如果不做隔离,日常开发浪费时间和精力维护,最后导致开发人员不再信任测试。13.单元测试与 ABTest 单元测试与 ABTest 有什么关系吗?事实上没有什么关系。但一定程度程度上,它们本质是相同的,都是保障线上代码质量(当然单测的成本,对于基建、开发者的能力的要求更高)。在日常开发中,经常主动为新的代码逻辑增加 AB 开关,一旦线上出问题留一条后路;发生问题的时候往往感慨 AB 开关救我一命;单元测试可以让问题左移,防止问题上线,同样是一道保护;如果有一天团队同学愿意主动增加单元测试来保护自己的代码,那么单元测试这件事就算比较成功了。14.写在最后 从软件工程到卓越工程,单元测试从可选变成了必要;想要实现主干开发、大库模式,单元测试是前提条件。关于单元测试这件事,我觉得最重要永远是写单元测试的人,优秀的团队文化非常重要,没有什么能够真正衡量单元测试做的好坏,有的只是程序员的职业操守。我们花了很大的篇幅讨论有效单元测试的重要性以及如何写出有效的单元测试,不得不承认单元测试有一定的成本,真正实践依然需要很多的路要走,需要我们在实践中定义好单元测试的边界,找到最适合团队的最佳实践。如何写出有效的单元测试 23 参考文档 单元测试的艺术 有效的单元测试 Succeeding with Agile 匠艺整洁之道 The Test Pyramid:https:/ Software Engineering at Google:https:/qiangmzsx.github.io/Software-Engineering-at-Google/#/zh-cn/Chapter-12_Unit_Testing/Chapter-12_Unit_Testing 代码重构:面向单元测试 24 代码重构:面向单元测试 作者:悬衡 重构代码时,我们常常纠结于这样的问题:需要进一步抽象吗?会不会导致过度设计?如果需要进一步抽象的话,如何进行抽象呢?有什么通用的步骤或者法则吗?单元测试是我们常用的验证代码正确性的工具,但是如果只用来验证正确性的话,那就是真是“大炮打蚊子”-大材小用,它还可以帮助我们评判代码的抽象程度与设计水平。本文还会提出一个以“可测试性”为目标,不断迭代重构代码的思路,利用这个思路,面对任何复杂的代码,都能逐步推导出重构思路。为了保证直观,本文会以一个“生产者消费者”的代码重构示例贯穿始终。最后还会以业务上常见的 Excel 导出系统为例简单阐述一个业务上的重构实例。阅读本文需要具有基本的单元测试编写经验(最好是 Java),但是本文不会涉及任何具体的单元测试框架和技术,因为它们都是不重要的,学习了本文的思路,可以将它们用在任意的单测工具上。1.不可测试的代码 程序员们重构一段代码的动机是什么?可能众说纷纭:代码不够简洁?不好维护?不符合个人习惯?过度设计,不好理解?代码重构:面向单元测试 25 这些都是比较主观的因素,在一个老练程序员看来恰到好处的设计,一个新手程序员却可能会觉得过于复杂,不好理解。但是让他们同时坐下来为这段代码添加单元测试时,他们往往能够产生类似的感受,比如:“单测很容易书写,很容易就全覆盖了”,那么这就是可测试的代码。“虽然能写得出来,但是费了老大劲,使用了各种框架和技巧,才覆盖完全”,那么这就是可测试性比较差的代码。“完全不知道如何下手写”,那么这就是不可测试的代码。一般而言,可测试的代码一般都是同时是简洁和可维护的,但是简洁可维护的代码却不一定是可测试的,比如下面的“生产者消费者”代码就是不可测试的:public void producerConsumer()BlockingQueue blockingQueue=new LinkedBlockingQueue();Thread producerThread =new Thread()-for(int i=0;i try while(true)Integer result=blockingQueue.take();System.out.println(result);catch(InterruptedException ignore);producerThread.start();consumerThread.start();上面这段代码做的事情非常简单,启动两个线程:生产者:将 0-9 的每个数字,分别加上0,100)的随机数后通过阻塞队列传递给消费者。代码重构:面向单元测试 26 消费者:从阻塞队列中获取数字并打印。这段代码看上去还是挺简洁的,但是,算得上一段好代码吗?尝试下给这段代码加上单元测试。仅仅运行一下这个代码肯定是不够的,因为我们无法确认生产消费逻辑是否正确执行。我也只能发出“完全不知道如何下手”的感叹,这不是因为我们的单元测试编写技巧不够,而是因为代码本身存在的问题:1)违背单一职责原则:这一个函数同时做了 数据传递,处理数据,启动线程三件事情。单元测试要兼顾这三个功能,就会很难写。2)这个代码本身是不可重复的,不利于单元测试,不可重复体现在:?需要测试的逻辑位于异步线程中,对于它什么时候执行?什么时候执行完?都是不可控的。?逻辑中含有随机数。?消费者直接将数据输出到标准输出中,在不同环境中无法确定这里的行为是什么,有可能是输出到了屏幕上,也可能是被重定向到了文件中。因为第 2 点的原因,我们就不得不放弃单测了呢?其实只要通过合理的模块职责划分,依旧是可以单元测试。这种划分不仅仅有助于单元测试,也会“顺便”帮助我们抽象一套更加合理的代码。2.可测试意味着什么?所有不可测试的代码都可以通过合理的重构与抽象,让其核心逻辑变得可测试,这也重构的意义所在。本章就会详细说明这一点。首先我们要了解可测试意味着什么,如果说一段代码是可测试的,那么它一定符合下面的条件:1)可以在本地设计完备的测试用例,称之为 完全覆盖的单元测试。2)只要完全覆盖的单元测试用例全部正确运行,那么这一段逻辑肯定是没有问题的。代码重构:面向单元测试 27 第 1 点常会令人感到难以置信,但事实比想象的简单,假设有这样一个分段函数:F(x)看起来有无限的定义域,我们永远无法穷举所有可能的输入。但是再仔细想想,我们并不需要穷举,其实只要下面几个用例可以通过,那么就可以确保这个函数是没有问题的:-50 f(-51)=-100 -50,50 f(-25)=-50 f(25)=50 50 f(51)=100 代码重构:面向单元测试 28 边界情况 f(-50)=-100 f(50)=100 日常工作中的代码当然比这个复杂很多,但是没有本质区别,也是按照如下思路进行单元测试覆盖的:每一个分段其实就是代码中的一个条件分支,用例的分支覆盖率达到了 100%。像 2x 这样的逻辑运算,通过几个合适的采样点就可以保证正确性。边界条件的覆盖,就像是分段函数的转折点。但是业务代码依旧比 f(x)要复杂很多,因为 f(x)还有其他好的性质让它可以被完全测试,这个性质被称作引用透明:函数的返回值只和参数有关,只要参数确定,返回值就是唯一确定的。现实中的代码大多都不会有这么好的性质,反而具有很多“坏的性质”,这些坏的性质也常被称为副作用:代码中含有远程调用,无法确定这次调用是否会成功。含有随机数生成逻辑,导致行为不确定。执行结果和当前日期有关,比如只有工作日的早上,闹钟才会响起。好在我们可以用一些技巧将这些副作用从核心逻辑中抽离出来。高阶函数 “引用透明”要求函数的出参由入参唯一确定,之前的例子容易让人产生误解,觉得出参和入参一定要是数据,让我们把视野再打开一点,出入参可以是一个函数,它也可以是引用透明的。普通的函数又可以称作一阶函数,而接收函数作为参数,或者返回一个函数的函数称为高阶函数,高阶函数也可以是引用透明的。代码重构:面向单元测试 29 对于函数 f(x)来说,x 是数据还是函数,并没有本质的不同,如果 x 是函数的话,仅仅意味着 f(x)拥有更加广阔的定义域,以至于没有办法像之前一样只用一个一维数轴表示。对于高阶函数 f(g)(g 是一个函数)来说,只要对于特定的函数 g,返回逻辑也是固定,它就是引用透明的了,而不用在乎参数 g 或者返回的函数是否有副作用。利用这个特性,我们很容易将一个有副作用的函数转换为一个引用透明的高阶函数。一个典型的拥有副作用的函数如下:public int f()return ThreadLocalRandom.current().nextInt(100)+1;它生成了随机数并且加 1,因为这个随机数,导致它不可测试。但是我们将它转换为一个可测试的高阶函数,只要将随机数生成逻辑作为一个参数传入,并且返回一个函数即可:上面的 g 就是一个引用透明的函数,只要给 g 传递一个数字生成器,返回值一定是一个“用数字生成器生成一个数字并且加 1”的逻辑,并且不存在分支条件和边界情况,只需要一个用例即可覆盖:代码重构:面向单元测试 30 实际业务中可以稍微简化一下高阶函数的表达,g 的返回的函数既然每次都会被立即执行,那我们就不返回函数了,直接将逻辑写在方法中,这样也是可测试的:这里我虽然使用了 Lambda 表达式简化代码,但是“函数”并不仅仅是指 Lambda表达式,OOP 中的充血模型的对象,接口等等,只要其中含有逻辑,它们的传递和返回都可以看作“函数”。因为这个例子比较简单,“可测试”带来的收益看起来没有那么高,真实业务中的逻辑一般比+1 要复杂多了,此时如果能构建有效的测试将是非常有益的。3.面向单测的重构 第一轮重构 我们本章回到开头的生产者消费者的例子,用上一章学习到的知识对它进行重构。那段代码无法测试的第一个问题就是职责不清晰,它既做数据传递,又做数据处理。因此我们考虑将生产者消费者数据传递的代码单独抽取出来:代码重构:面向单元测试 31 这一段代码的职责就很清晰了,我们给这个方法编写单元测试的目标也十分明确,即验证数据能够正确地从生产者传递到消费者。但是很快我们又遇到了之前提到的第二个问题,即异步线程不可控,会导致单测执行的不稳定,用上一章的方法,我们将执行器作为一个入参抽离出去:这时我们就为它写一个稳定的单元测试了:代码重构:面向单元测试 32 只要这个测试能够通过,就能说明生产消费在逻辑上是没有问题的。一个看起来比之前的分段函数复杂很多的逻辑,本质上却只是它定义域上的一个恒等函数(因为只要一个用例就能覆盖全部情况),是不是很惊讶。如果不太喜欢上述的函数式编程风格,可以很容易地将其改造成 OOP 风格的抽象类,就像上一章提到的,传递对象和传递函数没有本质的区别:代码重构:面向单元测试 33 此时单元测试就会像是这个样子:看到这些类,熟悉设计模式的读者们一定会想到“模板方法模式”,但是我们在上面的过程从来没有刻意去用任何设计模式,正确的重构就会让你在无意间“重新发现”这些常用的设计模式,一般这种情况下,设计模式的使用都是正确的,因为我们一直在把代码往更加可测试的方向推荐,而这也是衡量设计模式是否使用正确的重要标准,错误的设计模式使用则会让代码更加的割裂和不可测试,后文讨论“过度设计”这个主题时会进一步深入讨论这一部分内容。很显然这种测试无法验证多线程运行的情况,但我故意这么做的,这部分单元测试的主要目的是验证逻辑的正确性,只有先验证逻辑上的正确性,再去测试并发才比较有意义,在逻辑存在问题的情况下就去测试并发,只会让问题隐藏得更深,难以排查。一般开源项目中会有专门的单元测试去测试并发,但是因为其编写代价比较大,运行时间比较长,数量会远少于逻辑测试。经过第一轮重构,主函数变成了这个样子(这里我最终采用了 OOP 风格):代码重构:面向单元测试 34 public void producerConsumer()new ProducerConsumer(Executors.newFixedThreadPool(2)Override void produce()for(int i=0;i 10;i+)produceInner(i+ThreadLocalRandom.current().nextInt(100);Override void consume()while(true)Integer result=consumeInner();System.out.println(result);.start();在第一轮重构中,我们仅仅保障了数据传递逻辑是正确的,在第二轮重构中,我们还将进一步扩大可测试的范围。第二轮重构 代码中影响我们进一步扩大测试范围因素还有两个:随机数生成逻辑 打印逻辑 只要将这两个逻辑像之前一样抽出来即可:代码重构:面向单元测试 35 这次采用 OOP 和函数式混编的风格,也可以考虑将 numberGenerator 和numberConsumer 两个方法参数改成抽象方法,这样就是更加纯粹的 OOP。它也只需要一个测试用例即可实现完全覆盖:代码重构:面向单元测试 36 此时主函数变成:经过两轮重构,我们将一个很随意的面条代码重构成了很优雅的结构,除了更加可测试外,代码也更加简洁抽象,可复用,这些都是面向单测重构所带来的附加好处。你 可 能 会 注 意 到,即 使 经 过 了 两 轮 重 构,我 们 依 旧 不 会 直 接 对 主 函 数producerConsumer 进行测试,而只是无限接近覆盖里面的全部逻辑,因为我认为它不在“测试的边界”内,我更倾向于用集成测试去测试它,集成测试则不在本篇文章讨论的范围内。下一章则重点讨论测试边界的问题。4.单元测试的边界 边界内的代码都是单元测试可以有效覆盖到的代码,而边界外的代码则是没有单元测试保障的。上一章所描述的重构过程本质上就是一个在探索中不断扩大测试边界的过程。但是单元测试的边界是不可能无限扩大的,因为实际的工程中必然有大量的不可测试部分,比如 RPC 调用,发消息,根据当前时间做计算等等,它们必然得在某个地方传入测试边界,而这一部分就是不可测试的。代码重构:面向单元测试 37 理想的测试边界应该是这样的,系统中所有核心复杂的逻辑全部包含在了边界内部,然后边界外都是不包含逻辑的,非常简单的代码,比如就是一行接口调用。这样任何对于系统的改动都可以在单元测试中就得到快速且充分的验证,集成测试时只需要简单测试下即可,如果出现问题,一定是对外部接口的理解有误,而不是系统内部改错了。清晰的单元测试边界划分有利于构建更加稳定的系统核心代码,因为我们在推进测试边界的过程中会不断地将副作用从核心代码中剥离出去,最终会得到一个完整且可测试的核心,就如同下图的对比一样:5.重构的工作流 好代码从来都不是一蹴而就的,都是先写一个大概,然后逐渐迭代和重构的,从这个角度来说,重构别人的代码和写新代码没有很大的区别。从上面的内容中,我们可以总结出一个简单的重构工作流:代码重构:面向单元测试 38 按照这个方法,就能够逐步迭代出一套优雅且可测试的代码,即使因为时间问题没有迭代到理想的测试边界,也会拥有一套大部分可测试的代码,后人可以在前人用例的基础上,继续扩大测试边界。6.过度设计 最后再谈一谈过度设计的问题。按照本文的方法是不可能出现过度设计的问题,过度设计一般发生在为了设计而设计,生搬硬套设计模式的场合,但是本文的所有设计都有一个明确的目的-提升代码的“可测试性”,所有的技巧都是在过程中无意使用的,不存在生硬的问题。而且过度设计会导致“可测试性”变差,过度设计的代码常常是把自己的核心逻辑都给抽象掉了,导致单元测试无处可测。如果发现一段代码“写得很简洁,很抽象,但是就是不好写单元测试”,那么大概率是被过度设计了。代码重构:面向单元测试 39 另外一种过度设计是因为过度依赖框架而无意中导致的,Java 往往习惯于将自己的设计耦合进 Spring 框架中,比如将一段完整的逻辑拆分到几个 Spring Bean 中,而不是使用普通的 Java 类,导致根本就无法在不启动容器的情况下进行完整的测试,最后只能写一堆无效的测试提升“覆盖率”。这也是很多人抱怨“单元测试没有用”的原因。7.和 TDD 的区别 本文到这里都还没有提及到 TDD,但是上文中阐述的内容肯定让不少读者想到了这个名词,TDD 是“测试驱动开发”的简写,它强调在代码编写之前先写用例,包括三个步骤:红灯:写用例,运行,无法通过用例 绿灯:用最快最脏的代码让测试通过 重构:将代码重构得更加优雅 在开发过程中不断地重复这三个步骤。但是在实践中会发现,在繁忙的业务开发中想要先写测试用例是很困难的,可能会有以下原因:代码结构尚未完全确定,出入口尚未明确,即使提前写了单元测试,后面大概率也要修改。产品一句话需求,外加对系统不够熟悉,用例很难在开发之前写好。因此本文的工作流将顺序做了一些调整,先写代码,然后再不断地重构代码适配单元测试,扩大系统的测试边界。不过从更广义的 TDD 思想上来说,这篇文章的总体思路和 TDD 是差不多的,或者标题也可以改叫做“TDD 实践”。代码重构:面向单元测试 40 业务实例-导出系统重构 钉钉审批的导出系统是一个专门负责将审批单批量导出成 Excel 的系统:大概步骤如下:启动一个线程,在内存中异步生成 Excel 上传 Excel 到钉盘/oss 发消息给用户 钉钉审批导出系统比常规导出系统要更加复杂一些,因为它的表单结构并不是固定的。而用户可以通过设计器灵活配置:代码重构:面向单元测试 41 从上面可以看出单个审批单还具有复杂的内部结构,比如明细,关联表单等等,而且还能相互嵌套,因此逻辑很十分复杂。我接手导出系统的时候,已经维护两年了,没有任何测试用例,代码中导出都是类似 patchXxx 的方法,可见在两年的岁月中,被打了不少补丁。系统虽然总体能用,但是有很多小 bug,基本上遇到边界情况就会出现一个 bug(边界情况比如明细里只有一个控件,明细里有关联表单,而关联表单里又有明细等等)。代码完全不可测试,完成的逻辑被 Spring Bean 隔离成一小块,一小块,就像下图一样:我决定将这些代码重构,不能让它继续荼毒后人,但是面对一团乱麻的代码完全不知道如何下手(以下贴图仅仅是为了让大家感受下当时的心情,不用仔细看):代码重构:面向单元测试 42 代码重构:面向单元测试 43 我决定用本文的工作流对代码进行重新梳理。确定测试边界 首先需要确定哪些部分是单元测试可以覆盖到的,哪些部分是不需要覆盖到的,靠集成测试保证的。经过分析,我认为导出系统的核心功能,就是根据表单配置和表单数据生成 excel 文件:这部分也是最核心,逻辑也最复杂的部分,因此我将这一部分作为我的测试边界,而其他部分,比如上传,发工作通知消息等放在边界之外:图中“表单配置”是一个数据,而“表单数据”其实是一个函数,因为导出过程中会不断批量分页地去查询数据。代码重构:面向单元测试 44 不断迭代,扩大测试边界到理想状态 我迭代的过程如下:异步执行导致不可测试:抽出一个同步的函数。大量使用 Spring Bean 导致逻辑割裂:将逻辑放到普通的 Java 类或者静态方法中。表单数据,流程与用户的相关信息查询是远程调用,含有副作用:通过高阶函数将这些副作用抽出去。导入状态落入数据库,也是一个副作用:同样通过高阶函数将其抽象出去。最终导出的测试边界大约是这个样子:public byte export(FormConfig config,DataService dataService,ExportStatusStore statusStore)/.省略具体逻辑,其中包括所有可测试的逻辑,包括表单数据转换,excel 生成 Config:数据,表单配置信息,含有哪些控件,以及控件的配置。dataService:函数,用于批量分页查询表单数据的副作用。statusStore:函数,用于变更和持久化导出的状态的副作用。public interface DataService PageList batchGet(String formId,Long cursor,int pageSize);代码重构:面向单元测试 45 public interface ExportStatusStore /*将状态切换为 RUNNING */void runningStatus();/*将状态置为 finish *param fileId 文件 id */void finishStatus(Long fileId);/*将状态置为 error *param errMsg 错误信息 */void errorStatus(String errMsg);在本地即可验证生成的 Excel 文件是否正确(代码经过简化):public void testExport()/这里的 export 就是刚刚展示的导出测试边界 byte excelBytes=export(new FormConfig(),new LocalDataService(),new LocalStatusStore();assertExcelContent(excelBytes,Arrays.asList(Arrays.asList(序号,表格,表格,表格,创建时间,创建者),Arrays.asList(序号,物品编号,物品名称,xxx,创建时间,创建者),Arrays.asList(1,22,火车,而非,2020-10-11 00:00:00,悬衡);其中 LocalDataService,LocalStatusStore 分别是内存中的数据服务,和状态变更服务实现,用于进行单元测试。assert
展开阅读全文

开通  VIP会员、SVIP会员  优惠大
下载10份以上建议开通VIP会员
下载20份以上建议开通SVIP会员


开通VIP      成为共赢上传

当前位置:首页 > 研究报告 > 其他

移动网页_全站_页脚广告1

关于我们      便捷服务       自信AI       AI导航        抽奖活动

©2010-2026 宁波自信网络信息技术有限公司  版权所有

客服电话:0574-28810668  投诉电话:18658249818

gongan.png浙公网安备33021202000488号   

icp.png浙ICP备2021020529号-1  |  浙B2-20240490  

关注我们 :微信公众号    抖音    微博    LOFTER 

客服