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

【譯】本文譯自:?Building Reusable Mock Modules with Spring Boot - Reflectoring

將代碼庫分割成松散耦合的模塊,每個模塊都有一組專門的職責,這不是很好嗎?
這意味著我們可以輕松找到代碼庫中的每個職責來添加或修改代碼。也意味著代碼庫很容易掌握,因為我們一次只需要將一個模塊加載到大腦的工作記憶中。
而且,由于每個模塊都有自己的 API,這意味著我們可以為每個模塊創(chuàng)建一個可重用的模擬。在編寫集成測試時,我們只需導入一個模擬模塊并調(diào)用其 API 即可開始模擬。我們不再需要知道我們模擬的類的每一個細節(jié)。
在本文中,我們將著眼于創(chuàng)建這樣的模塊,討論為什么模擬整個模塊比模擬單個 bean 更好,然后介紹一種簡單但有效的模擬完整模塊的方法,以便使用 Spring Boot 進行簡單的測試設置。
?代碼示例
本文附有?GitHub 上的工作代碼示例。
什么是模塊?
當我在本文中談論“模塊”時,我的意思是:
模塊是一組高度內(nèi)聚的類,這些類具有專用的 API 和一組相關的職責。
我們可以將多個模塊組合成更大的模塊,最后組合成一個完整的應用程序。
一個模塊可以通過調(diào)用它的 API 來使用另一個模塊。
你也可以稱它們?yōu)椤敖M件”,但在本文中,我將堅持使用“模塊”。
如何構建模塊?
在構建應用程序時,我建議預先考慮如何模塊化代碼庫。我們的代碼庫中的自然邊界是什么?
我們的應用程序是否需要與外部系統(tǒng)進行通信?這是一個自然的模塊邊界。我們可以構建一個模塊,其職責是與外部系統(tǒng)對話!
我們是否確定了屬于一起的用例的功能“邊界上下文”?這是另一個很好的模塊邊界。我們將構建一個模塊來實現(xiàn)應用程序的這個功能部分中的用例!
當然,有更多方法可以將應用程序拆分為模塊,而且通常不容易找到它們之間的邊界。他們甚至可能會隨著時間的推移而改變!更重要的是在我們的代碼庫中有一個清晰的結(jié)構,這樣我們就可以輕松地在模塊之間移動概念!
為了使模塊在我們的代碼庫中顯而易見,我建議使用以下包結(jié)構:
每個模塊都有自己的包
每個模塊包都有一個?api?子包,包含所有暴露給其他模塊的類
每個模塊包都有一個內(nèi)部子包?internal?,其中包含:實現(xiàn) API 公開的功能的所有類一個 Spring 配置類,它將 bean 提供給實現(xiàn)該 API 所需的 Spring 應用程序上下文
就像俄羅斯套娃一樣,每個模塊的?internal?子包可能包含帶有子模塊的包,每個子模塊都有自己的?api?和?internal?包
給定?internal?包中的類只能由該包中的類訪問。
這使得代碼庫非常清晰,易于導航。在我關于清晰架構邊界?中閱讀有關此代碼結(jié)構的更多信息,或?示例代碼中的一些代碼。
這是一個很好的包結(jié)構,但這與測試和模擬有什么關系呢?
模擬單個 Bean 有什么問題?
正如我在開始時所說的,我們想著眼于模擬整個模塊而不是單個 bean。但是首先模擬單個 bean 有什么問題呢?
讓我們來看看使用 Spring Boot 創(chuàng)建集成測試的一種非常常見的方式。
假設我們想為 REST 控制器編寫一個集成測試,該控制器應該在 GitHub 上創(chuàng)建一個存儲庫,然后向用戶發(fā)送電子郵件。
集成測試可能如下所示:
@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(...);
?}
}
復制代碼
這個測試實際上看起來很整潔,我見過(并編寫)了很多類似的測試。但正如人們所說,細節(jié)決定成敗。
我們使用?@WebMvcTest?注解來設置 Spring Boot 應用程序上下文以測試 Spring MVC 控制器。應用程序上下文將包含讓控制器工作所需的所有 bean,僅此而已。
但是我們的控制器在應用程序上下文中需要一些額外的 bean 才能工作,即?GitHubMutations、?GitHubQueries、和?EmailNotificationService。因此,我們通過?@MockBean?注解將這些 bean 的模擬添加到應用程序上下文中。
在測試方法中,我們在一對?given()語句中定義這些模擬的狀態(tài),然后調(diào)用我們要測試的控制器端點,之后?verify()?在模擬上調(diào)用了某些方法。
那么,這個測試有什么問題呢? 我想到了兩件主要的事情:
首先,要設置?given()?和?verify()?部分,測試需要知道控制器正在調(diào)用模擬 bean 上的哪些方法。這種對實現(xiàn)細節(jié)的低級知識使測試容易被修改。每次實現(xiàn)細節(jié)發(fā)生變化時,我們也必須更新測試。這稀釋了測試的價值,并使維護測試成為一件苦差事,而不是“有時是例行公事”。
其次,?@MockBean?注解將導致 Spring 為每個測試創(chuàng)建一個新的應用程序上下文(除非它們具有完全相同的字段)。在具有多個控制器的代碼庫中,這將顯著增加測試運行時間。
如果我們投入一點精力來構建上一節(jié)中概述的模塊化代碼庫,我們可以通過構建可重用的模擬模塊來解決這兩個缺點。
讓我們通過看一個具體的例子來了解如何實現(xiàn)。
模塊化 Spring Boot 應用程序
好,讓我們看看如何使用 Spring Boots 實現(xiàn)可重用的模擬模塊。
這是示例應用程序的文件夾結(jié)構。如果你想跟隨,你可以在 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,用于這些接口的簽名。
在內(nèi)部,?github?模塊有類?GitHubService,它實現(xiàn)了兩個接口,還有類?GitHubModuleConfiguration,它是一個 Spring 配置,為應用程序上下文貢獻一個?GitHubService?實例:
@Configuration
class GitHubModuleConfiguration {
? ?@Bean
? ?GitHubService gitHubService() {
? ? ? ?return new GitHubService();
? ?}
}
復制代碼
由于?GitHubService?實現(xiàn)了?github?模塊的整個 API,因此這個 bean?足以使該模塊的 API 可用于同一 Spring Boot 應用程序中的其他模塊。
Mail 模塊
mail?模塊的構建方式類似。它的 API 由單個接口?EmailNotificationService?組成:
public interface EmailNotificationService {
? ?void sendEmail(String to, String subject, String text);
}
復制代碼
該接口由內(nèi)部 beanEmailNotificationServiceImpl?實現(xiàn)。
請注意,我在?mail?模塊中使用的命名約定與在?github?模塊中使用的命名約定不同。?github?模塊有一個以?*Servicee?結(jié)尾的內(nèi)部類,而?mail?模塊有一個?*Service?類作為其 API 的一部分。雖然?github?模塊不使用丑陋的?*Impl?后綴,但?mail?模塊使用了。
我故意這樣做是為了使代碼更現(xiàn)實一些。你有沒有見過一個代碼庫(不是你自己寫的)在所有地方都使用相同的命名約定?我沒有。
但是,如果您像我們在本文中所做的那樣構建模塊,那實際上并不重要。因為丑陋的?*Impl?類隱藏在模塊的 API 后面。
在內(nèi)部,?mail?模塊具有?EmailModuleConfiguration?類,它為?Spring 應用程序上下文提供 API 實現(xiàn):
@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();
? ?}
}
復制代碼
控制器調(diào)用?github?模塊的 API 來創(chuàng)建一個 GitHub 倉庫,然后通過?mail?模塊的 API 發(fā)送郵件,讓用戶知道新的倉庫。
模擬 GitHub 模塊
現(xiàn)在,讓我們看看如何為?github?模塊構建一個可重用的模擬。我們創(chuàng)建了一個?@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*()?方法允許我們將模擬設置為所需的狀態(tài),而?verify*()?方法允許我們在運行測試后檢查與模擬的交互是否發(fā)生。
@Primary?注解確保如果模擬和真實 bean 都加載到應用程序上下文中,則模擬優(yōu)先。
模擬 Email 郵件模塊
我們?yōu)?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());
? ?}
}
復制代碼
在測試中使用模擬模塊
現(xiàn)在,有了模擬模塊,我們可以在控制器的集成測試中使用它們:
@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?注解的原因,以便模擬優(yōu)先。
如何處理行為異常的模塊?
模塊可能會在啟動期間嘗試連接到某些外部服務而行為異常。例如,?mail?模塊可能會在啟動時創(chuàng)建一個 SMTP 連接池。當沒有可用的 SMTP 服務器時,這自然會失敗。這意味著當我們在集成測試中加載模塊時,Spring 上下文的啟動將失敗。
為了使模塊在測試期間表現(xiàn)得更好,我們可以引入一個配置屬性?mail.enabled。然后,我們使用?@ConditionalOnProperty?注解模塊的配置類,以告訴 Spring 如果該屬性設置為?false,則不要加載此配置。
現(xiàn)在,在測試期間,只加載模擬模塊。
我們現(xiàn)在不是在測試中模擬特定的方法調(diào)用,而是在模擬模塊上調(diào)用準備好的?given*()?方法。這意味著測試不再需要測試對象調(diào)用的類的內(nèi)部知識。
執(zhí)行代碼后,我們可以使用準備好的?verify*()?方法來驗證是否已創(chuàng)建存儲庫或已發(fā)送郵件。同樣,不知道具體的底層方法調(diào)用。
如果我們需要另一個控制器中的?github?或?mail?模塊,我們可以在該控制器的測試中使用相同的模擬模塊。
如果我們稍后決定構建另一個使用某些模塊的真實版本但使用其他模塊的模擬版本的集成,則只需使用幾個?@Import?注解來構建我們需要的應用程序上下文。
這就是模塊的全部思想:我們可以使用真正的模塊 A 和模塊 B 的模擬,我們?nèi)匀挥幸粋€可以運行測試的工作應用程序。
模擬模塊是我們在該模塊中模擬行為的中心位置。他們可以將諸如“確??梢詣?chuàng)建存儲庫”之類的高級模擬期望轉(zhuǎn)換為對 API bean 模擬的低級調(diào)用。
結(jié)論
通過有意識地了解什么是模塊 API 的一部分,什么不是,我們可以構建一個適當?shù)哪K化代碼庫,幾乎不會引入不需要的依賴項。
由于我們知道什么是 API 的一部分,什么不是,我們可以為每個模塊的 API 構建一個專用的模擬。我們不在乎內(nèi)部,我們只是在模擬 API。
模擬模塊可以提供 API 來模擬某些狀態(tài)并驗證某些交互。通過使用模擬模塊的 API 而不是模擬每個單獨的方法調(diào)用,我們的集成測試變得更有彈性以適應變化。