测试驱动开发中的最佳实践

通过遵循这些 TDD 最佳实践,确保您正在生成非常高质量的代码。
152 位读者喜欢这个。
magnifying glass on computer screen

Opensource.com

在我之前关于 测试驱动开发 (TDD) 和变异测试 的系列文章中,我演示了在构建解决方案时依赖示例的好处。 这就引出了一个问题:“依赖示例”是什么意思?

在该系列中,我描述了我在构建解决方案时的一个期望,即确定是白天还是夜晚。 我提供了一个具体的小时示例,我认为该小时属于白天类别。 我创建了一个名为 dayHourDateTime 变量,并为其赋予了 2019 年 8 月 8 日,7 小时,0 分钟,0 秒 的特定值。

我的逻辑(或推理方式)是:“当系统收到通知,时间恰好是 2019 年 8 月 8 日上午 7 点时,我期望系统会执行必要的计算并返回 白天 值。”

有了这样一个具体的例子,创建单元测试 (Given7amReturnDaylight) 非常容易。 然后我运行了测试,并观察到我的单元测试失败,这让我有机会解决这个早期故障。

迭代是解决方案

TDD(以及敏捷)一个非常重要的方面是,除非您进行迭代,否则不可能获得可接受的解决方案。 TDD 是一门基于无情迭代过程的专业学科。 非常重要的是要注意,它强制每次迭代都必须以微小的失败开始。 这种微小的失败只有一个目的:征求即时反馈。 这种即时反馈确保我们可以快速缩小想要解决方案和获得解决方案之间的差距。

迭代提供了一个机会,通过尽早失败来征求即时反馈。 因为这种失败是快速的(即,它是微小的失败),所以它不会令人震惊; 即使我们失败了,我们也可以保持冷静,因为我们知道修复失败很容易。 来自该失败的反馈将指导我们朝着修复失败的方向前进。

冲洗,重复,直到我们完全缩小差距并交付完全满足期望的解决方案(但请记住,期望也必须是微小的期望)。

为什么是微小的?

这种方法通常感觉非常没有野心。 在 TDD(和敏捷)中,最好选择一个微小、几乎微不足道的挑战,然后通过首先失败,然后迭代直到我们解决这个微不足道的挑战来完成 TDD 的歌舞。 习惯于更实质性、更重要的工程和问题解决的人往往会觉得这样的练习有损他们的能力水平。

敏捷哲学的基石之一是,将问题空间缩小到多个尽可能小的表面积。 正如 Robert C. Martin 所说

“敏捷是关于小型编程团队做小事的微小想法”

但是,一系列不起眼的、平庸的、微小的、几乎微不足道的微小胜利,如何才能使我们达到大规模的解决方案?

这就是复杂而精细的系统思考发挥作用的地方。 在构建系统时,始终存在最终得到可怕的“巨石”的风险。 巨石是基于紧密耦合原则构建的系统。 巨石的任何部分都高度依赖于同一巨石的许多其他部分。 这种安排使巨石非常脆弱、不可靠且难以操作、维护、排除故障和修复。

避免这种陷阱的唯一方法是最小化或更好地完全消除耦合。 与其投入巨大的精力来构建将组装成系统的精细部件,不如采取谦虚的、婴儿般的步骤来构建微小的、微型部件。 这些微型部件本身的能力非常有限,并且由于这种安排,将不依赖于其他组件。 这将最小化甚至消除任何耦合。

构建有用、精细的系统的预期最终目标是从通用、完全独立的组件集合中组合它。 每个组件越通用,生成的系统就越健壮、有弹性和灵活。 此外,拥有一组通用组件使它们能够通过重新配置这些组件来重新用于构建全新的系统。

考虑一个用乐高积木制成的玩具城堡。 如果我们从那个城堡中几乎拿起任何一块积木并单独检查它,我们将无法在该积木上找到任何指定它是用于建造城堡的乐高积木的东西。 积木本身足够通用,这使其适用于建造其他装置,例如玩具汽车、玩具飞机、玩具船等。 这就是拥有通用组件的力量。

TDD 是一种经过验证的学科,用于交付通用的、独立的和自主的组件,这些组件可以安全地用于快速组装大型、复杂的系统。 与敏捷一样,TDD 专注于微活动。 并且由于敏捷基于称为“全团队”的基本原则,因此此处说明的谦虚方法在指定业务示例时也很重要。 如果用于构建组件的示例不够适度,则很难满足期望。 因此,期望必须是谦虚的,这使得结果示例同样谦虚。

例如,如果全团队的成员(请求者)向开发人员提供了一个期望和一个示例,内容如下

“在处理订单时,请确保为忠诚客户的订单或超过一定金额的订单或两者都应用适当的折扣。”

开发人员应该认识到这个例子太有野心了。 这不是一个谦虚的期望。 如果您愿意,它不够微小。 开发人员应始终努力引导请求者在制作示例时更加具体和微观。 矛盾的是,示例越具体,最终解决方案就越通用。

一个更好、更有效的期望和示例是

“对于大于 100.00 美元的订单,折扣为 18.00 美元。”

或者

“对于大于 100.00 美元且由已经下过三个订单的客户下的订单,折扣为 25.00 美元。”

这种微小的例子使它们很容易变成自动化的微小期望(阅读:单元测试)。 这样的期望会让我们失败,然后我们会振作起来并迭代,直到我们交付解决方案——一个健壮的、通用的组件,它知道如何根据全团队提供的微小例子来计算折扣。

编写高质量的单元测试

仅仅编写单元测试而不考虑其质量是一种徒劳的努力。 马虎编写的单元测试将导致臃肿、紧密耦合的代码。 这样的代码是脆弱的、难以推理的,并且通常几乎不可能修复。

我们需要为编写高质量的单元测试制定一些基本规则。 这些基本规则将帮助我们在构建健壮、可靠的解决方案方面取得快速进展。 最简单的方法是以首字母缩略词的形式引入助记符:FIRST,它说单元测试必须是

  • F = 快速 (Fast)
  • I = 独立 (Independent)
  • R = 可重复 (Repeatable)
  • S = 自验证 (Self-validating)
  • T = 彻底 (Thorough)

快速 (Fast)

由于单元测试描述了一个微小的例子,它应该期望从实现的代码中进行非常简单的处理。 这意味着每个单元测试的运行速度都应该非常快。

独立 (Independent)

由于单元测试描述了一个微小的例子,它应该描述一个非常简单的过程,该过程不依赖于任何其他单元测试。

可重复 (Repeatable)

由于单元测试不依赖于任何其他单元测试,因此它必须是完全可重复的。 这意味着每次运行某个单元测试时,它都会产生与上次运行相同的结果。 单元测试运行的次数或运行顺序都不应影响预期输出。

自验证 (Self-validating)

当单元测试运行时,测试结果应立即可见。 不应期望开发人员去寻找其他信息来源来找出他们的单元测试是失败还是通过。

彻底 (Thorough)

单元测试应描述微小示例中定义的所有期望。

结构良好的单元测试

单元测试是代码。 与任何其他代码一样,单元测试也需要结构良好。 交付马虎、混乱的单元测试是不可接受的。 适用于控制干净实现代码的规则的所有原则都同样适用于单元测试。

编写可靠高质量代码的经过时间考验和验证的方法是基于称为 SOLID 的干净代码原则。 这个首字母缩略词帮助我们记住五个非常重要的原则

  • S = 单一职责原则 (Single responsibility principle)
  • O = 开闭原则 (Open–closed principle)
  • L = 里氏替换原则 (Liskov substitution principle)
  • I = 接口隔离原则 (Interface segregation principle)
  • D = 依赖倒置原则 (Dependency inversion principle)

单一职责原则 (Single responsibility principle)

每个组件必须仅负责执行一个操作。 此原则在此模因中得到说明

Sign illustrating single-responsibility principle

抽化粪池是一项必须与注满游泳池分开进行的操作。

应用于单元测试,此原则确保每个单元测试验证一个且仅验证一个期望。 从技术角度来看,这意味着每个单元测试必须有一个且仅有一个 Assert 语句。

开闭原则 (Open–closed principle)

此原则指出,组件应该对扩展开放,但对任何修改关闭。

Open-closed principle

应用于单元测试,此原则确保我们不会在该单元测试中实现对现有单元测试的更改。 相反,我们必须编写一个全新的单元测试来实现更改。

里氏替换原则 (Liskov substitution principle)

此原则为确定哪个抽象级别可能适合该解决方案提供了指南。

Liskov substitution principle

应用于单元测试,此原则指导我们避免与依赖于底层计算环境(如数据库、磁盘、网络等)的依赖项紧密耦合。

接口隔离原则 (Interface segregation principle)

此原则提醒我们不要膨胀 API。 当子系统需要协作来完成任务时,它们应该通过接口进行通信。 但是这些接口不能膨胀。 如果需要新的功能,请不要将其添加到已定义的接口; 而是制作一个全新的接口。

Interface segregation principle

应用于单元测试,从接口中删除膨胀有助于我们制作更具体的单元测试,这反过来又会产生更通用的组件。

依赖倒置原则 (Dependency inversion principle)

此原则指出,我们应该控制我们的依赖项,而不是依赖项控制我们。 如果需要使用另一个组件的服务,而不是负责在我们正在构建的组件中实例化该组件,则必须将其注入到我们的组件中。

Dependency inversion principle

应用于单元测试,此原则有助于将意图与实现分离。 我们必须努力仅注入那些已充分抽象化的依赖项。 这种方法对于确保单元测试不与集成测试混合非常重要。

测试测试 (Testing the tests)

最后,即使我们设法生成了满足 FIRST 原则的结构良好的单元测试,也不能保证我们交付了可靠的解决方案。 TDD 最佳实践依赖于构建组件/服务时的正确事件顺序; 我们总是并且总是被期望提供我们期望的描述(在微小示例中提供)。 只有在单元测试中描述了这些期望之后,我们才能继续编写实现代码。 但是,在编写实现代码时,可能会发生并且经常发生两种不良副作用

  1. 实现的代码使单元测试通过,但是它们以一种复杂的方式编写,使用了不必要的复杂逻辑
  2. 实现的代码在单元测试编写完后被标记为 AFTER

在第一种情况下,即使所有单元测试都通过,变异测试也会发现一些突变体幸存下来。 正如我在通过示例进行变异测试:从脆弱的 TDD 发展而来中所解释的那样,这是一个非常不受欢迎的情况,因为它意味着解决方案不必要地复杂,因此无法维护。

在第二种情况下,保证所有单元测试都通过,但是代码库中可能很大一部分是由未在任何地方描述的实现代码组成的。 这意味着我们正在处理神秘代码。 在最佳情况下,我们可以将该神秘代码视为枯木并安全地删除它。 但更可能的是,删除此未描述的实现代码将导致一些严重的破坏。 这种破坏表明我们的解决方案工程设计不佳。

结论

TDD 最佳实践源于称为 极限编程(简称 XP)的经过时间考验的方法。 XP 的基石之一是基于 3C

  1. 卡片 (Card): 一张小卡片简要说明意图(例如,“审核客户请求”)。
  2. 对话 (Conversation): 卡片成为对话的票据。 整个团队聚集在一起讨论“审核客户请求”。 那是什么意思? 我们是否有足够的信息/知识在此增量中交付“审核客户请求”功能? 如果没有,我们如何进一步切分这张卡片?
  3. 具体确认示例 (Concrete confirmation examples): 这包括插入的所有特定值(例如,具体名称、数值、特定日期、任何其他与用例相关的内容)以及预期作为处理输出的所有值。

从这样的微小示例开始,我们编写单元测试。 我们观察单元测试失败,然后使它们通过。 在这样做时,我们遵守并尊重最佳软件工程实践:FIRST 原则、SOLID 原则和变异测试学科(即,杀死所有幸存的突变体)。

这确保了我们的组件和服务交付时具有内置的可靠质量。 衡量这种质量的标准是什么? 很简单——变更成本。 如果交付的代码变更成本很高,则其质量很差。 非常高质量的代码结构良好,以至于变更起来既简单又便宜,同时又不会产生任何变更管理风险。

接下来要阅读什么
标签
User profile image.
Alex 自 1990 年以来一直从事软件开发。他目前的热情是如何将“软”带回软件中。 他坚信,我们的行业已经达到了相当的水平,完全可以实现这个崇高的目标(即将“软”带回软件中)。

3 条评论

好文章。 非常有趣,有很多有用的信息。 感谢分享。

内容丰富的帖子。 我真的很感谢您为编译和分享这篇文章所做的努力。

好文章 :)

Creative Commons License本作品根据 Creative Commons Attribution-Share Alike 4.0 International License 获得许可。
© . All rights reserved.