写点什么

使用 Spring Boot 进行单元测试

用户头像
信码由缰
关注
发布于: 刚刚
使用 Spring Boot 进行单元测试

【注】本文译自: Unit Testing with Spring Boot - Reflectoring


编写好的单元测试可以被认为是一门难以掌握的艺术。但好消息是支持它的机制很容易学习。

本教程为您提供了这些机制,并详细介绍了编写良好的单元测试所必需的技术细节,重点是 Spring Boot 应用程序。

我们将看看如何以可测试的方式创建 Spring bean,然后讨论 Mockito 和 AssertJ 的用法,这两个库默认包含在 Spring Boot 中用于测试。

请注意,本文仅讨论单元测试。集成测试、Web 层测试和持久层测试将在本系列的后续文章中讨论。

 代码示例

本文附有 GitHub 上 的工作代码示例。

依赖关系

对于本教程中的单元测试,我们将使用 JUnit Jupiter (JUnit 5)、Mockito 和 AssertJ。我们还将包括 Lombok 以减少一些样板代码:

dependencies {    compileOnly('org.projectlombok:lombok')    testCompile('org.springframework.boot:spring-boot-starter-test')    testCompile('org.junit.jupiter:junit-jupiter:5.4.0')    testCompile('org.mockito:mockito-junit-jupiter:2.23.0')}
复制代码


Mockito 和 AssertJ 是使用 spring-boot-starter-test 依赖项自动导入的,但我们必须自己包含 Lombok。不要在单元测试中使用 Spring 如果你以前用 Spring 或 Spring Boot 写过测试,你可能会说我们不需要 Spring 来写单元测试。这是为什么?考虑以下测试 RegisterUseCase 类的单个方法的“单元”测试:


@ExtendWith(SpringExtension.class)@SpringBootTestclass RegisterUseCaseTest {
@Autowired private RegisterUseCase registerUseCase;
@Test void savedUserHasRegistrationDate() { User user = new User("zaphod", "zaphod@mail.com"); User savedUser = registerUseCase.registerUser(user); assertThat(savedUser.getRegistrationDate()).isNotNull(); }
}
复制代码


这个测试在我电脑上的一个空 Spring 项目上运行大约需要 4.5 秒。

但是一个好的单元测试只需要几毫秒。否则它会阻碍由测试驱动开发(TDD)思想推动的“测试/代码/测试”流程。但即使我们不采用 TDD,等待太长时间的测试也会破坏我们的注意力。

执行上面的测试方法实际上只需要几毫秒。 剩下的 4.5 秒是由于 @SpringBootRun 告诉 Spring Boot 设置整个 Spring Boot 应用程序上下文。

所以我们启动了整个应用程序只是为了将 RegisterUseCase 实例自动装配到我们的测试中。一旦应用程序变大并且 Spring 不得不将越来越多的 bean 加载到应用程序上下文中,它将花费更长的时间。那么,为什么我们不应该在单元测试中使用 Spring Boot 呢?老实说,本教程的大部分内容都是关于在没有 Spring Boot 的情况下编写单元测试。

创建可测试的 Spring Bean

然而,我们可以做一些事情来提高 Spring bean 的可测试性。

字段注入是不可取的

让我们从一个不好的例子开始。考虑以下类:


@Servicepublic class RegisterUseCase {
@Autowired private UserRepository userRepository;
public User registerUser(User user) { return userRepository.save(user); }
}
复制代码


这个类不能在没有 Spring 的情况下进行单元测试,因为它没有提供传递 UserRepository 实例的方法。那么,我们需要按照上一节中讨论的方式编写测试,让 Spring 创建一个 UserRepository 实例并将其注入到用 @Autowired 注解的字段中。这里的教训是不要使用字段注入。

提供构造函数

实际上,我们根本不要使用 @Autowired 注解:


@Servicepublic class RegisterUseCase {
private final UserRepository userRepository;
public RegisterUseCase(UserRepository userRepository) { this.userRepository = userRepository; }
public User registerUser(User user) { return userRepository.save(user); }
}
复制代码


这个版本通过提供允许传入 UserRepository 实例的构造函数来允许构造函数注入。在单元测试中,我们现在可以创建这样一个实例(可能是我们稍后讨论的模拟实例)并将其传递给构造函数。

在创建生产应用程序上下文时,Spring 将自动使用此构造函数来实例化 RegisterUseCase 对象。注意,在 Spring 5 之前,我们需要在构造函数中添加 @Autowired 注解,以便 Spring 找到构造函数。

还要注意 UserRepository 字段现在是 final。这是有道理的,因为字段内容在应用程序的生命周期内永远不会改变。它还有助于避免编程错误,因为如果我们忘记初始化字段,编译器会报错。

减少样板代码

使用 Lombok 的 @RequiredArgsConstructor 注解,我们可以让构造函数自动生成:

@Service@RequiredArgsConstructorpublic class RegisterUseCase {
private final UserRepository userRepository;
public User registerUser(User user) { user.setRegistrationDate(LocalDateTime.now()); return userRepository.save(user); }
}
复制代码


现在,我们有一个非常简洁的类,没有样板代码,可以在普通的 java 测试用例中轻松实例化:

class RegisterUseCaseTest {
private UserRepository userRepository = ...;
private RegisterUseCase registerUseCase;
@BeforeEach void initUseCase() { registerUseCase = new RegisterUseCase(userRepository); }
@Test void savedUserHasRegistrationDate() { User user = new User("zaphod", "zaphod@mail.com"); User savedUser = registerUseCase.registerUser(user); assertThat(savedUser.getRegistrationDate()).isNotNull(); }
}
复制代码


然而,还缺少一点,那就是如何模拟我们被测类所依赖的 UserRepository 实例,因为我们不想依赖真实的东西,它可能需要连接到数据库。使用 Mockito 来模拟依赖现在事实上的标准模拟库是 Mockito。它至少提供了两种方法来创建模拟的 UserRepository 以填补前面代码示例中的空白。

使用普通 Mockito 模拟依赖项

第一种方法是以编程方式使用 Mockito:

private UserRepository userRepository = Mockito.mock(UserRepository.class);
复制代码


这将创建一个从外部看起来像 UserRepository 的对象。默认情况下,当一个方法被调用时它什么都不做,如果该方法有返回值则返回 null。我们的测试现在将在 assertThat(savedUser.getRegistrationDate()).isNotNull() 处以 NullPointerException 失败,因为 userRepository.save(user) 现在返回 null。所以,我们必须告诉 Mockito 在调用 userRepository.save() 时返回一些东西。我们使用静态 when 方法来做到这一点:


@Testvoid savedUserHasRegistrationDate() {    User user = new User("zaphod", "zaphod@mail.com");    when(userRepository.save(any(User.class))).then(returnsFirstArg());    User savedUser = registerUseCase.registerUser(user);    assertThat(savedUser.getRegistrationDate()).isNotNull();}
复制代码


这将使 userRepository.save() 返回传递给方法的相同用户对象。Mockito 具有更多功能,可以进行模拟、匹配参数和验证方法调用。有关更多信息,请查看参考文档

使用 Mockito 的 @Mock 注解模拟依赖项

创建模拟对象的另一种方法是 Mockito 的 @Mock 注解与 JUnit Jupiter 的 MockitoExtension 相结合:


@ExtendWith(MockitoExtension.class)class RegisterUseCaseTest {
@Mock private UserRepository userRepository;
private RegisterUseCase registerUseCase;
@BeforeEach void initUseCase() { registerUseCase = new RegisterUseCase(userRepository); }
@Test void savedUserHasRegistrationDate() { // ... }
}
复制代码


@Mock 注解指定了 Mockito 应该注入模拟对象的字段。 @MockitoExtension 告诉 Mockito 评估那些 @Mock 注解,因为 JUnit 不会自动执行此操作。

结果和手动调用 Mockito.mock() 一样,选择使用哪种方式是品味问题。 但是请注意,通过使用 MockitoExtension 将我们的测试绑定到测试框架。

请注意,我们也可以在 registerUseCase 字段上使用 @InjectMocks 注解,而不是手动构造 RegisterUseCase 对象。然后 Mockito 会按照指定的算法为我们创建一个实例:


@ExtendWith(MockitoExtension.class)class RegisterUseCaseTest {
@Mock private UserRepository userRepository;
@InjectMocks private RegisterUseCase registerUseCase;
@Test void savedUserHasRegistrationDate() { // ... }
}
复制代码


使用 AssertJ 创建可读断言

Spring Boot 测试支持自动附带的另一个库是 AssertJ。我们已经在上面使用它来实现我们的断言:


assertThat(savedUser.getRegistrationDate()).isNotNull();
复制代码


然而,让断言更具可读性不是更好吗?例如:

assertThat(savedUser).hasRegistrationDate();
复制代码


在很多情况下,像这样的小改动会使测试更容易理解。因此,让我们在测试源文件夹中创建我们自己的自定义断言


class UserAssert extends AbstractAssert<UserAssert, User> {
UserAssert(User user) { super(user, UserAssert.class); }
static UserAssert assertThat(User actual) { return new UserAssert(actual); }
UserAssert hasRegistrationDate() { isNotNull(); if (actual.getRegistrationDate() == null) { failWithMessage( "Expected user to have a registration date, but it was null" ); } return this; }}
复制代码


现在,如果我们从新的 UserAssert 类而不是从 AssertJ 库导入 assertThat 方法,我们就可以使用新的、更易于阅读的断言。

创建像这样的自定义断言似乎需要很多工作,但实际上只需几分钟即可完成。我坚信投入这些时间来创建可读的测试代码是值得的,即使之后它的可读性只是稍微好一点。毕竟,我们只编写一次测试代码,其他人(包括“未来的我”)必须在软件的生命周期中多次阅读、理解和操作代码

如果仍然觉得工作量太大,请查看 AssertJ 的断言生成器

结论

在测试中启动 Spring 应用程序是有原因的,但对于普通的单元测试来说,这是没有必要的。由于更长的周转时间,它甚至是有害的。相反,我们应该以一种易于支持为其编写简单单元测试的方式构建我们的 Spring bean。

Spring Boot Test Starter 附带 Mockito 和 AssertJ 作为测试库。让我们利用这些测试库来创建富有表现力的单元测试!最终形式的代码示例可在 github 上 找到。

发布于: 刚刚阅读数: 2
用户头像

信码由缰

关注

分享程序人生。 2019.07.04 加入

“码”界老兵,分享程序人生。

评论

发布
暂无评论
使用 Spring Boot 进行单元测试