共计 9677 个字符,预计需要花费 25 分钟才能阅读完成。
【译】本文译自: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 而不是模仿每个独自的办法调用,咱们的集成测试变得更有弹性以适应变动。