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个单元测试,…其中十个测试失败了。请注意,我不是说“失败”,因为就像建筑物周围的安全网一样,失败的测试是没有捕捉到掉下的锤子,也没有发现刚刚引入的错误的测试。因此,其中490个测试失败了,但有十个测试成功了。

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

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

虽然这个测试要好得多,得益于Hamcrest断言(如何命名测试方法是一个单独的故事,在这里详细解释)。

第一个片段会打印一个相当晦涩的错误信息,而第二个片段将在我与我刚刚制造的错误作斗争中对我非常有帮助:这个信息会自我解释。我会很快理解问题所在。

描述性的信息将帮助我理解问题是什么。然而,我会知道问题出现在哪里吗?是哪个Java类?其实并不知道。是在Phrases.java中吗?还是在Greetings.java中,其由Phrases.greetings()返回?我只能通过测试类的名称获得这些信息。如果它被称为PhrasesTest.java—所有它捕获的错误很可能位于Phrases.java中。如果它被称为GreetingsTest.java—…你明白我的意思。

我的观点是测试类的名称不仅仅是一个名称。它是对一位好奇的程序员的指示:“去查看源文件,你可以从我的名称中推断出它的名称,删除Test后缀。”如果我试图按照这个指示去找,却找不到任何东西,我会非常沮丧,尤其是如果这个项目不是我的。我无法从其他地方获取所需的信息。测试类的名称是我最后的希望。

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

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

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

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

正如您所知,实用类和私有静态方法是命令式编程的基础。面向对象的世界有更好的替代方案。特别是JUnit5为创建测试前提条件提供了相当优雅的机制:测试扩展。测试方法所需的一切都通过其参数提供,这些参数由扩展实例化,例如:

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

现在,测试及其前提条件保存在两个不同的位置,并且它们之间的耦合度不如以前紧密。此外,前提条件可以很容易地被重用。魔术@ExtendWith注解可以附加到其他测试上。PhrasesExtension的实现可以变得更智能:它可以开始关注测试方法的参数类型以及附加到其上的自定义注解(这就是@TempDir的工作原理)。

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

另外,如果你决定测试你的先决条件,你将无法以优雅的方式进行。当然,你可以在同一个目录中为它们编写测试,但在这种情况下,你将违反“一个活动类对应一个测试”的原则。

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

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

存储库布局将如下所示:

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

总之,通常我建议保持测试类的清晰:只有测试方法应该存在其中。没有属性,当然也没有静态私有方法。一切必要条件都必须是“假”对象。

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

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

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

我只在将它们作为特定实时类的集成测试时,将“ITCase”文件与“Test”文件放在一起。通常情况下,它们并不是这样——这就是为什么它们是集成测试的原因。它们可能会将多个类集成和测试在一起。在这种情况下,我会将它们放在一个单独的包中,并给它们任意的名称,这些名称与实时类的名称不匹配。

在这里,GreetingsITCase.java 是对 Greetings.java 进行集成测试的,而 SimpleGuessingITCase.java 则是对任何特定类进行集成测试的。显然,foo.it 包仅存在于测试中,并不存在于 src/main/java 中。

因此,第一条规则是:测试类只能有使用@Test注解的方法(在Java中)。

然后,第二条规则是:包含测试的包只能有与实际类一一对应且仅有TestITCase后缀的类,不能有其他内容。

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-11-22 at 10:24

sixnines availability badge   GitHub stars