Skip to content

Files

Latest commit

2cab597 · Oct 5, 2021

History

History
276 lines (169 loc) · 20.1 KB

File metadata and controls

276 lines (169 loc) · 20.1 KB

十一、把它们放在一起

“如果你总是做你一直做的事,那么你将永远得到你一直得到的。”

——阿尔伯特·爱因斯坦

我们经历了大量的理论和更多的实践。整个旅程就像一列高速行驶的火车,我们几乎没有机会重复我们学到的东西。没有时间休息。

好消息是,现在是反思的时候了。我们将总结所学到的一切,并进行 TDD 最佳实践。其中一些已经提到,而另一些将是新的。

本章涵盖的主题包括:

  • 简而言之,TDD
  • 常见约定和良好实践,例如命名测试中的约定和良好实践
  • 工具
  • 下一步

简而言之,TDD

红绿重构是 TDD 的支柱,它将 TDD 包装成一个短而可重复的循环。我们所说的短,是指非常短。每个阶段的时间通常以分钟为单位,如果不是秒的话。编写一个测试,看到它失败,编写足够的实现代码使最后一个测试通过,运行所有测试,并进入绿色阶段。一旦编写了最低限度的代码,以通过测试的形式保证了安全性,就应该重构代码,直到它达到我们所希望的程度。在此阶段,测试应始终通过。在进行重构时,既不能引入新功能,也不能引入新测试。在如此短的时间内完成所有这一切通常是可怕的,或者听起来不可能。我们希望,通过我们一起做的练习,您的技能有所提高,信心和速度也有所提高。

虽然在TDD中有测试这个词,但这不是主要的好处,也不是目的。TDD 首先是一种更好的代码设计方法的概念。除此之外,我们还将进行一些测试,这些测试应用于持续检查应用程序是否继续按预期工作。

以前经常提到速度的重要性。虽然我们越来越精通 TDD 可以部分实现这一点,但另一个贡献者是测试替身(模仿、桩、间谍等)。通过这些,我们可以消除对外部依赖关系的需求,如数据库、文件系统、第三方服务等。

TDD 的其他好处是什么?文档就是其中之一。由于代码本身是我们正在处理的应用程序的唯一准确且始终是最新的表示形式,因此当我们需要更好地理解一段代码的功能时,使用 TDD 编写的规范(也是代码)是我们应该首先求助的地方。

设计怎么样?您注意到 TDD 如何生成设计得更好的代码。随着我们从一个规范到另一个规范的发展,TDD 倾向于出现,而不是预先定义设计。同时,易于测试的代码是设计良好的代码。测试迫使我们应用一些编码最佳实践。

我们还了解到,TDD 不需要仅在小型单元(方法)上进行实践。它还可以在更高的级别上使用,重点是可以跨越多个方法、类甚至应用程序和系统的特性或行为。在如此高的层次上实践的 TDD 形式之一是行为驱动开发BDD)。与 TDD 不同,BDD 是基于开发人员为开发人员所做的单元测试,BDD 几乎可以被组织中的每个人使用。因为它处理行为,并且是用自然(普遍存在的)语言编写的,所以测试人员、经理、业务代表和其他人可以参与创建并在以后将其用作参考。

我们将遗留代码定义为没有测试的代码。我们面临着遗留代码摆在我们面前的一些挑战,并学习了一些可用于使其可测试的技术。

考虑到所有这些,让我们来看一下 TDD 最佳实践。

最佳做法

编码最佳实践是软件开发社区随时间发展起来的一组非正式规则,有助于提高软件质量。虽然每个应用程序都需要一定程度的创造性和独创性(毕竟,我们正在尝试构建新的或更好的东西),但编码实践可以帮助我们避免其他人面临的一些问题。如果您刚刚开始使用 TDD,那么最好应用其他人生成的一些(如果不是全部)最佳实践。

为便于对 TDD 最佳实践进行分类,我们将其分为四类:

  • 命名约定
  • 过程
  • 开发实践
  • 工具

正如您将看到的,并非所有这些都是 TDD 独有的。由于 TDD 的很大一部分是由编写测试组成的,所以下面几节中介绍的许多最佳实践通常适用于测试,而其他的则与一般的编码最佳实践有关。无论来源如何,在练习 TDD 时,所有这些都是有用的。

以一定程度的怀疑态度接受建议。成为一名优秀的程序员不仅需要知道如何编码,还需要能够决定哪种实践、框架或风格最适合项目和团队。敏捷不是要遵循别人的规则,而是要知道如何适应环境,选择适合团队和项目的最佳工具和实践。

命名约定

命名约定有助于更好地组织测试,以便开发人员更容易找到他们想要的东西。另一个好处是,许多工具都希望遵循这些约定。目前使用的命名约定有很多,这里介绍的只是沧海一粟。逻辑是任何命名约定都比没有好。最重要的是,团队中的每个人都知道正在使用哪些约定,并且对它们感到满意。选择更受欢迎的约定有一个优势,即新加入团队的人可以快速跟上进度,因为他们可以利用现有的知识找到自己的出路。

将实现与测试代码分开。 好处:避免意外将测试与生产二进制文件打包在一起;许多构建工具期望测试位于某个源目录中。

通常的做法是至少有两个源目录。实现代码应位于src/main/java中,测试代码应位于src/test/java中。在更大的项目中,源目录的数量可以增加,但是实现和测试之间的分离应该保持原样。

Gradle 和 Maven 等构建工具期望源目录分离以及命名约定。

您可能已经注意到,我们在本书中使用的build.gradle文件没有明确指定要测试什么,也没有指定要使用什么类来创建.jar文件。Gradle 假设测试在src/test/java中,应该打包到 JAR 文件中的实现代码在src/main/java中。

将测试类与实现放在同一个包中。 好处:知道测试和代码在同一个包中有助于更快地找到代码。

正如前面的实践中所述,即使包是相同的,类也位于不同的源目录中。

本书中的所有练习都遵循这一惯例。

以与测试类相似的方式命名测试类。 好处:知道测试与正在测试的类具有相似的名称有助于更快地找到类。

一种常用的做法是将测试命名为与实现类相同的名称,后缀为Test。例如,如果实现类是TickTackToe,那么测试类应该是TickTackToeTest

然而,在所有情况下,除了我们在整个重构练习中使用的那些之外,我们更喜欢后缀Spec。它有助于明确区分测试方法主要是作为一种指定将要开发的内容的方式创建的。测试是这些规范的重要子产品。

对测试方法使用描述性名称。

好处:它有助于理解测试的目标。

当试图找出某些测试失败的原因,或者当覆盖率应该随着更多测试而增加时,使用描述测试的方法名称是有益的。测试前应明确设置的条件、执行的操作以及预期结果。

命名测试方法有很多不同的方法,我们首选的方法是使用 BDD 场景中使用的Given/When/Then语法命名它们。Given描述(预)条件,When描述行动,Then描述预期结果。如果测试没有先决条件(通常使用@Before@BeforeClass注释设置),Given可以跳过。

让我们看看我们为 Tic Tac Toe 应用程序创建的一个规范:

    @Test 
    public void whenPlayAndWholeHorizontalLineThenWinner() { 
        ticTacToe.play(1, 1); // X 
        ticTacToe.play(1, 2); // O 
        ticTacToe.play(2, 1); // X 
        ticTacToe.play(2, 2); // O 
        String actual = ticTacToe.play(3, 1); // X 
        assertEquals("X is the winner", actual); 
    } 

只需阅读该方法的名称,我们就可以理解它是关于什么的。当我们比赛时,整个水平线、垂直线和对角线都被填充,那么我们就有了赢家。

不要仅仅依靠评论来提供有关测试目标的信息。从您喜爱的 IDE 执行测试时,注释不会出现,也不会出现在 CI 或生成工具生成的报告中。

过程

TDD 过程是一组核心实践。TDD 的成功实施取决于本节所述的实践。

在编写实现代码之前先编写一个测试。

好处:确保编写可测试的代码; 它确保每一行代码都有为其编写的测试。

通过首先编写或修改测试,开发人员在开始处理实现代码之前,会关注需求。这是与实现完成后编写测试相比的主要区别。另外一个好处是,通过先编写测试,我们避免了测试作为质量检查QC)而不是质量保证QA)工作的危险。我们正在努力确保质量是内在的,而不是稍后检查我们是否达到了质量目标。

仅在测试失败时编写新代码。

好处:它确认测试在没有实现的情况下不起作用。

如果测试通过而不需要编写或修改实现代码,那么要么功能已经实现,要么测试有缺陷。如果确实缺少新功能,那么测试总是通过,因此是无用的。由于预期的原因,测试应该失败。尽管无法保证测试验证的是正确的,但由于预期的原因,首先失败,验证正确的信心应该很高。

每次实现代码更改时重新运行所有测试。

好处:它确保没有由代码更改引起的意外副作用。

每次实现代码的任何部分发生更改时,都应该运行所有测试。理想情况下,测试可以快速执行,并且可以由开发人员在本地运行。一旦代码提交到版本控制,所有测试都应该再次运行,以确保不会因为代码合并而出现问题。当不止一个开发人员在处理代码时,这一点尤为重要。应使用持续集成CI)工具从存储库中提取代码、编译代码并运行测试,例如:

在编写新测试之前,所有测试都应该通过。

好处:焦点保持在一个小的工作单元上; 实现代码(几乎)始终处于工作状态。

有时,在实际实现之前编写多个测试是很有诱惑力的。在其他情况下,开发人员忽略现有测试检测到的问题,转而使用新功能。应尽可能避免这种情况。在大多数情况下,违反这一规则只会导致需要支付利息的技术债务。TDD 的目标之一是实现代码(几乎)总是按预期工作。一些项目,由于达到交付日期或维持预算的压力,打破了这一规则,将时间花在新特性上,将修复与失败测试相关的代码的任务留给以后完成。这些项目通常会推迟不可避免的事情。

只有在所有测试都通过后才能重构。

优点:这种重构是安全的。

如果所有可能受到影响的实现代码都有测试并且都通过了,那么重构是相对安全的。在大多数情况下,不需要进行新的测试。对现有测试进行小的修改就足够了。重构的预期结果是使所有测试在代码修改前后都通过。

开发实践

本节列出的实践集中于编写测试的最佳方法。编写最简单的代码以通过测试,因为它可以确保更清晰的设计并避免不必要的功能。

其思想是,实现越简单,维护产品就越好、越容易。这个想法遵循了保持简单和愚蠢KISS)原则。这表明,如果系统保持简单而不是复杂,那么大多数系统工作得最好;因此,简单性应该是设计中的一个关键目标,并且应该避免不必要的复杂性。先写断言,然后再行动,因为它很早就阐明了需求和测试的目的。

一旦编写了断言,测试的目的就明确了,开发人员可以专注于完成该断言的代码,以及以后的实际实现。最小化每个测试中的断言,因为它避免了断言轮盘赌;它允许执行更多的资产。

如果在一个测试方法中使用了多个断言,那么可能很难判断是哪个断言导致了测试失败。当测试作为 CI 过程的一部分执行时,这种情况尤其常见。如果问题无法在开发人员的机器上重现(如果问题是由环境问题引起的,则可能是这种情况),则修复问题可能会很困难且耗时。

当一个断言失败时,该测试方法的执行停止。如果该方法中有其他断言,则它们将不会运行,并且可以在调试中使用的信息将丢失。

最后但并非最不重要的一点是,拥有多个断言会导致对测试目标的混淆。

这种做法并不意味着每个试验方法只能有一个assert。如果有其他断言测试相同的逻辑条件或功能单元,则可以在相同的方法中使用它们。

让我们看几个例子:

@Test 

public final void whenOneNumberIsUsedThenReturnValueIsThatSameNumber() { 
    Assert.assertEquals(3, StringCalculator.add("3")); 
} 

@Test 
public final void whenTwoNumbersAreUsedThenReturnValueIsTheirSum() { 
    Assert.assertEquals(3+6, StringCalculator.add("3,6")); 
} 

前面的代码包含两个明确定义测试目标的规范。通过阅读方法名称并查看assert,应该可以清楚地了解正在测试的内容。考虑以下示例:

@Test 
public final void whenNegativeNumbersAreUsedThenRuntimeExceptionIsThrown() { 
    RuntimeException exception = null; 
    try { 
        StringCalculator.add("3,-6,15,-18,46,33"); 
    } catch (RuntimeException e) { 
        exception = e; 
    } 
    Assert.assertNotNull("Exception was not thrown", exception); 
    Assert.assertEquals("Negatives not allowed: [-6, -18]",  
            exception.getMessage()); 
} 

本规范有多个assert,但它们正在测试相同的逻辑功能单元。第一个assert确认异常存在,第二个确认其消息正确。当在一个测试方法中使用多个断言时,它们都应该包含解释失败的消息。这样,调试失败的assert就更容易了。对于每个测试方法一个assert的情况,消息是受欢迎的,但不是必需的,因为从方法名称中应该清楚地知道测试的目标是什么:

@Test 
public final void whenAddIsUsedThenItWorks() { 
    Assert.assertEquals(0, StringCalculator.add("")); 
    Assert.assertEquals(3, StringCalculator.add("3")); 
    Assert.assertEquals(3+6, StringCalculator.add("3,6")); 
    Assert.assertEquals(3+6+15+18+46+33, 
            StringCalculator.add("3,6,15,18,46,33")); 
    Assert.assertEquals(3+6+15, StringCalculator.add("3,6n15")); 
    Assert.assertEquals(3+6+15, 
            StringCalculator.add("//;n3;6;15"));    Assert.assertEquals(3+1000+6, 
            StringCalculator.add("3,1000,1001,6,1234")); 
} 

这个测试有很多断言。目前还不清楚该功能是什么,如果其中一个出现故障,则不知道其余功能是否正常。当通过某些 CI 工具执行此测试时,可能很难理解失败。

不要在测试之间引入依赖关系。

优点:测试以任何顺序独立运行,无论是全部运行还是仅运行一个子集。

每个测试都应该独立于其他测试。开发人员应该能够执行任何单个测试、一组测试或所有测试。通常,由于测试运行程序的设计,无法保证测试将按任何特定顺序执行。如果测试之间存在依赖关系,那么随着新测试的引入,它们可能很容易被打破。

测试应该运行得很快。

优点:这些测试经常使用。

如果运行测试需要很多时间,开发人员将停止使用测试,或者只运行与所做更改相关的一小部分测试。快速测试的好处是,除了提高使用率外,还可以提供快速反馈。问题越早被发现,就越容易解决。关于产生问题的代码的知识仍然是新鲜的。如果开发人员在等待测试执行完成时已经开始开发下一个特性,他们可能会决定推迟解决问题,直到开发出新特性。另一方面,如果他们放弃当前的工作来修复 bug,那么在上下文切换中就会浪费时间。

测试应该很快,开发人员可以在每次更改后运行所有测试,而不会感到无聊或沮丧。

使用测试替身。

好处:这减少了代码依赖性并且测试执行会更快。

模拟是快速执行测试和集中于单个功能单元的能力的先决条件。通过模拟正在测试的方法外部的依赖关系,开发人员可以专注于手头的任务,而无需花费时间进行设置。在大型团队的情况下,这些依赖关系甚至可能无法开发。此外,没有模拟的测试执行往往很慢。数据库、其他产品、服务等是模拟的最佳候选对象。

使用设置和拆卸方法。

优点:这允许在类或每个方法之前和之后执行设置和拆卸代码。

在许多情况下,一些代码需要在测试类之前或类中的每个方法之前执行。为此,JUnit 有@BeforeClass@Before注释,应该用作设置阶段。@BeforeClass在加载类之前(在运行第一个测试方法之前)执行关联的方法。 @Before在每个测试运行之前执行相关的方法。当测试需要某些先决条件时,应使用这两种方法。最常见的例子是在数据库(希望在内存中)中设置测试数据。

另一端是@After@AfterClass注释,应该用作拆卸阶段。它们的主要目的是销毁在设置阶段或测试本身创建的数据或状态。如前一个实践中所述,每个测试应独立于其他测试。此外,任何测试都不应受到其他测试的影响。拆卸阶段有助于维护系统,就像以前没有执行测试一样。

不要在测试中使用基类。

优点:它提供了测试的清晰度。

开发人员通常以与实现相同的方式处理测试代码。一个常见的错误是创建通过测试扩展的基类。这种做法以牺牲测试清晰度为代价避免了代码重复。如果可能,应避免或限制用于测试的基类。为了理解测试背后的逻辑,必须从测试类导航到其父类、父类的父类等等,这通常会带来不必要的混乱。测试的清晰性应该比避免代码重复更重要。

工具

TDD、编码和测试通常严重依赖于其他工具和过程。以下是一些最重要的问题。它们中的每一个都是本书无法探讨的大主题,因此将仅对它们进行简要描述。

代码覆盖率和 CI。

好处:它保证一切都经过测试。

在确定所有代码、分支和复杂性都经过测试时,代码覆盖率实践和工具非常有价值。其中一些工具如下:

CI 工具是除最琐碎的项目外所有项目的必备工具。一些最常用的工具包括:

将 TDD 与 BDD 一起使用。

优点:涵盖了开发人员单元测试和面向客户的功能测试。

虽然带有单元测试的 TDD 是一种很好的实践,但在许多情况下,它并不能提供项目所需的所有测试。TDD 开发速度快,有助于设计过程,并通过快速反馈提供信心。另一方面,BDD 更适合于集成和功能测试,通过叙述为需求收集提供了更好的过程,并且是通过场景与客户沟通的更好方式。两者都应该使用,它们一起提供一个涉及所有利益相关者和团队成员的完整流程。TDD(基于单元测试)和 BDD 应该驱动开发过程。我们建议使用 TDD 进行高代码覆盖率和快速反馈,使用 BDD 进行自动验收测试。虽然 TDD 主要面向白盒,但 BDD 通常针对黑盒测试。TDD 和 BDD 都试图将重点放在 QA 而不是 QC 上。

总结

在本章中,我们首先简要介绍了 TDD。我们了解了有助于提高软件质量的四种最佳实践

进入最后一章,我们将介绍 CI 和连续交付的概念,并通过一个示例强调 TDD 在整个管道过程中的重要性。