【注】本文译自: Testing MVC Web Controllers with Spring Boot and @WebMvcTest - Reflectoring

在有关使用 Spring Boot 进行测试的系列的第二部分中,我们将了解 Web 控制器。首先,我们将探索 Web 控制器的实际作用,这样我们就可以构建涵盖其所有职责的测试。 然后,我们将找出如何在测试中涵盖这些职责。只有涵盖了这些职责,我们才能确保我们的控制器在生产环境中按预期运行。

 代码示例

本文附有 GitHub 上的工作代码示例。

依赖

我们将使用 JUnit Jupiter (JUnit 5) 作为测试框架,使用 Mockito 进行模拟,使用 AssertJ 来创建断言,使用 Lombok 来减少样板代码:

dependencies {
    compile('org.springframework.boot:spring-boot-starter-web')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.junit.jupiter:junit-jupiter:5.4.0')
    testCompile('org.mockito:mockito-junit-jupiter:2.23.0')
}

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 魔法。总之,对于每个请求,控制器通常会执行以下步骤:

# 职责 描述
1. 监听 HTTP 请求 控制器应该响应某些 URL、HTTP 方法和内容类型。
2. 反序列化输入 控制器应该解析传入的 HTTP 请求并根据 URL、HTTP 请求参数和请求正文中的变量创建 Java 对象,以便我们可以在代码中使用它们。
3. 验证输入 控制器是防止错误输入的第一道防线,因此它是我们可以验证输入的地方。
4. 调用业务逻辑 解析输入后,控制器必须将输入转换为业务逻辑期望的模型并将其传递给业务逻辑。
5. 序列化输出 控制器获取业务逻辑的输出并将其序列化为 HTTP 响应。
6. 转换异常 如果在某个地方发生异常,控制器应将其转换为对用户有意义的错误消息和 HTTP 状态。

控制器显然有很多工作要做! 我们应该注意不要添加更多的职责,比如执行业务逻辑。否则,我们的控制器测试将变得臃肿且无法维护。 我们将如何编写有意义的测试,涵盖所有这些职责?

单元测试还是集成测试?

我们写单元测试吗?还是集成测试?到底有什么区别?让我们讨论这两种方法并决定其中一种。 在单元测试中,我们将单独测试控制器。这意味着我们将实例化一个控制器对象,模拟业务逻辑,然后调用控制器的方法并验证响应。 这对我们有用吗?让我们检查一下可以单独的单元测试中涵盖上面确定的 6 个职责中的哪一个:

# 职责 可以在单元测试中涵盖吗
1. 监听 HTTP 请求 ❌ 不,因为单元测试不会评估 @PostMapping 注解和指定 HTTP 请求属性的类似注解。
2. 反序列化输入 ❌ 不,因为像@RequestParam 和 @PathVariable 这样的注释不会被评估。相反,我们将输入作为 Java 对象提供,从而有效地跳过 HTTP 请求的反序列化。
3. 验证输入 ❌ 不依赖于 bean 验证,因为不会评估 @Valid 注释。
4. 调用业务逻辑 ✔ 是的,因为我们可以验证是否使用预期的参数调用了模拟的业务逻辑。
5. 序列化输出 ❌ 不能,因为我们只能验证输出的 Java 版本,而不能验证将生成的 HTTP 响应。
6. 转换异常 ❌ 不可以。我们可以检查是否引发了某个异常,但不能检查它是否被转换为某个 JSON 响应或 HTTP 状态代码。

与 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 请求非常简单。我们只需调用 MockMvcperform() 方法并提供我们要测试的 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):

@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 和控制器期望的请求正文。请求正文是使用 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(错误请求):

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

我们希望控制器将传入的 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<>();

        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 数据结构来处理这个异常。在这种情况下,异常处理程序会导致所有控制器返回 HTTP 状态 400,并将 ErrorResult对象作为 JSON 字符串放入响应正文中。 为了验证这确实发生了,我们扩展了我们之前对失败验证的测试:

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

同样,我们从响应正文中读取 JSON 字符串,并将其与预期的 JSON 字符串进行比较。此外,我们检查响应状态是否为 400。 这也可以以可读性更强的方式实现,我们接下来将要学习。创建自定义 ResultMatcher 某些断言很难写,更重要的是,很难阅读。特别是当我们想要将来自 HTTP 响应的 JSON 字符串与预期值进行比较时,它需要大量代码,正如我们在最后两个示例中看到的那样。 幸运的是,我们可以创建自定义的 ResultMatcher,我们可以在 MockMvc 的流畅 API 中使用它们。让我们看看如何做到这一点。匹配 JSON 输出 使用以下代码来验证 HTTP 响应正文是否包含某个 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 的入口点。它返回实际的 ResultMatcher,它从 HTTP 响应正文解析 JSON,并将其与传入的预期对象逐个字段进行比较。匹配预期的验证错误 我们甚至可以更进一步简化我们的异常处理测试。我们用了 4 行代码来验证 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,我们必须从上面添加方法 containsErrorMessageForField() 到我们的 ResponseBodyMatchers 类:

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

所有丑陋的代码都隐藏在这个辅助类中,我们可以在集成测试中愉快地编写干净的断言。

结论

Web 控制器有很多职责。如果我们想用有意义的测试覆盖一个 web 控制器,仅仅检查它是否返回正确的 HTTP 状态是不够的。 通过 @WebMvcTest,Spring Boot 提供了我们构建 Web 控制器测试所需的一切,但为了使测试有意义,我们需要记住涵盖所有职责。否则,我们可能会在运行时遇到丑陋的惊喜。 本文中的示例代码可在 GitHub 上找到。