用@WebMvcTest测试MVC Web Contorller(二)

祝坤荣
原文 https://reflectoring.io/sprin...
翻译: 祝坤荣
阅读大约需要10分钟

1. 校验匹配HTTP请求

验证一个controller监听一个特定的HTTP请求很直接。我们只要调用MockMvc的perform()方法并提供要测试的URL即可:

mockMvc.perform(post("/forums/42/register")
    .contentType("application/json"))
    .andExpect(status().isOk());

不只是校验controller会对一个特定的请求会有响应,这个测试也可以校验HTTP方法(这个例子是POST)与请求的content type是否正确。以上controller会拒绝任何用了不同HTTP方法或content type的请求。

记住这个测试仍然会失败,因为我们的controller期望一些入参。

更多匹配HTTP请求的内容可以在Javadoc MockHttpServletRequestBuilder中看到。

2. 校验输入

为了校验入参被成功的序列化成了Java对象,我们需要在测试请求中提供它。输入可以是请求body(@RequestBody)里的JSON内容,一个URL中的变量(@PathVariable)或一个HTTP请求中的参数(@RequestParam):

@Test
void 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和controller期望的请求body。请求body是用Spring Boot提供的ObjectMapper生成的,将UserResource对象序列化成了一个JSON字符串。

如果测试绿了,那么我们就知道了controller的register()方法可以将将这些HTTP请求的参数并将其解析成为Java对象。

3. 检查输入校验

让我们看下UserResource用@NotNull声明来拒绝null值:

@Test
void 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());
}

当我们为方法参数增加了@Valid的声明后Bean检验会自动触发。所以,对于走乐观路径来说(比如让检验成功),我们在上一节创建的测试已经足够了。

如果我们想要测试一下检验失败的情况,我们需要加一个测试用例,发送一个不合法的UserResouceJSON对象给controller.我们期望controller返回HTTP状态400(Bad request):

@Test
void 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);
}

我们期望controller将传入的UserResource对象转成User并将这个对象传给registerUser()方法。

为了验证这个,我们可以模拟RegisterUseCase,其是被声明了@MockBean声明并被注入到application context:

@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");
}

当调用了controller后,我们使用ArgumentCaptor来捕捉传给RegisterUseCase.registerUser()的User对象并检查它包含了期望的值。

verify用来检查registerUser()确实被调用了一次。

记住如果我们对User对象做了很多断言假设,为了更易读,我们可以写一个自定义Mockito断言方法

5. 检查输出序列化

在业务逻辑被调用后,我们期望controller将结果封装到JSON字符串并放在HTTP响应里。在这个例子,我们期望HTTP响应body里有一个有效的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));
}

为了对响应body做断言,我们需要将HTTP交互的结果存储在一个使用andReturn方法返回的类型MvcResult中。

然后可以从响应body中读取JSON字符串并使用isEqualToIgnoringWhitespce()来比较预期字符串。我们可以用Spring Boot提供的ObjectMapper来将Java对象编程一个JSON字符串。

记住我们通过使用一个自定义的ResultMatcher来让这些更易读,后面会介绍

6. 校验异常处理

通常,如果一个异常发生,controller会返回一个特定的HTTP状态码,400,是请求出问题了,500,是异常出现了,等等。

Spring默认能处理大部分这些情况。尽管如此,如果我们有自定义的异常处理,我们会需要测试。比如我们想要对每个无效的表单项返回一个结构化的带表单名和错误信息的响应。先写一个@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<>();
    ErrorResult(String field, String message){
      this.fieldErrors.add(new FieldValidationError(field, message));
    }
  }

  @Getter
  @AllArgsConstructor
  static class FieldValidationError {
    private String field;
    private String message;
  }
  
}

如果bean校验失败,Spring抛出MethodArgumentNotValidException。我们通过将Spring的FieldError映射到我们自己的ErrorResult数据结构来处理这个异常。异常处理会让所有controller返回HTTP 400状态并将ErrorResult对象转成JSON字符串放在响应body。

要校验这个动作,我们使用之前的测试来让校验失败:

@Test
void whenNullValue_thenReturns400AndErrorResult() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  MvcResult mvcResult = mockMvc.perform(...)
          .contentType("application/json")
          .param("sendWelcomeMail", "true")
          .content(objectMapper.writeValueAsString(user)))
          .andExpect(status().isBadRequest())
          .andReturn();

  ErrorResult expectedErrorResponse = new ErrorResult("name", "must not be null");
  String actualResponseBody = 
      mvcResult.getResponse().getContentAsString();
  String expectedResponseBody = 
      objectMapper.writeValueAsString(expectedErrorResponse);
  assertThat(actualResponseBody)
      .isEqualToIgnoringWhitespace(expectedResponseBody);
}

一样的,我们从响应body读取JSON字符串并与期望的JSON字符串来比较。而且,我们也检查响应码是400.

这些,也可以按更可读的方式来实现,就像之前学过的

编写自定义ResultMatchers

特定的断言不太好写,更重要的是,比较难读。特别是当我们想要从HTTP响应中比较JSON字符串是否符合预期时需要写很多代码,就像我们在上两个例子看到的。

幸运的是,我们可以使用MockMvc内置的API来写一个自定义的ResultMatcher。来看下在这个例子里我们怎么做。

匹配JSON输出

如果像下面的代码一样来比较HTTP响应body中是否包含一个Java对象的JSON形式是不是很舒服?

@Test
void whenValidInput_thenReturnsUserResource_withFluentApi() throws Exception {
  UserResource user = ...;
  UserResource expected = ...;

  mockMvc.perform(...)
      ...
      .andExpect(responseBody().containsObjectAsJson(expected, UserResource.class));
}

不需要手动比较JSON字符串了。并且更具有可读性。事实上,代码可以自解释。

要像上面这样使用代码,我们要写一个自定义的ResultMatcher:

public class ResponseBodyMatchers {
  private ObjectMapper objectMapper = new ObjectMapper();

  public <T> ResultMatcher containsObjectAsJson(
      Object expectedObject, 
      Class<T> targetClass) {
    return mvcResult -> {
      String json = mvcResult.getResponse().getContentAsString();
      T actualObject = objectMapper.readValue(json, targetClass);
      assertThat(actualObject).isEqualToComparingFieldByField(expectedObject);
    };
  }
  
  static ResponseBodyMatchers responseBody(){
    return new ResponseBodyMatchers();
  }
  
}

静态方法responseBody()作为我们API的入口。它返回从HTTP响应body的实际ResultMatcher并且逐项比较是否与预期对象相符。

匹配期望的校验错误

我们可以进一步简化我们的异常处理测试。这里用了四行代码来检查JSON响应包含了特定的错误信息。我们可以使用一行替代:

@Test
void whenNullValue_thenReturns400AndErrorResult_withFluentApi() throws Exception {
  UserResource user = new UserResource(null, "zaphod@galaxy.net");

  mockMvc.perform(...)
      ...
      .content(objectMapper.writeValueAsString(user)))
      .andExpect(status().isBadRequest())
      .andExpect(responseBody().containsError("name", "must not be null"));
}

同样,代码可以自解释。

要开启这个API,我们要上面代码里的ResponseBodyMatchers类填加containsErrorMessageForField():

public class ResponseBodyMatchers {
  private ObjectMapper objectMapper = new ObjectMapper();

  public ResultMatcher containsError(
        String expectedFieldName, 
        String expectedMessage) {
    return mvcResult -> {
      String json = mvcResult.getResponse().getContentAsString();
      ErrorResult errorResult = objectMapper.readValue(json, ErrorResult.class);
      List<FieldValidationError> fieldErrors = errorResult.getFieldErrors().stream()
              .filter(fieldError -> fieldError.getField().equals(expectedFieldName))
              .filter(fieldError -> fieldError.getMessage().equals(expectedMessage))
              .collect(Collectors.toList());

      assertThat(fieldErrors)
              .hasSize(1)
              .withFailMessage("expecting exactly 1 error message"
                         + "with field name '%s' and message '%s'",
                      expectedFieldName,
                      expectedMessage);
    };
  }

  static ResponseBodyMatchers responseBody() {
    return new ResponseBodyMatchers();
  }
}

所有的糟糕代码都隐藏在了helper类里,而我们可以愉快的在集成测试里编写干净的断言代码。

结论

Web controller有许多职责。如果我们想要用有意义的测试来覆盖一个web controller,只是检查是否返回HTTP状态码是不够的。

通过@WebMvcTest,Spring Boot提供了所有需要在web controller测试需要的东西,但要让测试有意义,我们要记得覆盖所有职责。不然,我们可能在应用运行时出现惊吓。

这篇文章的代码在github上可用。


本文来自祝坤荣(时序)的微信公众号「麦芽面包,id「darkjune_think」

开发者/科幻爱好者/硬核主机玩家/业余翻译~~~~
微博:祝坤荣
B站: https://space.bilibili.com/23...
转载请注明。

交流Email: zhukunrong@yeah.net

阅读 238

麦芽面包
杭州程序员乱弹,聊技术,看世界。兴趣方向互联网分布式系统稳定性建设,容量规划,压测,监控,容灾多...

科幻影迷,书虫,硬核玩家,译者

997 声望
1.5k 粉丝
0 条评论
你知道吗?

科幻影迷,书虫,硬核玩家,译者

997 声望
1.5k 粉丝
宣传栏