使用单元测试清洁代码:保持测试套件清洁的技巧和窍门
封面照片由Evano Community 的Sarah Dorweiler 拍摄
单元测试非常重要。它们可以在重构代码时防止回归问题,提供文档支持,还能节省你大量繁琐的手动测试时间。简而言之,测试能够促成变革。
但是,我们对测试的整洁性有多重视呢?我们重构了应用的生产代码,为变量赋予了描述性的名称,提取了可重复使用的方法,并使代码易于理解。但是,我们对测试也做了同样的事情吗?
请考虑一下罗伯特·C·马丁的这句话:
测试代码与生产代码同等重要。它并非二等公民。它需要思考、设计和细心。它必须像生产代码一样保持整洁。
那么,我们如何保持测试代码的整洁呢?让我们考虑以下一些想法。
结构化测试
测试应该按照“安排-执行-断言”模式进行构建。该模式有很多名称,有时也被称为“构建-操作-检查”、“设置-执行-验证”或“给定-何时-然后”模式。
我更喜欢“安排-行动-断言”这种引人入胜的头韵。不管你怎么称呼它,它的模式看起来都是这样的:
- 安排:设置您将要使用的测试装置、对象或组件
- Act:执行某些操作,例如调用函数或单击按钮
- 断言:断言预期的行为或输出发生了
在 React 世界中,测试简单的切换按钮组件时应用此模式可能如下所示:
我们通过渲染组件,将代码和操作全部安排在同一行ToggleButton
。然后,我们对输出进行断言,确保按钮渲染到 DOM 中,并且按钮的文本在屏幕上可见。
更复杂的例子可能如下所示:
这里我们通过创建一个状态组件来组织代码,该组件允许切换按钮的开关状态。我们通过渲染组件来执行操作。然后,我们断言按钮最初处于关闭状态。接下来,我们再次通过点击按钮执行操作,然后再次断言按钮现在处于打开状态。为了更准确起见,我们再次通过点击按钮执行操作,并通过验证按钮是否恢复为关闭状态再次断言。
这里需要注意的是,通常情况下,你应该只在每个测试开始时为“安排”阶段编写代码。之后,你可以在“执行”和“断言”的迭代之间循环。但是,如果你在测试后期发现自己又回到了“安排”阶段,这可能表明你正在测试第二个概念,应该将其移至单独的测试。稍后会详细介绍。
测试对象构建器
测试对象构建器是一些方法、类或构造函数,可用于创建常用对象。例如,您可能经常使用一个User
包含特定用户各种数据的对象。这些数据可能包括名字、姓氏、电子邮件地址、电话号码、邮寄地址、职位、应用权限等等。
在每个测试中创建一个新User
对象很容易就需要几行代码,导致测试文件长达数百行,非常繁琐。相反,我们可以通过创建一个辅助测试对象构建器方法来返回一个新User
对象,从而保持测试代码的 DRY 原则。更妙的是,当我们需要更明确地指定对象中使用的属性时,我们可以允许覆盖默认值。
我发现一个特别有用的库是faker.js这个npm 包。我们可以用这个包为各种不同的字段生成模拟数据,例如firstName
、jobTitle
、phoneNumber
等等。
考虑这个User
测试对象构建器的例子:
我们的buildUser
方法返回一个代表用户的普通对象。然后,我们可以buildUser
在测试文件中使用此方法来创建默认具有随机值的用户(例如user1
用户),或者创建具有我们指定的特定值的用户(例如user2
用户)。
每次测试评估一个概念
每个测试应该只验证一件事。不要试图在同一个测试中测试多项内容。例如,一个糟糕的日期选择器组件测试可能会写成“以各种状态渲染”,然后渲染八个不同的日期选择器来说明差异。这样的测试做得太多了。更好的测试应该更具体,例如“当用户点击文本输入框时渲染日期选择器”。
测试应该快速
运行缓慢的测试套件非常痛苦。更糟糕的是,当缓慢的测试套件是可选的,或者没有作为持续集成管道的一部分强制执行时,开发人员往往会选择不运行这些测试套件。没有人喜欢等待。
另一方面,快速测试套件可以在编写生产代码时持续运行。这种短反馈循环使您能够更快、更自信地进行开发。快速测试套件还能促进测试驱动开发等编程范式的发展。
在 JavaScript 世界中,watch
在开发时以模式运行 Jest 测试会改变游戏规则。
测试应该独立
测试应该能够按任意顺序运行。换句话说,任何给定的测试都不应依赖于它之前的测试。如果您在测试文件中的测试之间没有仔细地进行适当的拆卸或清理,最终可能会在一个测试中修改全局变量,进而影响后续测试。这可能会导致意外的行为和令人头疼的问题。当一个测试在单独运行时通过,但在作为测试套件的一部分运行时失败时,这总是一次有趣的调试冒险。
如果您使用 Jest,则设置和拆卸通常在beforeEach
和afterEach
代码块中完成。另外,请记住,每个测试文件都有自己的 实例JSDOM
,但同一文件中的测试共享同一个JSDOM
实例。
测试应该是可重复的
测试应该能够在任何环境中运行。如果测试套件在我的机器上通过,它也应该在你的机器上通过。这也意味着它应该在持续集成 (CI) 管道中通过。当测试可重复时,就不会出现测试在一个环境中通过而在另一个环境中失败的情况。这样的不稳定性会降低你对测试的信心。
测试应该是自我验证的
测试应该返回布尔值。测试要么通过,要么失败。你不应该需要人工来解释测试结果。这也是快照测试糟糕且应该避免的众多原因之一。
快照测试不会告诉你正确的输出应该是什么,它只会告诉你有些地方发生了变化。作为开发人员,你需要判断快照的更改是故意的,还是需要解决的错误。但通常情况下,开发人员会盲目地接受快照的更改,并认为新的快照是正确的。
测试应该及时编写
测试应该与生产代码同时编写。如果您是测试驱动开发的倡导者,那么您会认为测试应该在生产代码之前编写。如果您不那么严格,那么您可能会在生产代码之后不久编写测试。这两种方法都比几个月后再编写测试来提高代码库的代码覆盖率要好得多。
确保测试在应该失败的时候失败
您是否遇到过一些测试,其测试内容与其声明不符?这些测试可能通过了,但它肯定没有测试任何有意义的内容,也没有测试其声明的预期用途。这样的测试会造成一种虚假的自信感。毕竟,您的测试套件已经通过了!
请考虑一下马丁·福勒的这句话:
当我编写测试时,我喜欢看到测试至少失败一次。
这句话真是妙极了!要验证测试是否正常工作,只需对测试代码或生产代码稍加修改,将输出改为故意错误的内容即可。如果测试失败了,那就太好了!(当然,在进行完这项完整性检查后,别忘了把测试改回来,让它再次通过。)
记住要测试你的边缘情况
只测试快乐路径是新手常犯的错误。除了确保正常行为有效之外,还要尝试考虑可能出错的地方。如果有人给你的函数提供了无效的参数怎么办?或者可能是意外的数据类型怎么办?
考虑这个示例场景:您正在编写一个函数,该函数根据三角形三边的长度值返回三角形的类型。
我们将该函数称为triangleType
,它将具有三个参数,以便函数签名如下所示:triangleType(side1, side2, side3)
。
您会在什么情况下测试这样的函数?
最显而易见的测试用例可能是检查它能否正确识别有效的等边三角形、等腰三角形和不等边三角形。你的测试用例可能如下所示:
triangleType(4, 4, 4) // Equilateral Triangle
triangleType(6, 7, 6) // Isosceles Triangle
triangleType(6, 7, 8) // Scalene Triangle
有趣的是,测试这三种情况甚至可以根据函数的当前实现提供 100% 的代码覆盖率。但是,仅靠这三个测试是不够的。
例如,如果函数中所有元素都为零,会怎么样?那不是一个三角形,而是一个点。但是函数会将其识别为等边三角形,因为所有边都相等。
如果函数传入负数会怎么样?三角形的长度不能为负。这毫无意义。
或者,如果其中两条边比第三条边短很多会怎么样?那么两条边就不会相连,也就不存在三角形了。
这三个额外的测试用例可能如下所示:
triangleType(0, 0, 0) // Not a triangle
triangleType(-6, -7, -8) // Not a triangle
triangleType(5, 3, 100) // Not a triangle
正如您所看到的,测试代码中的快乐路径之外的更多内容也至关重要。
测试你最担心出错的事情
我喜欢追求 100% 的测试覆盖率。但重要的是,不要对这个数字过于武断。存在收益递减规律,每增加一个测试,其价值就会越来越小。如果你的代码覆盖率是 95%,那么最后 5% 的代码覆盖率可能就不值得了。并非所有东西都值得测试。
测试应用程序的关键部分至关重要。您最担心代码中哪些部分会出错?首先,请专注于为核心功能编写完善的测试。然后,编写额外的测试来覆盖不太重要的路径。但在此过程中,请记住将测试重点放在特定的行为和产品需求上,而不仅仅是覆盖最后那行难以触及的代码。
概括
你成功了!如果你需要快速复习一下本文介绍的所有内容,以下是我提供的单元测试技巧和窍门,可以帮助你写出干净的代码:
- 使用Arrange-Act-Assert模式构建您的测试。
- 使用测试对象构建器可以轻松对常用对象进行测试设置。
- 每次测试评估一个概念。
- 首先——测试应该快速、独立、可重复、自我验证和及时。
- 确保测试在应该失败的时候失败。
- 记住你的界限和边缘情况。
- 测试您最担心出错的事情。
感谢您的阅读,祝您编码愉快!
文章来源:https://dev.to/thawkin3/clean-code-with-unit-tests-tips-and-tricks-for-keeping-your-test-suites-clean-483l