开始 TDD:安排 - 执行 - 断言
单元测试并没有什么神秘之处。它们只是代码,是用你编写应用程序的相同语言编写的可执行代码。每个单元测试都是你想要编写的代码的首次使用。它调用代码的方式与真实应用程序中的调用方式相同。测试执行该代码,捕获我们关心的所有输出,并检查它们是否符合我们的预期。因为测试以与真实应用程序完全相同的方式使用我们的代码,所以我们可以立即获得关于代码易用性或难用性的反馈。这听起来可能很明显,但它是一个强大的工具,可以帮助我们编写干净且正确的代码。让我们看一个单元测试的示例,并学习如何定义其结构。
定义测试结构
当我们做事情时,遵循模板总是有帮助的,单元测试也不例外。基于克莱斯勒综合薪酬项目(Chrysler Comprehensive Compensation Project)的商业工作,TDD 的发明者 Kent Beck 发现单元测试具有某些共同特征。这被总结为测试代码的推荐结构,称为 Arrange-Act-Assert(准备-执行-断言),简称 AAA。
AAA 的原始定义
AAA 的原始描述可以在 |
为了解释每个部分的作用,让我们来看一个完整的单元测试示例,该测试用于确保用户名以小写形式显示:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
public class UsernameTest {
@Test
public void convertsToLowerCase() {
var username = new Username("SirJakington35179");
String actual = username.asLowerCase();
assertThat(actual).isEqualTo("sirjakington35179");
}
}
首先要注意的是我们的测试类名:UsernameTest
。这是对我们代码读者的第一段故事讲述。我们描述了正在测试的行为领域,在这个例子中是用户名。我们所有的测试,甚至所有的代码,都应该遵循这种故事讲述的方法:我们希望代码的读者理解什么?我们希望他们清楚地看到我们正在解决的问题是什么,以及解决它的代码应该如何被使用。我们希望向他们展示代码是正确的。
单元测试本身是 convertsToLowerCase()
方法。同样,名称描述了我们的期望结果。当代码成功运行时,用户名将被转换为小写。名称有意简单、清晰且具有描述性。该方法带有 JUnit5 测试框架的 @Test
注解。该注解告诉 JUnit 这是一个可以为我们运行的测试。
在 @Test
方法内部,我们可以看到我们的 Arrange-Act-Assert 结构。我们首先 准备(Arrange) 代码运行所需的环境。这包括创建所需的对象、提供必要的配置以及连接任何依赖的对象和函数。有时我们不需要这一步,例如,如果我们正在测试一个简单的独立函数。在我们的示例代码中,Arrange 步骤是创建 username
对象并向构造函数提供名称的那一行代码。然后,它将对象存储在本地变量 username
中,以便在测试方法体中使用。这是 var username = new Username("SirJakington35179");
的第一行。
接下来是 执行(Act) 步骤。这是我们调用被测代码的部分——我们运行该代码。这始终是对被测代码的调用,提供任何必要的参数,并安排捕获结果。在示例中, String actual = username.asLowerCase();
这一行是 Act 步骤。我们调用 username
对象上的 asLowerCase()
方法。它不需要参数,并返回一个简单的 String
对象,其中包含小写文本 sirjakington35179
作为结果。
完成测试的最后一步是 断言(Assert) 步骤。assertThat(actual).isEqualTo("sirjakington35179");
这一行是我们的 Assert 步骤。它使用了 AssertJ 流式断言库中的 assertThat()
方法和 isEqualTo()
方法。它的任务是检查我们从 Act 步骤返回的结果是否符合我们的预期。在这里,我们测试原始名称中的所有大写字母是否已转换为小写。
像这样的单元测试易于编写、易于阅读,并且运行速度非常快。许多这样的测试可以在 1 秒内完成。
JUnit 库是 Java 的行业标准单元测试框架。它为我们提供了一种将 Java 方法注解为单元测试的方式,让我们可以运行所有测试,并直观地显示结果,如下所示在 IntelliJ IDE 窗口中:

我们在这里看到单元测试失败了。测试期望结果为 sirjakington35179
文本字符串,但我们收到了 null
。使用 TDD,我们将完成足够的代码以使测试通过:

我们可以看到,我们对生产代码的更改使该测试通过。它变成了绿色,用流行的术语来说。失败的测试被称为红色测试,通过的测试被称为绿色测试。这是基于流行 IDE 中显示的颜色,而这些颜色又基于交通信号灯。看到所有这些红色测试变为绿色的短迭代过程,既令人惊讶地满足,又增强了我们对工作的信心。测试通过迫使我们从结果反向思考,帮助我们专注于代码的设计。让我们看看这意味着什么。
从结果反向推导
我们立刻注意到的一件事是,使这个测试通过的实际代码是多么不重要。这个测试中的所有内容都是关于定义代码的期望。我们正在围绕代码的用途和我们期望它做的事情设定界限。我们并没有以任何方式限制它如何实现。我们正在从外向内看待代码。任何使我们的测试通过的实现都是可以接受的。
这似乎是学习使用 TDD 的一个转折点。我们中的许多人通过先编写实现来学习编程。我们思考代码将如何工作。我们深入研究特定实现背后的算法和数据结构。然后,作为最后的想法,我们将所有内容包装在某种可调用的接口中。
TDD 将这一点完全颠覆了。我们有意首先设计我们的可调用接口,因为这是代码用户将看到的内容。我们使用测试来精确描述代码将如何设置、如何调用以及我们可以期望它为我们做什么。一旦我们习惯了这种从外到内的设计,TDD 就会非常自然地跟随,并以几种重要方式提高我们的工作效率。让我们回顾一下这些改进是什么。
提高工作流程效率
像这样的单元测试以多种方式提高了我们作为开发者的效率。最明显的是,我们编写的代码已经通过了测试:我们知道它是有效的。我们不必等待手动 QA 过程发现缺陷,然后在未来提出错误报告进行返工。我们现在就发现并修复错误,在将它们发布到主源代码主干之前,更不用说发布给用户了。我们已经为同事记录了我们的意图。如果有人想知道我们的 Username
类是如何工作的,它就在测试中——如何创建对象,可以调用哪些方法,以及我们期望的结果是什么。
单元测试为我们提供了一种隔离运行代码的方式。我们不再被迫重建整个应用程序,运行它,在数据库中设置测试数据条目,登录用户界面,导航到正确的屏幕,然后目视检查代码的输出。我们只需运行测试。这使我们能够执行尚未完全集成到应用程序主干中的代码。这加快了我们的工作速度。我们可以更快地开始,花更多时间开发手头的代码,而减少在繁琐的手动测试和部署过程上的时间。
另一个好处是,这种设计行为提高了我们代码的模块化。通过设计可以小块测试的代码,我们提醒自己编写可以小块执行的代码。这是自 20 世纪 60 年代以来设计的基本方法,至今仍然有效。
本节介绍了我们用于组织每个单元测试的标准结构,但它并不能保证我们会编写一个好的测试。为了实现这一点,每个测试都需要具有特定的属性。FIRST原则 描述了一个好的测试的属性。接下来让我们学习如何应用这些原则。