AssertJ 和 Mockito 跟随 spring-boot-starter-test 依赖自动获得。
Web 控制器的职责
==========
让我们从一个典型的 REST 控制器开始:
@RestController``@RequiredArgsConstructor``class RegisterRestController {
private final RegisterUseCase registerUseCase;
@PostMapping("/forums/{forumId}/register")
UserResource register(@PathVariable("forumId") Long forumId, @Valid @RequestBody UserResource userResource,
@RequestParam("sendWelcomeMail") boolean sendWelcomeMail) {
User user = new User(userResource.getName(), userResource.getEmail());
Long userId = registerUseCase.registerUser(user, sendWelcomeMail);
return new UserResource(userId, user.getName(), user.getEmail());
}
}
控制器方法用 @PostMapping 注解来定义它应该侦听的 URL、HTTP 方法和内容类型。
它通过用 @PathVariable、@RequestBody 和 @RequestParam 注解的参数获取输入,这些参数会从传入的 HTTP 请求中自动填充。
参数可以使用 @Valid 进行注解,以指示 Spring 应该对它们 bean 验证。
然后控制器使用这些参数,调用业务逻辑返回一个普通的 Java 对象,默认情况下该对象会自动映射到 JSON 并写入 HTTP 响应体。
这里有很多 spring 魔法。总之,对于每个请求,控制器通常会执行以下步骤:
控制器显然有很多工作要做!
我们应该注意不要添加更多的职责,比如执行业务逻辑。否则,我们的控制器测试将变得臃肿且无法维护。
我们将如何编写有意义的测试,涵盖所有这些职责?
单元测试还是集成测试?
===========
我们写单元测试吗?还是集成测试?到底有什么区别?让我们讨论这两种方法并决定其中一种。
在单元测试中,我们将单独测试控制器。这意味着我们将实例化一个控制器对象,模拟业务逻辑,然后调用控制器的方法并验证响应。
这对我们有用吗?让我们检查一下可以单独的单元测试中涵盖上面确定的 6 个职责中的哪一个:
与 Spring 的集成测试会启动一个包含我们需要的所有 bean 的 Spring 应用程序上下文。这包括负责侦听某些 URL、与 JSON 之间进行序列化和反序列化以及将异常转换为 HTTP 的框架 bean。这些 bean 将评估简单单元测试会忽略的注释。总之,简单的单元测试不会覆盖 HTTP 层。所以,我们需要在我们的测试中引入 Spring 来为我们做 HTTP 魔法。因此,我们正在构建一个集成测试来测试我们的控制器代码和 Spring 为 HTTP 支持提供的组件之间的集成。
那么,我们该怎么做呢?
使用 @WebMvcTest 验证控制器职责
======================
Spring Boot 提供了 @WebMvcTest 注释来启动一个应用程序上下文,该上下文只包含测试 Web 控制器所需的 bean:
@ExtendWith(SpringExtension.class)@WebMvcTest(controllers = RegisterRestController.class)
class RegisterRestControllerTest {
@Autowired
private MockMvc mockMvc;`
@Autowired
private ObjectMapper objectMapper;
@MockBean
private RegisterUseCase registerUseCase;
@Test
void whenValidInput_thenReturns200() throws Exception {
mockMvc.perform(...);
`}``}
@ExtendWith
===========
本教程中的代码示例使用 @ExtendWith 批注告诉 JUnit 5 启用 Spring 支持。从 Spring Boot 2.1 开始,我们不再需要加载 SpringExtension,因为它作为元注释包含在 Spring Boot 测试注解中,例如 @DataJpaTest、@WebMvcTest 和 @SpringBootTest。
我们现在可以 @Autowire 从应用程序上下文中获取我们需要的所有 bean。Spring Boot 自动提供了像 ObjectMapper 这样的 bean 来映射到 JSON 和一个 MockMvc 实例来模拟 HTTP 请求。
我们使用 @MockBean 来模拟业务逻辑,因为我们不想测试控制器和业务逻辑之间的集成,而是控制器和 HTTP 层之间的集成。@MockBean 自动用 Mockito 模拟替换应用程序上下文中相同类型的 bean。
您可以在我关于模拟的文章中阅读有关 @MockBean 注解的更多信息。
使用带或不带 controllers 参数的 @WebMvcTest?
===================================
通过在上面的示例中将 controllers 参数设置为 RegisterRestController.class,我们告诉 Spring Boot 将为此测试创建的应用程序上下文限制为给定的控制器 bean 和 Spring Web MVC 所需的一些框架 bean。我们可能需要的所有其他 bean 必须单独包含或使用 @MockBean 模拟。
如果我们不使用 controllers 参数,Spring Boot 将在应用程序上下文中包含所有控制器。因此,我们需要包含或模拟掉任何控制器所依赖的所有 bean。这使得测试设置更加复杂,具有更多的依赖项,但节省了运行时间,因为所有控制器测试都将重用相同的应用程序上下文。
我倾向于将控制器测试限制在最窄的应用程序上下文中,以使测试独立于我在测试中甚至不需要的 bean,即使 Spring Boot 必须为每个单独的测试创建一个新的应用程序上下文。
让我们来回顾一下每个职责,看看我们如何使用 MockMvc 来验证每一个职责,以便构建我们力所能及的最好的集成测试。
1.验证 HTTP 请求匹配
==============
验证控制器是否侦听某个 HTTP 请求非常简单。我们只需调用 MockMvc 的 perform() 方法并提供我们要测试的 URL:
mockMvc.perform(post(“/forums/42/register”)
.contentType(“application/json”))
.andExpect(status().isOk());
除了验证控制器对特定 URL 的响应之外,此测试还验证正确的 HTTP 方法(在我们的示例中为 POST)和正确的请求内容类型。我们上面看到的控制器会拒绝任何具有不同 HTTP 方法或内容类型的请求。
请注意,此测试仍然会失败,因为我们的控制器需要一些输入参数。
更多匹配 HTTP 请求的选项可以在 MockHttpServletRequestBuilder 的 Javadoc 中找到。
2.验证输入序列化
=========
为了验证输入是否成功序列化为 Java 对象,我们必须在测试请求中提供它。输入可以是请求正文的 JSON 内容 (@RequestBody)、URL 路径中的变量 (@PathVariable) 或 HTTP 请求参数 (@RequestParam):
@Testvoid whenValidInput_thenReturns200() throws Exception {` `UserResource user = new UserResource("Zaphod", "zaphod@galaxy.net");` ` mockMvc.perform(post("/forums/{forumId}/register", 42L)` `.contentType("application/json")` `.param("sendWelcomeMail", "true")` `.content(objectMapper.writeValueAsString(user)))` `.andExpect(status().isOk());
}
我们现在提供路径变量 forumId、请求参数 sendWelcomeMail 和控制器期望的请求正文。请求正文是使用 Spring Boot 提供的 ObjectMapper 生成的,将 UserResource 对象序列化为 JSON 字符串。
如果测试结果为绿色,我们现在知道控制器的 register() 方法已将这些参数作为 Java 对象接收,并且它们已从 HTTP 请求中成功解析。
3.验证输入验证
========
假设 UserResource 使用 @NotNull 注释来拒绝 null 值:
@Value``public class UserResource {`
@NotNull
private final String name;
@NotNull
private final String email;
`}
当我们将 @Valid 注解添加到方法参数时,Bean 验证会自动触发,就像我们在控制器中使用 userResource 参数所做的那样。因此,对于快乐路径(即验证成功时),我们在上一节中创建的测试就足够了。
如果我们想测试验证是否按预期失败,我们需要添加一个测试用例,在该用例中我们将无效的 UserResource JSON 对象发送到控制器。然后我们期望控制器返回 HTTP 状态 400(错误请求):
@Testvoid whenNullValue_thenReturns400() throws Exception {` `UserResource user = new UserResource(null, "zaphod@galaxy.net");` ` mockMvc.perform(post("/forums/{forumId}/register", 42L)` `...` `.content(objectMapper.writeValueAsString(user)))` `.andExpect(status().isBadRequest());
}
根据验证对应用程序的重要性,我们可能会为每个可能的无效值添加这样的测试用例。但是,这会很快增加很多测试用例,因此您应该与您的团队讨论您希望如何处理项目中的验证测试。
4.验证业务逻辑调用
==========
接下来,我们要验证业务逻辑是否按预期调用。在我们的例子中,业务逻辑由 RegisterUseCase 接口提供,并需要一个 User 对象和一个 boolean 值作为输入:
interface RegisterUseCase {
Long registerUser(User user, boolean sendWelcomeMail);``}
我们希望控制器将传入的 UserResource 对象转换为 User 并将此对象传递给 registerUser() 方法。
为了验证这一点,我们可以要求 RegisterUseCase 模拟,它已使用 @MockBean 注解注入到应用程序上下文中:
@Test``void whenValidInput_thenMapsToBusinessModel() throws Exception {
UserResource user = new UserResource(“Zaphod”, “zaphod@galaxy.net”);
mockMvc.perform(…);`
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(registerUseCase, times(1)).registerUser(userCaptor.capture(), eq(true));
assertThat(userCaptor.getValue().getName()).isEqualTo("Zaphod");
`assertThat(userCaptor.getValue().getEmail()).isEqualTo(“zaphod@galaxy.net”);``}
在执行了对控制器的调用之后,我们使用 ArgumentCaptor 来捕获传递给 RegisterUseCase.registerUser() 的 User 对象并断言它包含预期值。
调用 verify 检查 registerUser() 是否被调用过一次。
请注意,如果我们对 User 对象进行大量断言,我们可以 创建自己的自定义 Mockito 断言方法 以获得更好的可读性。
5.验证输出序列化
=========
调用业务逻辑后,我们希望控制器将结果映射到 JSON 字符串并将其包含在 HTTP 响应中。在我们的例子中,我们希望 HTTP 响应正文包含一个有效的 JSON 格式的 UserResource 对象:
@Test``void whenValidInput_thenReturnsUserResource() throws Exception {
MvcResult mvcResult = mockMvc.perform(…)
…
.andReturn();`
UserResource expectedResponseBody = ...;
String actualResponseBody = mvcResult.getResponse().getContentAsString();
assertThat(actualResponseBody).isEqualToIgnoringWhitespace(
`objectMapper.writeValueAsString(expectedResponseBody));``}
要对响应主体进行断言,我们需要使用 andReturn() 方法将 HTTP 交互的结果存储在 MvcResult 类型的变量中。
然后我们可以从响应正文中读取 JSON 字符串,并使用 isEqualToIgnoringWhitespace() 将其与预期的字符串进行比较。我们可以使用 Spring Boot 提供的 ObjectMapper 从 Java 对象构建预期的 JSON 字符串。
请注意,我们可以通过使用自定义的 ResultMatcher 使其更具可读性,稍后对此加以描述。
6.验证异常处理
========
通常,如果发生异常,控制器应该返回某个 HTTP 状态。400 — 如果请求有问题,500 — 如果出现异常,等等。
默认情况下,Spring 会处理大多数这些情况。但是,如果我们有自定义异常处理,我们想测试它。假设我们想要返回一个结构化的 JSON 错误响应,其中包含请求中每个无效字段的字段名称和错误消息。我们会像这样创建一个 @ControllerAdvice:
@ControllerAdvice``class ControllerExceptionHandler {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
ErrorResult handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
ErrorResult errorResult = new ErrorResult();
for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
errorResult.getFieldErrors()
.add(new FieldValidationError(fieldError.getField(), fieldError.getDefaultMessage()));
}
return errorResult;
}`
@Getter
@NoArgsConstructor
static class ErrorResult {
private final List<FieldValidationError> fieldErrors = new ArrayList<>();
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)
最后
正值金三银四招聘旺季,很多小伙伴都询问我有没有前端方面的面试题,特地整理出来赠送给大家!
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!
[外链图片转存中…(img-WjeXRstu-1713557376834)]
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)
[外链图片转存中…(img-vK26SgZ8-1713557376834)]
最后
正值金三银四招聘旺季,很多小伙伴都询问我有没有前端方面的面试题,特地整理出来赠送给大家!