乐趣区

关于springboot:使用-Spring-Boot-和-WebMvcTest-测试-MVC-Web-Controller

【注】本文译自: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 上找到。

退出移动版