通过示例测试

已发表: 2022-02-15

我们将继续撰写有关与测试相关的所有内容的系列博客。 在本博客中,我们将重点放在真实示例上。

虽然本文中的示例是使用 JUnit 5 和 AssertJ 编写的,但这些课程适用于任何其他单元测试框架。

JUnit 是最流行的 Java 测试框架。 AssertJ 是一个 Java 库,可帮助开发人员编写更具表现力的测试。

基本测试结构

我们将查看的第一个测试示例是一个简单的计算器,用于将 2 个数字相加。

 类计算器应该{

     @Test // 1
     无效总和(){
         计算器计算器 = new Calculator(); // 2
         int 结果 = 计算器.sum(1, 2); // 3
         assertThat(结果).isEqualTo(3); // 4
     }
}

我更喜欢在编写测试时使用ClassShould命名约定,以避免在每个方法名称中重复shouldtest 你可以在这里读更多关于它的内容。

上面的测试是做什么的?

让我们逐行打破测试:

  1. @Test注解让 JUnit 框架知道哪些方法应该作为测试运行。 在测试类中拥有不是测试的private方法是完全正常的。
  2. 这是我们测试的安排阶段,我们准备测试环境。 这个测试我们只需要一个Calculator实例。
  3. 这是我们触发我们想要测试的行为的行为阶段。
  4. 这是我们检查发生了什么以及一切是否按预期解决的断言阶段。 assertThat(result)方法是 AssertJ 库的一部分,并且有多个重载。

每个重载都返回一个专门的Assert对象。 返回的对象具有对我们传递给assertThat方法的对象有意义的方法。 在我们的例子中,该对象是AbstractIntegerAssert ,带有用于测试整数的方法。 isEqualTo(3)将检查result == 3 。 如果是,则测试将通过,否则将失败。

在这篇博文中,我们不会关注任何实现。

关于ArrangeActAssert的另一种思考方式是GivenWhenThen

写完sum实现后,我们可以问自己一些问题:

  • 我怎样才能改进这个测试?
  • 还有更多我应该涵盖的测试用例吗?
  • 如果我添加一个正数和一个负数会发生什么? 两个负数? 一正一负?
  • 如果我溢出整数值怎么办?

让我们添加这些案例并稍微改进现有的测试名称。

我们不会在我们的实现中允许溢出。 如果sum溢出,我们将抛出ArithmeticException

 类计算器应该{

     私人计算器计算器=新计算器();

     @测试
     无效 sumPositiveNumbers() {
         int sum =calculator.sum(1, 2);
         assertThat(sum).isEqualTo(3);
     }

     @测试
     无效 sumNegativeNumbers() {
         int sum =calculator.sum(-1, -1);
         assertThat(sum).isEqualTo(-2);
     }

     @测试
     无效 sumPositiveAndNegativeNumbers() {
         int sum =calculator.sum(1, -2);
         assertThat(sum).isEqualTo(-1);
     }

     @测试
     无效 failWithArithmeticExceptionWhenOverflown() {
         assertThatThrownBy(() ->calculator.sum(Integer.MAX_VALUE, 1))
             .isInstanceOf(ArithmeticException.class);
     } 

}

JUnit 将在运行每个@Test方法之前创建一个新的CalculatorShould实例。 这意味着每个CalculatorShould将有一个不同的calculator ,因此我们不必在每个测试中都实例化它。

shouldFailWithArithmeticExceptionWhenOverflown测试使用不同类型的assert 。 它检查一段代码是否失败。 assertThatThrownBy方法将运行我们提供的 lambda 并确保它失败。 我们已经知道,所有的assertThat方法都返回一个专门的Assert允许我们检查发生了哪种类型的异常。

这是一个示例,说明我们如何测试我们的代码是否在我们期望的时候失败。 如果在任何时候我们重构Calculator并且它没有在溢出时抛出ArithmeticException ,我们的测试将失败。

ObjectMother 设计模式

下一个示例是用于确保 Person 实例有效的验证器类。

 类 PersonValidatorShould {

    私人 PersonValidator 验证器 = new PersonValidator();

    @测试
    无效失败时名称IsNull(){
        Person person = new Person(null, 20, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(person))
            .isInstanceOf(InvalidPersonException.class);
    }

    @测试
    无效的失败时年龄(){
        Person person = new Person("John", -5, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(person))
            .isInstanceOf(InvalidPersonException.class);

    }
}

ObjectMother设计模式通常用于创建复杂对象的测试中以隐藏测试中的实例化细节。 多个测试甚至可能创建同一个对象,但在其上测试不同的东西。

测试#1 与测试#2 非常相似。 我们可以通过将验证提取为私有方法来重构PersonValidatorShould ,然后将非法的Person实例传递给它,期望它们都以相同的方式失败。

 类 PersonValidatorShould {

     私人 PersonValidator 验证器 = new PersonValidator();

     @测试
     无效失败时名称IsNull(){
         shouldFailValidation(PersonObjectMother.createPersonWithoutName());
     }

     @测试
     无效的失败时年龄(){
         shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge());
     }

     私人无效应该失败验证(人员无效人员){
         assertThatThrownBy(() -> validator.validate(invalidPerson))
             .isInstanceOf(InvalidPersonException.class);
   
     }
 }

测试随机性

我们应该如何测试代码中的随机性?

假设我们有一个PersonGenerator ,它具有generateRandom来生成随机Person实例。

我们首先编写以下内容:

 类 PersonGeneratorShould {

     私有 PersonGenerator 生成器 = new PersonGenerator();

     @测试
     无效的 generateValidPerson() {
         人 person = generator.generateRandom();
         断言(人)。
    }
}

然后我们应该问自己:

  • 我想在这里证明什么? 这个功能需要做什么?
  • 我应该只验证生成的人是非空实例吗?
  • 我需要证明它是随机的吗?
  • 生成的实例是否必须遵循一些业务规则?

我们可以使用依赖注入来简化我们的测试。

 公共接口 RandomGenerator {
     字符串生成随机字符串();
     int generateRandomInteger();
}

PersonGenerator现在有另一个构造函数,它也接受该接口的一个实例。 默认情况下,它使用JavaRandomGenerator实现,该实现使用java.Random生成随机值。

但是,在测试中,我们可以编写另一个更可预测的实现。

 @测试
 无效的 generateValidPerson() {
     RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20);
     PersonGenerator 生成器 = new PersonGenerator(randomGenerator);
     人 person = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
}

此测试证明PersonGenerator生成由RandomGenerator指定的随机实例,而无需深入了解RandomGenerator的任何细节。

测试JavaRandomGenerator并没有真正增加任何价值,因为它是java.Random的简单包装器。 通过测试它,您实际上是在测试 Java 标准库中的java.Random 编写明显的测试只会导致额外的维护,几乎没有任何好处。

为避免为测试目的编写实现,例如PredictableGenerator ,您应该使用 Mockito 等模拟库。

当我们编写PredictableGenerator时,我们实际上是手动存根RandomGenerator类。 您也可以使用 Mockito 对其进行存根:

 @测试
 无效的 generateValidPerson() {
     RandomGenerator randomGenerator = mock(RandomGenerator.class);
     when(randomGenerator.generateRandomString()).thenReturn("John Doe");
     when(randomGenerator.generateRandomInteger()).thenReturn(20);

     PersonGenerator 生成器 = new PersonGenerator(randomGenerator);
     人 person = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
 }

这种编写测试的方式更具表现力,并导致特定测试的实现更少。

Mockito 是一个用于编写模拟和存根的 Java 库。 在测试依赖于您无法轻松实例化的外部库的代码时,它非常有用。 它允许您为这些类编写行为,而无需直接实现它们。

当我们有多个类似于我们习惯的测试时,Mockito 还允许创建和注入模拟以减少样板代码的另一种语法:

 @ExtendWith(MockitoExtension.class) // 1
 类 PersonGeneratorShould {

     @Mock // 2
     随机生成器随机生成器;

     @InjectMocks // 3
     私有 PersonGenerator 生成器;

     @测试
     无效的 generateValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         人 person = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

1. JUnit 5 可以使用“扩展”来扩展其功能。 这个注解允许它通过注解识别模拟并正确注入它们。

2. @Mock注解创建一个模拟的字段实例。 这与在我们的测试方法体中编写mock(RandomGenerator.class)相同。

3. @InjectMocks注解会创建一个新的PersonGenerator实例,并在generator实例中注入 mock。

有关 JUnit 5 扩展的更多详细信息,请参见此处。

有关 Mockito 注射的更多详细信息,请参见此处。

使用@InjectMocks有一个陷阱。 它可能消除了手动声明对象实例的需要,但我们失去了构造函数的编译时安全性。 如果在任何时候有人向构造函数添加了另一个依赖项,我们将不会在此处收到编译时错误。 这可能导致不容易检测到的测试失败。 我更喜欢使用@BeforeEach手动设置实例:

 @ExtendWith(MockitoExtension.class)
类 PersonGeneratorShould {

     @嘲笑
     随机生成器随机生成器;

     私有 PersonGenerator 生成器;

     @BeforeEach
     无效设置(){
         生成器 = 新 PersonGenerator(randomGenerator);
     }

     @测试
     无效的 generateValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         人 person = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

测试时间敏感的过程

一段代码通常依赖于时间戳,我们倾向于使用诸如System.currentTimeMillis()之类的方法来获取当前 epoch 时间戳。

虽然这看起来不错,但当类在内部为我们做出决定时,很难测试和证明我们的代码是否正常工作。 这种决定的一个例子是确定今天是什么日子。

 类 IndexerShould {
     私有索引器 indexer = new Indexer();
     @测试
     无效 generateIndexNameForTomorrow() {
         String indexName = indexer.tomorrow("my-index");
         // 这个测试今天可以工作,但是明天呢?
        断言(索引名称)
           .isEqualTo("my-index.2022-02-02");
     }
}

我们应该再次使用依赖注入来“控制”生成索引名称时的日期。

Java 有一个Clock类来处理这样的用例。 我们可以将一个Clock实例传递给我们的Indexer来控制时间。 默认构造函数可以使用Clock.systemUTC()来实现向后兼容性。 我们现在可以用clock.millis()替换System.currentTimeMillis()调用。

通过注入Clock ,我们可以在类中强制执行可预测的时间并编写更好的测试。

测试文件生成方法

  • 我们应该如何测试将其输出写入文件的类?
  • 我们应该将这些文件存储在哪里,以便它们在任何操作系统上工作?
  • 我们如何确保该文件不存在?

在处理文件时,如果我们尝试自己解决这些问题,可能很难编写测试,正如我们将在下面的示例中看到的那样。 接下来的测试是对质量可疑的旧测试。 它应该测试DogToCsvWriter是否将狗序列化并将其写入 CSV 文件:

 类 DogToCsvWriterShould {

     私人 DogToCsvWriter writer = new DogToCsvWriter("/tmp/dogs.csv");
     
     @测试
     无效 convertToCsv() {
         writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         字符串 csv = Files.readString("/tmp/dogs.csv");

         assertThat(csv).isEqualTo("Monty,corgi,brown\nZoe,maltese,white");
     }
}

序列化过程应该与编写过程解耦,但让我们专注于修复测试。

上述测试的第一个问题是它无法在 Windows 上运行,因为 Windows 用户将无法解析路径/tmp/dogs.csv 。 另一个问题是,如果文件已经存在,它将无法工作,因为在执行上述测试时它没有被删除。 它在 CI/CD 管道中可能工作正常,但如果多次运行则不能在本地运行。

JUnit 5 有一个注解,您可以使用它来获取对由框架为您创建和删除的临时目录的引用。 虽然创建和删除临时文件的机制因框架而异,但想法保持不变。

 类 DogToCsvWriterShould {

     @测试
     void convertToCsv(@TempDir 路径 tempDir) {
         路径 dogsCsv = tempDir.resolve("dogs.csv");
         DogToCsvWriter writer = new DogToCsvWriter(dogsCsv);
         writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         字符串 csv = Files.readString(dogsCsv);

         assertThat(csv).isEqualTo("Monty,corgi,brown\nZoe,maltese,white");
     }
}

有了这个小小的改动,我们现在确信上面的测试可以在 Windows、macOS 和 Linux 上运行,而不必担心绝对路径。 它还将在测试后删除创建的文件,因此我们现在可以多次运行它并每次都获得可预测的结果。

命令与查询测试

命令和查询有什么区别?

  • 命令:我们指示对象执行产生效果而不返回值的动作(void 方法)
  • 查询:我们要求一个对象执行一个动作并返回一个结果或异常

到目前为止,我们主要测试了在执行阶段调用了返回值或抛出异常的方法的查询。 我们如何测试void方法并查看它们是否与其他类正确交互? 框架提供了一组不同的方法来编写这些类型的测试。

到目前为止,我们为查询编写的断言都是以assertThat开头的。 在编写命令测试时,我们使用一组不同的方法,因为我们不再像处理查询那样检查方法的直接结果。 我们想“验证”我们的方法与系统其他部分的交互。

 @ExtendWith(MockitoExtension.class)
 类 FeedMentionServiceShould {

     @嘲笑
     私有 FeedRepository 存储库;

     @嘲笑
     私人 FeedMentionEventEmitter 发射器;

     私人 FeedMentionService 服务;

     @BeforeEach
     无效设置(){
         服务=新的FeedMentionService(存储库,发射器);
     }

     @测试
     无效的 insertMentionToFeed() {
         长 feedId = 1L;
         提及提及 = ...;

         何时(repository.upsertMention(feedId,提及))
             .thenReturn(UpsertResult.success(feedId, 提及));

         FeedInsertionEvent 事件 = 新 FeedInsertionEvent(feedId, 提及);
         提及Service.insertMentionToFeed(事件);

         验证(发射器).mentionInsertedToFeed(feedId,提及);
         verifyNoMoreInteractions(发射器);
     }
}

在这个测试中,我们首先模拟我们的存储库,当被要求在我们的提要中更新提及时,以UpsertResult.success响应。 我们不关心在这里测试存储库。 存储库方法应在FeedRepositoryShould中进行测试。 通过模拟这种行为,我们实际上并没有调用存储库方法。 我们只是告诉它下次调用它时如何响应。

然后我们告诉我们的mentionService在我们的提要中插入这个提及。 我们知道,只有在提要中成功插入提及时,它才应该发出结果。 通过使用verify方法,我们可以确保在我们的提及和提要中调用了方法mentionInsertedToFeed ,并且没有使用verifyNoMoreInteractions再次调用。

最后的想法

编写质量测试来自经验,而最好的学习方式就是实践。 这篇博客中写的技巧来自实践。 如果您从未遇到过这些陷阱,则很难看到它们,希望这些建议可以使您的代码设计更加健壮。 拥有可靠的测试将增加您更改事物的信心,而不会在每次必须部署代码时大汗淋漓。

有兴趣加入 Mediatoolkit 的团队吗?
查看我们为高级前端开发人员提供的空缺职位!