当前位置: 首页 > 科技观察

如何编写有效的单元测试

时间:2023-03-22 10:26:35 科技观察

作者|王浩(光久)什么是单元测试《单元测试的艺术》单元测试的定义:单元测试是一段自动化的代码,它调用被测试的工作单元,然后检查关于这个单元的单一最终结果的某些假设.单元测试几乎总是使用单元测试框架编写的;只要生产代码不变,单元测试的结果就是稳定的。为什么需要单元测试在我看来,单元测试的意义可以概括为以下三点:单元测试是确保你写的代码是你想要的结果的最有效方式测试是最好的文档一个单元测试描述了代码的预期行为,可以最有效地保证代码的正确运行,减少代码缺陷;由于单元体积小,当因为代码改动出现问题时,可以帮助我们快速定位问题;有单元测试覆盖的代码,让我们更有底气,更敢于自信地做代码重构;编写单元测试的过程通常伴随着代码重构。如果你发现一段代码单元测试很难写,你需要反思我们的设计,然后重构来促进代码设计的优化,帮助我们塑造设计;同时,单元测试也是一个优化的、自动化的、可执行的文档;没有单元测试覆盖的代码很难维护。是什么让有效的单元测试可读、可维护、可靠和快速执行!《单元测试的艺术》描述了一个好的单元的特点:它应该是自动化的、可重复的;它应该易于实施;第二天应该有意义;任何人都应该能够一键运行它;快速地;它的结果应该是稳定的(如果运行之间没有进行任何修改,多次运行测试应该总是返回相同的结果);它应该完全控制被测单元;它应该完全隔离(独立于其他测试运行);如果失败了,应该很容易看出预期的结果是什么,从而定位问题。可读性“一个普通的程序员可以写出计算机可以理解的代码。一个好的程序员可以写出一个人可以理解的代码”——MartinFowler可读的代码是可维护的;难以阅读和理解测试用例,最后的结果是删除它,因为维护成本太高。纯性能的可读性。在可维护性团队中使用范式结构有助于提高可用性、快速定位问题并消除代码中的不良气味。可靠可靠的意思:测试可以重复;测试与依赖环境隔离;只测试不验证是不可靠的测试;不依赖于测试类中的测试顺序;测试结果准确:准确验证和准确定位错误问题;快速执行保证快速的单次测试执行,缩短反馈时间;为什么有效的单元测试如此重要无效的单元测试是没有意义的,反而会增加维护成本,最终导致单元测试失败!如上图所示,在坐标中的任意一点,横纵坐标的竖线所形成的矩形区域代表了CI为团队带来的价值。在我看来,有两个关键因素:横坐标是单元测试。对于基础能力建设,纵轴是有效的单元测试;没有有效的单元测试,基础能力毫无意义!完善的基础能力也帮助我们以更低的成本编写出有效的单元测试。如何编写有效的单元测试我们以Flutter为例,讨论如何编写有效的单元测试;使用Flutter官方提供的测试框架:flutter_testintegration_test统一的编码约定为AAA(Arrange-Act-Assert)或GWT(Given-When-Then),统一的编码约定有助于保证测试代码的可读性和可维护性。使用测试替身测试替身帮助我们隔离被测代码,加快执行速度,保证测试代码的可靠性;Dummy:什么都不做的实现。接口中的每个方法什么都不做。如果该方法有返回值,则返回值应尽可能接近null或0。Stub:Dummy的一种,Stub的函数不返回null或0,而是返回一个值,可以让待测函数沿着预定的路径前进。Spy:Stub的一种,返回测试所需的特定值,并推动系统沿着我们期望的路径前进。但是,Spy会记住对它所做的操作并允许测试询问它。Mock:Spy的一种,它返回测试所需的特定值,推动系统沿着我们期望的路径前进,并记住对它做了什么。但是Mock也知道我们的期望,并根据这些期望来判断测试是否通过;换句话说,测试断言是用Mock编写的。Fake:Fake是一个实现基本业务规则的模拟器,因此测试可以要求Fake在所需路径上执行。一个测试应该只检查一件事,以明确测试的目的。一旦出现错误,可精准定位问题;一个测试只有一个模拟对象,避免模拟对象过多,一个测试用例的验证内容尽可能简单;avoidredundanttests避免冗余测试冗余测试将提高维护成本;避免条件逻辑条件逻辑会让你的单元测试更难维护,不容易排查问题,不够准确;单体测试需要确定性,避免脆弱测试,Mock不确定依赖:时间、随机数、并发、Infrastructure、已有数据、持久化、网络等;快速测试执行,避免sleep等操作,导致测试执行缓慢;避免过度规范。关于过度规范讨论的核心问题是我们需要判断单元测试应该覆盖哪些。哪些应该留给其他测试手段。如果一个场景,单元测试覆盖后,单元测试经常失败,需要持续更新维护,那么可以考虑不做单元测试覆盖。像素完美是一个经常被讨论的典型例子。Flutter的GoldenTest是goldenmaster测试的一个例子;《有效的单元测试》中关于像素完美的讨论:Pixelperfection:顾名思义,它是一个图形和图像生成特定的测试,味道不好。它将魔术数字与基本断言混合在一起,使测试极难阅读且极度脆弱。这种测试几乎不可读,因为即使测试在语义上是高级概念,它仍然对硬编码的低级细节(例如像素坐标和颜色)进行断言。指定坐标上的像素点是黑色还是白色,与两个图形是相连还是堆叠的概念是不同的。这种测试非常脆弱,因为即使是输入中一个很小且无关紧要的变化——无论是另一个图像,还是图形对象的渲染方式——都足以影响输出,破坏测试,这让你不得不精确检查像素坐标和颜色呢。使用金主技术也会遇到同样的问题。方法是预先记录图像,手动检查其正确性,稍后测试时将渲染图像与其进行比较。这些不是我们想要维护的测试。我们不想用这种脆弱的精度编写测试,而是使用模糊匹配和智能算法来代替繁琐的数值比较。针对特定场景,GoldenTest是一种非常有效的手段,但需要非常谨慎的评估;谨慎使用黄金测试!不要编写永不失败的测试,也不要编写没有验证的测试。需要明确检查单个测试的逻辑。永不失败的测试或未经验证的测试是不可靠的。测试不能起错名字,以免测试描述与测试内容不符;测试结果必须准确;测试必须在它应该失败的时候失败!测试private或者protected方法解决思路:把方法变成public方法;将方法提取到新类;将方法转换为静态方法;使方法对测试可见;避免强制测试顺序依赖测试顺序导致测试可靠性变得脆弱,日后维护成本变高;在teardown阶段清理测试环境,比如恢复全局Config,清理创建的文件目录等;统一的单测命名和变量命名可以提高可靠性。可读性和可维护性;使用有意义的断言断言的错误信息一定要有意义,出现问题时可以弄清楚错误原因;单元测试被视为“一等公民”测试用例应被视为“一等公民”:同样需要代码审查,也需要代码质量检查,以确保单元测试的有效性;单元测试代码审查的过程也是团队成员相互学习,积累最佳实践的过程。加快执行速度每日监控单次测试执行时间,分析测试性能,对执行时间过长的测试用例进行优化。测试金字塔测试金字塔是MikeCohn在他的书《Succeeding with Agile》中提出的一个概念。测试金字塔是一个比喻,它告诉我们根据不同的粒度对软件测试进行分组。它还告诉我们每个组应该进行多少次测试。为了保持金字塔形状,一个健康、快速、可维护的测试组合应该是这样的:编写许多小而快的单元测试。适当写一些粗粒度的测试,少写高层次的端到端测试。注意不要让你的测试看起来像冰淇淋或沙漏,这将是一场维护噩梦,并且需要花费太多时间来完成。避免测试重复在实施测试金字塔时,您还应该牢记这两个基本规则:如果更高级别的测试发现了错误,并且较低级别的测试都通过了,那么您应该编写一个较低级别的测试来覆盖错误;尽最大努力将测试推下金字塔;如果你已经在低级测试中覆盖了所有的情况,那么就没有必要再维护一个高级测试了。警惕沉没成本的思维陷阱,果断按下删除键。没有理由将宝贵的时间浪费在不再提供价值的测试上。补充单元测试应该从哪里开始?应及时编写单元测试。即使不实践TDD,代码实现后也应该尽快编写单元测试,避免写出不可测试的代码,尽快暴露bug;但不幸的是,我们很多时候在刚开始优秀工程,推广单元测试的时候,不得不面对补充单元测试的情况;这绝对是一件具有挑战性的事情。补充单元测试应该从哪里开始?参照测试金字塔,对于基础组件库,可根据具体情况确定;对于业务库,建议第一步从金字塔顶端开始测试:优先覆盖回归测试用例中的P0级用例;避免过度指定终端端到端测试;适当的合同测试;接下来,从金字塔的中间层开始,不断向上向下补充;可测试的设计应该可以轻松快速地为一段代码编写单元测试;可测试设计允许我们编写模块化设计;操作指南为了编写可测试的代码,您需要注意以下几点:避免复杂的私有方法;避免最终方法;避免静态方法;使用new要小心;避免构造函数中的逻辑;避免单身;组合优于继承;避免服务查找;基于接口的设计;可测试代码可测试代码设计有时需要避免复杂的私有方法或受保护方法,因为这些意味着它们无法被测试;这是否意味着可测试设计违反了开闭原则?当代码重构时,可以认为对象模型中增加了另一个终端用户——测试用户。另外,如果实在不想暴露一部分代码,也可以用@visibleForTesting修饰;单元测试和重构编写单元测试的过程往往伴随着重构;代码重构还需要进行单元测试,以确保代码正确运行。重构需要遵守的纪律:没有测试的重构是没有意义的,频繁重构,果断重构,坚决重构;持续重构,将问题扼杀在摇篮中;果断重构是敏捷编程的名言之一。规则很简单:重构时要勇敢。勇敢尝试,勇敢修改,不怕代码。让测试永远通过,打造绿色安全地带,不允许破窗。出库留一条路,贴上标签,以便需要时回滚。可测试代码可测试代码是解耦代码;可测试的代码帮助我们实现更好的抽象。如果做不了TDD,可以先做测试。下图展示了遵循TDD三大原则的实践过程。TDD很强大,但不一定适用于所有团队。推广难度大,学习曲线高。TDD其实包括两个方面:测试先行,进化设计;先测试是一个很重要的工程实践,如果你做不了TDD,可以先做测试。在KentBeck的经典《解析极限编程》中提到:早点测试,经常测试,自动测试!首先测试的本质能力要求是接口设计能力——是否能够明确设计单元的边界。如何理解单元测试代码覆盖率不要将它们变成管理指标。这就是您使用覆盖率数字的目的:将它们用作帮助您改进的指标,而不是将它们用作惩罚团队和失败构建的棍子。——《匠艺整洁之道》代码覆盖率的一大忌讳:为了追求代码覆盖率,只测试不验证;一味追求代码覆盖率,往往会写出无效的单元测试,增加维护成本,最后不得不放弃Failed。与其追求代码覆盖率,不如专注于确保编写有意义的测试。沉淀最佳实践必须承认单元测试有一定的成本。从成本曲线来看,前期相对较高;正是这个初期的门槛,让很多人望而却步。在团队内部提拔的时候,最难的就是写第一个单元测试;我们需要积累最佳实践,以帮助降低编写单元测试的成本,并使我们更容易编写有效的单元测试。我认为积累最佳实践的最好方法是CodeReview;前面我们说过,单元测试应该被看作是“某一个公民”。在CodeReview的过程中,互相学习,分享最佳实践,消除无效。单元测试。隔离单元测试和集成测试集成测试是对一个工作单元的测试,它对被测试的工作单元没有完全的控制权,使用了工作单元的一个或多个真实的依赖关系,例如时间、网络、数据库、线程或随机数生成器等。任何运行不快、结果不稳定或使用被测单元的一个或多个真实依赖项的测试都是集成测试。在日常开发过程中,我们需要构建一个绿色安全区:单元测试和集成测试隔离;集成测试不够稳定,运行时间长等问题,如果不隔离,日常开发浪费时间和精力维护,最终导致开发者不再信任测试。单元测试与ABTest单元测试与ABTest有什么关系吗?其实没关系。但在一定程度上,它们本质上是一样的,都保证了线上代码的质量(当然单测成本对基础设施和开发者能力要求更高);逻辑加了AB开关,一旦线上有问题,就有出路;当出现问题时,我常常觉得AB开关救了我的命;单元测试可以把问题左移,防止问题上线,也是一种保护;如果有一天队友愿意主动加入单元测试来保护自己的代码,那么单元测试就会相对成功。写在最后从软件工程到卓越工程,单元测试从可有可无变成了必须;要实现主干开发和大库模式,单元测试是前提条件。关于单元测试,我觉得最重要的永远是写单元测试的人。优秀的团队文化非常重要。真正能衡量一个单元测试好坏的东西,只有程序员的职业道德。我们花了很多篇幅讨论有效单元测试的重要性以及如何编写有效的单元测试。我们不得不承认,单元测试是有一定成本的。真正的实践还需要很长的路要走,需要我们在实践中去定义。单元测试的边界,以找到最适合团队的最佳实践。参考文档《单元测试的艺术》《有效的单元测试》《Succeeding with Agile》《匠艺整洁之道》TheTestPyramid:https://martinfowler.com/articles/practical-test-pyramid.htmlSoftwareEngineeringatGoogle:https://qiangmzsx.github.io/Software-Engineering-at-Google/#/zh-cn/Chapter-12_Unit_Testing/Chapter-12_Unit_Testing