On the Layout of Tests

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

我不知道你使用的编程语言是什么,但我最近使用Java、Ruby、JavaScript、PHP、Python、C++和Rust编程的经验告诉我,我将试图说服你遵循的原则对于所有语言都是通用的。它涉及到测试文件的命名。对你来说,这可能看起来是一个不太重要的问题,但让我试着证明它并非如此。你如何给测试类命名文件?在src/test/java目录中你会创建多少个测试类文件?如果一个类只在测试中使用而本身不是一个测试,你会把它放在哪里?对于大多数这些问题,你们大多数人的答案可能是“随便!”所以让我们试着找到一个更好的答案。

我的单元测试的主要目的是帮助我编写代码。它们是一个安全网—当我犯错时,它们会捕捉到我。举个例子,假设我回去编辑了几年前编辑过的几个文件,当然,这次编辑我做错了。然后,我运行项目中的所有500个单元测试,…其中有十个测试变成了红色。请注意,我没有说 “失败”,因为就像建筑物周围的安全网一样,失败的测试是没有捕捉到掉落的锤子和刚刚引入的错误bug的测试。因此,其中490个测试失败,但有十个测试成功

Assertions

接下来,我摸了摸脑袋,思考着——我到底做错了什么?我破坏了哪个文件?我只改动了几十行代码。错误到底出在哪里?为了找出答案,我阅读了测试的输出。我希望它们在控制台打印的信息足够描述性,能够帮助我理解问题所在。我不想撤销所有的更改,重新开始,对吧?我想要快速跳转到含有错误的那一行,修复它,重新运行所有500个测试,确保它们全部通过,提交我的更改,然后结束一天的工作。

毋庸置疑,测试断言的描述性消息和测试方法的适当命名是成功的关键。让我们来考虑简单的对象短语,我们在其中添加了一些英语短语,它会神奇地理解哪些是问候语(显然,使用机器学习)。对于这样的一个类来说,这个Java/JUnit5的测试会很糟糕。

import org.junit.jupiter.api.Test;

@Test void test1() { Phrases p = new Phrases(); p.add("Hello, world!"); p.add("London is a capital of Great Britain"); assert(p.greetings().count() == 1); } ```

虽然这个测试要好得多多亏了[Hamcrest](https://www.hamcrest.org)的断言(如何命名测试方法——在[这里](https://stackoverflow.com/questions/155436/)详细解释)的帮助。

```java
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;

@Test void countsSimpleGreetings() { Phrases p = new Phrases(); p.add("Hello, world!"); p.add("London is a capital of Great Britain"); assertThat( "Total count of greetings", p.greetings().count(), equalTo(1) ); } ```

第一段代码将打印出一个相当晦涩的错误信息而第二段代码将对我在刚刚犯下的错误的解决帮助很大该信息将会自我解释我将迅速理解问题所在

## Test Classes

描述性的信息将帮助我理解问题所在然而我会知道问题出现在哪里吗在哪个Java类中实际上不会它是在`Phrases.java`中吗还是在`Greetings.java`后者由`Phrases.greetings()`返回我只能从测试类的*名称*中获取此信息如果它被命名为`PhrasesTest.java`---它捕获的所有错误*很可能*位于`Phrases.java`如果它被命名为`GreetingsTest.java`---...好吧你明白我的意思了

我的观点是测试类的名称并不仅仅是一个名称它是对一个好奇的程序员的一条指令:“去查看源文件你可以通过我的名称推导出源文件的名称只需去掉`Test`后缀。” 如果我尝试按照这个指令去找结果却找不到任何东西我会感到非常沮丧尤其是如果这个项目不是我的我无法从其他地方获取所需的信息测试类的名称是我最后的希望

## Very Long Test Classes

如果一个测试类变得太长怎么办它可能有几十个或更多的测试方法我们不希望一个类变得太大对吗错了一个测试类不是一个类它甚至不是一个实用类它是测试脚本的容器它之所以被称为类是因为Java以及许多其他语言没有其他代码组织工具所以不要担心你的测试类变得过长一个测试类中的5000行代码*根本不是问题*再次强调因为它不是一个类它只是一组测试脚本的集合

## Test Prerequisites (Wrong Way)

很多时候一些类或函数并不是测试但必须在测试之间共享。(我相信你知道共享测试是一种反模式你知道吗?)看看我如何重构上面的单元测试它并不优雅但请耐心等一下!):

class PhrasesTest { @Test void countsSimpleGreetings() { Phrases p = new Phrases(); prepare(p); assertThat( “Total count of greetings”, p.greetings().count(), equalTo(1) ); } private static void prepare(Phrases p) { p.add(“Hello, world!”); p.add(“London is a capital of Great Britain”); } }


在这里,在私有方法`prepare()`中,我有一个方便的类`Phrases`对象的构建器。这个构建器对于其他测试(比如`GreetingsTest`)也可能有用。我不想把它从`PhrasesTest`复制到`GreetingsTest`中。相反,我想把它放在一个可以重用的地方。这将是它的正确位置(`foo`是我们所有类所属的Java包)。

src/ main/ java/ foo/ Phrases.java Greetings.java test/ java/ foo/ support/ FooUtils.java PhrasesTest.java GreetingsTest.java


静态方法`FooUtils.prepare()`现在位于`FooUtils`实用类中(一个糟糕的反模式!),该类位于`foo.support`包中。但是,请注意,它不在`foo`包中,而是在子包中,在实际代码块中没有对应的目录:没有`src/main/java/foo/support`目录。这是给几年后遇到这个仓库的程序员一个明确的信息:所有位于`foo.support`中的类仅属于测试流水线,本身不是测试。

## Test Prerequisites (Right Way)

正如你所知,实用类和私有静态方法是命令式编程的基础。面向对象的世界有更好的选择。特别是JUnit5,它提供了非常优雅的机制来创建测试的前提条件:[测试扩展](https://junit.org/junit5/docs/current/user-guide/#extensions-registration)。我们通过参数提供测试方法所需的一切,这些参数由扩展实例化,例如:

```java
import org.junit.jupiter.api.extension.*;

class PhrasesExtension implements ParameterResolver { @Override public boolean supportsParameter( ParameterContext pctx, ExtensionContext ectx) { return pctx.getParameter().getType() == Phrases.class; } @Override public Object resolveParameter( ParameterContext pctx, ExtensionContext ectx) { Phrases p = new Phrases(); p.add("Hello, world!"); p.add("London is a capital of Great Britain"); return p; } } ```

然后,测试将会是这样的:

```java
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(PhrasesExtension.class) class PhrasesTest { @Test void countsSimpleGreetings(Phrases p) { assertThat( "Total count of greetings", p.greetings().count(), equalTo(1) ); } } ```

现在,测试及其先决条件保存在两个不同的位置,并且它们之间的耦合性不如以前紧密。此外,先决条件可以很容易地被重用。魔术`@ExtendWith`注解可以附加到其他测试中。`PhrasesExtension`的实现可以变得更智能:它可以开始关注测试方法的参数类型以及附加到它的自定义注解(这就是[`@TempDir`](https://junit.org/junit5/docs/5.4.1/api/org/junit/jupiter/api/io/TempDir.html)的工作方式)。

## Fake Objects (Best Way)

尽管JUnit扩展很美,但我认为它们并不是将先决条件与测试方法解耦的最佳方式。JUnit扩展仍然与整个项目的测试套件相当耦合......如果你决定在其他项目中的其他位置使用它们,你将无法这样做。

此外,如果你决定测试你的先决条件,你将无法优雅地完成。当然,你可以在同一个目录下为它们编写测试,但在这种情况下,你将违反原则:一个测试对应一个实际类。

解决方案是:虚假对象。它们与其他真实对象一起存在,但具有特殊的“伪造”行为,例如(顺便说一下,我不喜欢工厂,但在这种情况下可以使用)。

class FactoryOfPhrases { public Phrases aboutLondon() { Phrases p = new Phrases(); p.add(“Hello, world!”); p.add(“London is a capital of Great Britain”); return p; } }


然后,测试将会看起来像这样:

class PhrasesTest { @Test void countsSimpleGreetings() { assertThat( “Total count of greetings”, new FactoryOfPhrases().aboutLondon() .greetings().count(), equalTo(1) ); } }


存储库布局将如下所示:

src/ main/ java/ foo/ FactoryOfPhrases.java Phrases.java Greetings.java test/ java/ foo/ FactoryOfPhrasesTest.java PhrasesTest.java GreetingsTest.java


请注意测试`FactoryOfPhrasesTest`。它测试的是"fake"对象`FactoryOfPhrases`,它是生活类集合的一部分。阶段工厂与所有其他类一起提供。因此,它可以被其他项目使用,而不仅仅用于测试目的。

总结一下,通常我建议保持测试类的整洁:只有测试方法应该在那里。没有属性,当然也没有静态私有方法。一切前提条件都必须是一个“伪造”对象。

## Integration Tests

在Maven世界中,有单元测试类(以`Test`为后缀)和集成测试类(以`ITCase`为后缀)。它们之间的区别很大。虽然它们都是由相同的`maven-compiler-plugin`在`test-compile`阶段编译的,但它们不会一起执行。相反,单元测试在`test`阶段执行。如果有任何单元测试失败,构建将立即失败。这是一种非常直接的方法,类似于其他构建自动化引擎。

集成测试分为四个步骤(这些是Maven阶段的名称):

pre-integration-test integration-test post-integration-test verify


首先,在“pre-integration-test”阶段获取集成测试所需的资源。例如,可以启动一个MySQL数据库的测试实例。然后,在“integration-test”阶段执行带有`ITCase`的测试。目前忽略执行结果,只记录在一个文件中。然后,在“post-integration-test”阶段释放资源。例如,关闭MySQL服务器。最后,在“verify”阶段验证测试结果,如果有些测试结果不合格,构建将失败。

当集成测试与具体的实时类相关联时,我将`ITCase`文件与`Test`文件放在一起。但很多时候,它们并不是这样的情况,这就是为什么它们被称为集成测试。它们可能会将多个类整合在一起进行测试。在这种情况下,我会将它们放在一个单独的包中,并给它们任意的名称,这些名称不与实时类的名称匹配。

src/ main/ java/ foo/ Phrases.java Greetings.java test/ java/ foo/ it/ SimpleGuessingITCase.java PhrasesTest.java GreetingsTest.java GreetingsITCase.java ```

在这里,GreetingsITCase.javaGreetings.java 的集成测试,而 SimpleGuessingITCase.java 是没有特定类的集成测试。显然,foo.it 包只存在于测试中,而不在 src/main/java 中。


因此,这里有第一个规则:一个测试类只能有使用@Test注解的方法(在Java的情况下)。

然后,还有第二个规则:带有测试的包只能有与实际类一一对应的具有TestITCase后缀的类,不能有其他类。

Translated by ChatGPT gpt-3.5-turbo/35 on 2023-09-20 at 21:54

sixnines availability badge   GitHub stars