使用 Spring Boot 构可重用的 Mock 模块

【译】本文译自: Building Reusable Mock Modules with Spring Boot – Reflectoring

将代码库分割成松散耦合的模块,每个模块都有一组专门的职责,这不是很好吗?

这意味着我们可以轻松找到代码库中的每个职责来添加或修改代码。也意味着代码库很容易掌握,因为我们一次只需要将一个模块加载到大脑的工作记忆中。

而且,由于每个模块都有自己的 API,这意味着我们可以为每个模块创建一个可重用的模拟。在编写集成测试时,我们只需导入一个模拟模块并调用其 API 即可开始模拟。我们不再需要知道我们模拟的类的每一个细节。

在本文中,我们将着眼于创建这样的模块,讨论为什么模拟整个模块比模拟单个 bean 更好,然后介绍一种简单但有效的模拟完整模块的方法,以便使用 Spring Boot 进行简单的测试设置。

代码示例

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

什么是模块?

当我在本文中谈论“模块”时,我的意思是:

模块是一组高度内聚的类,这些类具有专用的 API 和一组相关的职责。

我们可以将多个模块组合成更大的模块,最后组合成一个完整的应用程序。

一个模块可以通过调用它的 API 来使用另一个模块。

你也可以称它们为“组件”,但在本文中,我将坚持使用“模块”。

如何构建模块?

在构建应用程序时,我建议预先考虑如何模块化代码库。我们的代码库中的自然边界是什么?

我们的应用程序是否需要与外部系统进行通信?这是一个自然的模块边界。我们可以构建一个模块,其职责是与外部系统对话!

我们是否确定了属于一起的用例的功能“边界上下文”?这是另一个很好的模块边界。我们将构建一个模块来实现应用程序的这个功能部分中的用例!

当然,有更多方法可以将应用程序拆分为模块,而且通常不容易找到它们之间的边界。他们甚至可能会随着时间的推移而改变!更重要的是在我们的代码库中有一个清晰的结构,这样我们就可以轻松地在模块之间移动概念!

为了使模块在我们的代码库中显而易见,我建议使用以下包结构:

  • 每个模块都有自己的包

  • 每个模块包都有一个 api 子包,包含所有暴露给其他模块的类

  • 每个模块包都有一个内部子包 internal ,其中包含:

    • 实现 API 公开的功能的所有类
    • 一个 Spring 配置类,它将 bean 提供给实现该 API 所需的 Spring 应用程序上下文
  • 就像俄罗斯套娃一样,每个模块的 internal 子包可能包含带有子模块的包,每个子模块都有自己的 api 和 internal

  • 给定 internal 包中的类只能由该包中的类访问。

这使得代码库非常清晰,易于导航。在我关于清晰架构边界 中阅读有关此代码结构的更多信息,或 示例代码中的一些代码。

这是一个很好的包结构,但这与测试和模拟有什么关系呢?

模拟单个 Bean 有什么问题?

正如我在开始时所说的,我们想着眼于模拟整个模块而不是单个 bean。但是首先模拟单个 bean 有什么问题呢?

让我们来看看使用 Spring Boot 创建集成测试的一种非常常见的方式。

假设我们想为 REST 控制器编写一个集成测试,该控制器应该在 GitHub 上创建一个存储库,然后向用户发送电子邮件。

集成测试可能如下所示:

@WebMvcTest
class RepositoryControllerTestWithoutModuleMocks {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private GitHubMutations gitHubMutations;

    @MockBean
    private GitHubQueries gitHubQueries;

    @MockBean
    private EmailNotificationService emailNotificationService;

  @Test
  void givenRepositoryDoesNotExist_thenRepositoryIsCreatedSuccessfully()
      throws Exception {
    String repositoryUrl = "https://github.com/reflectoring/reflectoring";

    given(gitHubQueries.repositoryExists(...)).willReturn(false);
    given(gitHubMutations.createRepository(...)).willReturn(repositoryUrl);

    mockMvc.perform(post("/github/repository")
      .param("token", "123")
      .param("repositoryName", "foo")
      .param("organizationName", "bar"))
      .andExpect(status().is(200));

    verify(emailNotificationService).sendEmail(...);
    verify(gitHubMutations).createRepository(...);
  }

}

这个测试实际上看起来很整洁,我见过(并编写)了很多类似的测试。但正如人们所说,细节决定成败。

我们使用 @WebMvcTest 注解来设置 Spring Boot 应用程序上下文以测试 Spring MVC 控制器。应用程序上下文将包含让控制器工作所需的所有 bean,仅此而已。

但是我们的控制器在应用程序上下文中需要一些额外的 bean 才能工作,即 GitHubMutationsGitHubQueries、和 EmailNotificationService。因此,我们通过 @MockBean 注解将这些 bean 的模拟添加到应用程序上下文中。

在测试方法中,我们在一对 given() 语句中定义这些模拟的状态,然后调用我们要测试的控制器端点,之后 verify() 在模拟上调用了某些方法。

那么,这个测试有什么问题呢? 我想到了两件主要的事情:

首先,要设置 given()verify() 部分,测试需要知道控制器正在调用模拟 bean 上的哪些方法。这种对实现细节的低级知识使测试容易被修改。每次实现细节发生变化时,我们也必须更新测试。这稀释了测试的价值,并使维护测试成为一件苦差事,而不是“有时是例行公事”。

其次, @MockBean 注解将导致 Spring 为每个测试创建一个新的应用程序上下文(除非它们具有完全相同的字段)。在具有多个控制器的代码库中,这将显着增加测试运行时间。

如果我们投入一点精力来构建上一节中概述的模块化代码库,我们可以通过构建可重用的模拟模块来解决这两个缺点。

让我们通过看一个具体的例子来了解如何实现。

模块化 Spring Boot 应用程序

好,让我们看看如何使用 Spring Boots 实现可重用的模拟模块。

这是示例应用程序的文件夹结构。如果你想跟随,你可以在 GitHub 上找到代码:

├── github
|   ├── api
|   |  ├── <I> GitHubMutations
|   |  ├── <I> GitHubQueries
|   |  └── <C> GitHubRepository
|   └── internal
|      ├── <C> GitHubModuleConfiguration
|      └── <C> GitHubService
├── mail
|   ├── api
|   |  └── <I> EmailNotificationService
|   └── internal
|      ├── <C> EmailModuleConfiguration
|      ├── <C> EmailNotificationServiceImpl
|      └── <C> MailServer
├── rest
|   └── internal
|       └── <C> RepositoryController
└── <C> DemoApplication

该应用程序有 3 个模块:

  • github 模块提供了与 GitHub API 交互的接口,

  • mail 模块提供电子邮件功能,

  • rest 模块提供了一个 REST API 来与应用程序交互。

让我们更详细地研究每个模块。

GitHub 模块

github 模块提供了两个接口(用 <I> 标记)作为其 API 的一部分:

  • GitHubMutations,提供了一些对 GitHub API 的写操作,

  • GitHubQueries,它提供了对 GitHub API 的一些读取操作。

这是接口的样子:

public interface GitHubMutations {

    String createRepository(String token, GitHubRepository repository);

}

public interface GitHubQueries {

    List<String> getOrganisations(String token);

    List<String> getRepositories(String token, String organisation);

    boolean repositoryExists(String token, String repositoryName, String organisation);

}

它还提供类 GitHubRepository,用于这些接口的签名。

在内部, github 模块有类 GitHubService,它实现了两个接口,还有类 GitHubModuleConfiguration,它是一个 Spring 配置,为应用程序上下文贡献一个 GitHubService 实例:

@Configuration
class GitHubModuleConfiguration {

    @Bean
    GitHubService gitHubService() {
        return new GitHubService();
    }

}

由于 GitHubService 实现了 github 模块的整个 API,因此这个 bean 足以使该模块的 API 可用于同一 Spring Boot 应用程序中的其他模块。

Mail 模块

mail 模块的构建方式类似。它的 API 由单个接口 EmailNotificationService 组成:

public interface EmailNotificationService {

    void sendEmail(String to, String subject, String text);

}

该接口由内部 beanEmailNotificationServiceImpl 实现。

请注意,我在 mail 模块中使用的命名约定与在 github 模块中使用的命名约定不同。 github 模块有一个以 *Servicee 结尾的内部类,而 mail 模块有一个 *Service 类作为其 API 的一部分。虽然 github 模块不使用丑陋的 *Impl 后缀,但 mail 模块使用了。

我故意这样做是为了使代码更现实一些。你有没有见过一个代码库(不是你自己写的)在所有地方都使用相同的命名约定?我没有。

但是,如果您像我们在本文中所做的那样构建模块,那实际上并不重要。因为丑陋的 *Impl 类隐藏在模块的 API 后面。

在内部, mail 模块具有 EmailModuleConfiguration 类,它为 Spring 应用程序上下文提供 API 实现:

@Configuration
class EmailModuleConfiguration {

    @Bean
    EmailNotificationService emailNotificationService() {
        return new EmailNotificationServiceImpl();
    }

}

REST 模块

rest 模块由单个 REST 控制器组成:

@RestController
class RepositoryController {

    private final GitHubMutations gitHubMutations;
    private final GitHubQueries gitHubQueries;
    private final EmailNotificationService emailNotificationService;

    // constructor omitted

    @PostMapping("/github/repository")
    ResponseEntity<Void> createGitHubRepository(@RequestParam("token") String token,
            @RequestParam("repositoryName") String repoName, @RequestParam("organizationName") String orgName) {

        if (gitHubQueries.repositoryExists(token, repoName, orgName)) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
        String repoUrl = gitHubMutations.createRepository(token, new GitHubRepository(repoName, orgName));
        emailNotificationService.sendEmail("user@mail.com", "Your new repository",
                "Here's your new repository: " + repoUrl);

        return ResponseEntity.ok().build();
    }

}

控制器调用 github 模块的 API 来创建一个 GitHub 仓库,然后通过 mail 模块的 API 发送邮件,让用户知道新的仓库。

模拟 GitHub 模块
现在,让我们看看如何为 github 模块构建一个可重用的模拟。我们创建了一个 @TestConfiguration 类,它提供了模块 API 的所有 bean:

@TestConfiguration
public class GitHubModuleMock {

    private final GitHubService gitHubServiceMock = Mockito.mock(GitHubService.class);

    @Bean
    @Primary
    GitHubService gitHubServiceMock() {
        return gitHubServiceMock;
    }

    public void givenCreateRepositoryReturnsUrl(String url) {
        given(gitHubServiceMock.createRepository(any(), any())).willReturn(url);
    }

    public void givenRepositoryExists() {
        given(gitHubServiceMock.repositoryExists(anyString(), anyString(), anyString())).willReturn(true);
    }

    public void givenRepositoryDoesNotExist() {
        given(gitHubServiceMock.repositoryExists(anyString(), anyString(), anyString())).willReturn(false);
    }

    public void assertRepositoryCreated() {
        verify(gitHubServiceMock).createRepository(any(), any());
    }

    public void givenDefaultState(String defaultRepositoryUrl) {
        givenRepositoryDoesNotExist();
        givenCreateRepositoryReturnsUrl(defaultRepositoryUrl);
    }

    public void assertRepositoryNotCreated() {
        verify(gitHubServiceMock, never()).createRepository(any(), any());
    }

}

除了提供一个模拟的 GitHubService bean,我们还向这个类添加了一堆 given*()assert*() 方法。

给定的 given*() 方法允许我们将模拟设置为所需的状态,而 verify*() 方法允许我们在运行测试后检查与模拟的交互是否发生。

@Primary 注解确保如果模拟和真实 bean 都加载到应用程序上下文中,则模拟优先。

模拟 Email 邮件模块

我们为 mail 模块构建了一个非常相似的模拟配置:

@TestConfiguration
public class EmailModuleMock {

    private final EmailNotificationService emailNotificationServiceMock = Mockito.mock(EmailNotificationService.class);

    @Bean
    @Primary
    EmailNotificationService emailNotificationServiceMock() {
        return emailNotificationServiceMock;
    }

    public void givenSendMailSucceeds() {
        // nothing to do, the mock will simply return
    }

    public void givenSendMailThrowsError() {
        doThrow(new RuntimeException("error when sending mail")).when(emailNotificationServiceMock)
                .sendEmail(anyString(), anyString(), anyString());
    }

    public void assertSentMailContains(String repositoryUrl) {
        verify(emailNotificationServiceMock).sendEmail(anyString(), anyString(), contains(repositoryUrl));
    }

    public void assertNoMailSent() {
        verify(emailNotificationServiceMock, never()).sendEmail(anyString(), anyString(), anyString());
    }

}

在测试中使用模拟模块

现在,有了模拟模块,我们可以在控制器的集成测试中使用它们:

@WebMvcTest
@Import({ GitHubModuleMock.class, EmailModuleMock.class })
class RepositoryControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private EmailModuleMock emailModuleMock;

    @Autowired
    private GitHubModuleMock gitHubModuleMock;

    @Test
    void givenRepositoryDoesNotExist_thenRepositoryIsCreatedSuccessfully() throws Exception {

        String repositoryUrl = "https://github.com/reflectoring/reflectoring.github.io";

        gitHubModuleMock.givenDefaultState(repositoryUrl);
        emailModuleMock.givenSendMailSucceeds();

        mockMvc.perform(post("/github/repository").param("token", "123").param("repositoryName", "foo")
                .param("organizationName", "bar")).andExpect(status().is(200));

        emailModuleMock.assertSentMailContains(repositoryUrl);
        gitHubModuleMock.assertRepositoryCreated();
    }

    @Test
    void givenRepositoryExists_thenReturnsBadRequest() throws Exception {

        String repositoryUrl = "https://github.com/reflectoring/reflectoring.github.io";

        gitHubModuleMock.givenDefaultState(repositoryUrl);
        gitHubModuleMock.givenRepositoryExists();
        emailModuleMock.givenSendMailSucceeds();

        mockMvc.perform(post("/github/repository").param("token", "123").param("repositoryName", "foo")
                .param("organizationName", "bar")).andExpect(status().is(400));

        emailModuleMock.assertNoMailSent();
        gitHubModuleMock.assertRepositoryNotCreated();
    }

}

我们使用 @Import 注解将模拟导入到应用程序上下文中。

请注意, @WebMvcTest 注解也会导致将实际模块加载到应用程序上下文中。这就是我们在模拟上使用 @Primary 注解的原因,以便模拟优先。

如何处理行为异常的模块?

模块可能会在启动期间尝试连接到某些外部服务而行为异常。例如, mail 模块可能会在启动时创建一个 SMTP 连接池。当没有可用的 SMTP 服务器时,这自然会失败。这意味着当我们在集成测试中加载模块时,Spring 上下文的启动将失败。
为了使模块在测试期间表现得更好,我们可以引入一个配置属性 mail.enabled。然后,我们使用 @ConditionalOnProperty 注解模块的配置类,以告诉 Spring 如果该属性设置为 false,则不要加载此配置。
现在,在测试期间,只加载模拟模块。

我们现在不是在测试中模拟特定的方法调用,而是在模拟模块上调用准备好的 given*() 方法。这意味着测试不再需要测试对象调用的类的内部知识

执行代码后,我们可以使用准备好的 verify*() 方法来验证是否已创建存储库或已发送邮件。同样,不知道具体的底层方法调用。

如果我们需要另一个控制器中的 githubmail 模块,我们可以在该控制器的测试中使用相同的模拟模块。

如果我们稍后决定构建另一个使用某些模块的真实版本但使用其他模块的模拟版本的集成,则只需使用几个 @Import 注解来构建我们需要的应用程序上下文。

这就是模块的全部思想:我们可以使用真正的模块 A 和模块 B 的模拟,我们仍然有一个可以运行测试的工作应用程序

模拟模块是我们在该模块中模拟行为的中心位置。他们可以将诸如“确保可以创建存储库”之类的高级模拟期望转换为对 API bean 模拟的低级调用。

结论

通过有意识地了解什么是模块 API 的一部分,什么不是,我们可以构建一个适当的模块化代码库,几乎不会引入不需要的依赖项。

由于我们知道什么是 API 的一部分,什么不是,我们可以为每个模块的 API 构建一个专用的模拟。我们不在乎内部,我们只是在模拟 API。

模拟模块可以提供 API 来模拟某些状态并验证某些交互。通过使用模拟模块的 API 而不是模拟每个单独的方法调用,我们的集成测试变得更有弹性以适应变化。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注