共计 11382 个字符,预计需要花费 29 分钟才能阅读完成。
【注】本文译自: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 层之间的集成。@MockBea
n 主动用 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):
@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 上找到。