使用 Spring Boot 进行单元测试

800 阅读7分钟

【注】本文译自: 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)
@SpringBootTest
class 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 的可测试性。

字段注入是不可取的

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

@Service
public class RegisterUseCase {

    @Autowired
    private UserRepository userRepository;

    public User registerUser(User user) {
        return userRepository.save(user);
    }

}

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

这里的教训是不要使用字段注入

提供构造函数

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

@Service
public 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
@RequiredArgsConstructor
public 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 方法来做到这一点:

    @Test
    void 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 上 找到。