使用 Spring Boot 構建可重用的模擬模組

信碼由韁發表於2021-11-13

【譯】本文譯自: 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 而不是模擬每個單獨的方法呼叫,我們的整合測試變得更有彈性以適應變化。

相關文章