【譯】本文譯自: 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 才能工作,即 GitHubMutations、 GitHubQueries、和 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.enabled。然後,我們使用@ConditionalOnProperty註解模塊的配置類,以告訴 Spring 如果該屬性設置為false,則不要加載此配置。
現在,在測試期間,只加載模擬模塊。
我們現在不是在測試中模擬特定的方法調用,而是在模擬模塊上調用準備好的 given*() 方法。這意味着測試不再需要測試對象調用的類的內部知識。
執行代碼後,我們可以使用準備好的 verify*() 方法來驗證是否已創建存儲庫或已發送郵件。同樣,不知道具體的底層方法調用。
如果我們需要另一個控制器中的 github 或 mail 模塊,我們可以在該控制器的測試中使用相同的模擬模塊。
如果我們稍後決定構建另一個使用某些模塊的真實版本但使用其他模塊的模擬版本的集成,則只需使用幾個 @Import 註解來構建我們需要的應用程序上下文。
這就是模塊的全部思想:我們可以使用真正的模塊 A 和模塊 B 的模擬,我們仍然有一個可以運行測試的工作應用程序。
模擬模塊是我們在該模塊中模擬行為的中心位置。他們可以將諸如“確保可以創建存儲庫”之類的高級模擬期望轉換為對 API bean 模擬的低級調用。
結論
通過有意識地瞭解什麼是模塊 API 的一部分,什麼不是,我們可以構建一個適當的模塊化代碼庫,幾乎不會引入不需要的依賴項。
由於我們知道什麼是 API 的一部分,什麼不是,我們可以為每個模塊的 API 構建一個專用的模擬。我們不在乎內部,我們只是在模擬 API。
模擬模塊可以提供 API 來模擬某些狀態並驗證某些交互。通過使用模擬模塊的 API 而不是模擬每個單獨的方法調用,我們的集成測試變得更有彈性以適應變化。