你可以通过实例化一个控制器,为其注入依赖关系,并调用其方法,为 Spring MVC 编写普通的单元测试。然而,这样的测试并不能验证请求映射、数据绑定、消息转换、类型转换、验证,也不涉及任何支持 @InitBinder、@ModelAttribute 或 @ExceptionHandler 方法。

Spring MVC 测试框架,也被称为 MockMvc,旨在为 Spring MVC 控制器提供更完整的测试,而无需运行服务器。它通过调用DispacherServlet 和传递来自 spring-test 模块的 Servlet API 的 「mock」实现来实现这一目标,该模块在没有运行服务器的情况下复制了完整的 Spring MVC 请求处理。

MockMvc 是一个服务器端测试框架,可以让你使用轻量级和有针对性的测试来验证 Spring MVC 应用程序的大部分功能。你可以单独使用它来执行请求和验证响应,或者你也可以通过 WebTestClient API 使用它,将 MockMvc 插入作为处理请求的服务器。

静态导入

当直接使用 MockMvc 来执行请求时,你将需要静态导入。

  • MockMvcBuilders.*
  • MockMvcRequestBuilders.*
  • MockMvcResultMatchers.*
  • MockMvcResultHandlers.*

记住这个的简单方法是搜索 MockMvc*。如果使用 Eclipse,请确保在 Eclipse 首选项中将上述内容添加为 「favorite static members」

当通过 WebTestClient 使用 MockMvc 时,你不需要静态导入。WebTestClient 提供了一个流畅的 API,无需静态导入。

Setup Choices / 设置选择

MockMvc 可以通过两种方式之一进行设置。一种是直接指向你想测试的控制器,并以编程方式配置 Spring MVC 基础设施。第二种是指向带有 Spring MVC 和控制器基础设施的 Spring 配置。

要设置 MockMvc 来测试一个特定的控制器,请使用以下方法:

  1. class MyWebTests {
  2. MockMvc mockMvc;
  3. @BeforeEach
  4. void setup() {
  5. this.mockMvc = MockMvcBuilders.standaloneSetup(new AccountController()).build();
  6. }
  7. // ...
  8. }

或者你也可以在通过 WebTestClient 进行测试时使用这个设置,WebTestClient 委托给了上面所示的同一个构建器。

要通过 Spring 配置来设置 MockMvc,请使用下面的方法:

  1. @SpringJUnitWebConfig(locations = "my-servlet-context.xml")
  2. class MyWebTests {
  3. MockMvc mockMvc;
  4. @BeforeEach
  5. void setup(WebApplicationContext wac) {
  6. this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
  7. }
  8. // ...
  9. }

或者你也可以在通过 WebTestClient 进行测试时使用这种设置,它委托给上述相同的构建器。

你应该使用哪个设置选项?

webAppContextSetup 加载你的实际 Spring MVC 配置,导致一个更完整的集成测试。由于 TestContext 框架缓存了加载的 Spring 配置,它有助于保持测试快速运行,即使你在测试套件中引入更多的测试。此外,你可以通过 Spring 配置将模拟服务注入到控制器中,以保持对 Web 层的测试专注。下面的例子用 Mockito 声明了一个模拟服务。

  1. <bean id="accountService" class="org.mockito.Mockito" factory-method="mock">
  2. <constructor-arg value="org.example.AccountService"/>
  3. </bean>

然后,你可以将模拟服务注入测试中,以设置和验证你的期望,如下例所示。

  1. @SpringJUnitWebConfig(locations = "test-servlet-context.xml")
  2. class AccountTests {
  3. @Autowired
  4. AccountService accountService;
  5. MockMvc mockMvc;
  6. @BeforeEach
  7. void setup(WebApplicationContext wac) {
  8. this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
  9. }
  10. // ...
  11. }

另一方面,StandaloneSetup 更接近于一个单元测试。它一次测试一个控制器。你可以手动注入控制器的模拟依赖,它不涉及加载 Spring 配置。这样的测试更注重风格,更容易看到哪个控制器被测试,是否需要任何特定的 Spring MVC 配置来工作,等等。独立的 Setup 也是一种非常方便的方式,可以编写临时的测试来验证特定的行为或调试一个问题。

就像大多数 「集成与单元测试」的辩论一样,没有正确或错误的答案。然而,使用 standaloneSetup 确实意味着需要额外的 webAppContextSetup 测试,以验证 Spring MVC 的配置。另外,你也可以用 webAppContextSetup 来编写所有的测试,以便始终针对你的实际 Spring MVC 配置进行测试。

Setup Features / 设置功能

无论你使用哪种 MockMvc 构建器,所有的 MockMvcBuilder 实现都提供了一些常见的、非常有用的功能。例如,你可以为所有的请求声明一个 Accept 头,并期望在所有的响应中获得 200 的状态和 Content-Type 头,如下所示:

  1. // 静态导入 MockMvcBuilders.standaloneSetup
  2. MockMvc mockMvc = standaloneSetup(new MusicController())
  3. .defaultRequest(get("/").accept(MediaType.APPLICATION_JSON))
  4. .alwaysExpect(status().isOk())
  5. .alwaysExpect(content().contentType("application/json;charset=UTF-8"))
  6. .build();

此外,第三方框架(和应用程序)可以预先打包设置指令,例如 MockMvcConfigurer 中的指令。Spring 框架有一个这样的内置实现,它可以帮助保存和重用跨请求的 HTTP 会话。你可以按以下方式使用它。

  1. // 静态导入 SharedHttpSessionConfigurer.sharedHttpSession
  2. MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
  3. .apply(sharedHttpSession())
  4. .build();
  5. // Use mockMvc to perform requests...
  6. // 使用 mockMvc 来执行请求...

参见 ConfigurableMockMvcBuilder 的 javadoc,以获得所有MockMvc构建器功能的列表,或者使用IDE来探索可用的选项。

Performing Requests / 执行请求

本节展示了如何单独使用 MockMvc 来执行请求和验证响应。如果通过 WebTestClient 使用 MockMvc,请看相应的编写测试部分

要执行使用任何 HTTP 方法的请求,如下面的例子所示:

  1. // static import of MockMvcRequestBuilders.*
  2. mockMvc.perform(post("/hotels/{id}", 42).accept(MediaType.APPLICATION_JSON));

你也可以执行文件上传请求,在内部使用 MockMultipartHttpServletRequest,这样就没有实际解析多部分请求。相反,你必须把它设置成类似于下面的例子:

  1. mockMvc.perform(multipart("/doc").file("a1", "ABC".getBytes("UTF-8")));

你可以在 URI 模板风格中指定查询参数,如下例所示:

  1. mockMvc.perform(get("/hotels?thing={thing}", "somewhere"));

你也可以添加代表查询或表单参数的 Servlet 请求参数,如下例所示:

  1. mockMvc.perform(get("/hotels").param("thing", "somewhere"));

如果应用程序代码依赖于 Servlet 请求参数,并且不明确检查查询字符串(这是最常见的情况),那么你使用哪个选项并不重要。然而,请记住,与 URI 模板一起提供的查询参数会被解码,而通过 param(..)方法提供的请求参数预计已经被解码了。

在大多数情况下,最好不要在请求 URI 中加入上下文路径和 Servlet 路径。如果你必须用完整的请求 URI 进行测试,一定要相应地设置contextPath 和 servletPath,这样请求映射才会有效,正如下面的例子所示:

  1. mockMvc.perform(get("/app/main/hotels/{id}").contextPath("/app").servletPath("/main"))

在前面的例子中,每次执行请求都要设置 contextPath 和 servletPath 是很麻烦的。相反,你可以设置默认的请求属性,如下面的例子所示。

  1. class MyWebTests {
  2. MockMvc mockMvc;
  3. @BeforeEach
  4. void setup() {
  5. mockMvc = standaloneSetup(new AccountController())
  6. .defaultRequest(get("/")
  7. .contextPath("/app").servletPath("/main")
  8. .accept(MediaType.APPLICATION_JSON)).build();
  9. }
  10. }

前面的属性会影响通过 MockMvc 实例执行的每个请求。如果在一个给定的请求中也指定了相同的属性,它将覆盖默认值。这就是为什么默认请求中的 HTTP 方法和 URI 并不重要,因为它们必须在每个请求中被指定。

定义期望值

你可以通过在执行请求后附加一个或多个 andExpect(..) 调用来定义期望,如下面的例子所示。一旦一个期望失败,其他的期望就不会被断言。

  1. // static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.*
  2. mockMvc.perform(get("/accounts/1")).andExpect(status().isOk());

你可以通过在执行一个请求后附加 andExpectAll(..)来定义多个期望,正如下面的例子所示。与 andExpect(...)相反,andExpectAll(...)保证所有提供的期望将被断言,所有失败将被跟踪和报告。

  1. / static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.*
  2. mockMvc.perform(get("/accounts/1")).andExpectAll(
  3. status().isOk(),
  4. content().contentType("application/json;charset=UTF-8"));

MockMvcResultMatchers.*提供了一些期望,其中一些期望又进一步嵌套了更详细的期望。

期望一般分为两类。第一类断言验证了响应的属性(例如,响应状态、头信息和内容)。这些是要断言的最重要的结果。

第二类断言超越了响应。这些断言让你检查 Spring MVC 的具体方面,例如哪个控制器方法处理了请求,是否引发和处理了异常,模型的内容是什么,选择了什么视图,添加了什么 Flash 属性,等等。它们还可以让你检查 Servlet 的具体方面,如请求和会话属性。

下面的测试断言,绑定或验证失败:

  1. mockMvc.perform(post("/persons"))
  2. .andExpect(status().isOk())
  3. .andExpect(model().attributeHasErrors("person"));

很多时候,在编写测试时,转储执行请求的结果是很有用的。你可以这样做,print()是 MockMvcResultHandlers 的一个静态导入。

  1. mockMvc.perform(post("/persons"))
  2. .andDo(print())
  3. .andExpect(status().isOk())
  4. .andExpect(model().attributeHasErrors("person"));

只要请求处理没有引起未处理的异常,print()方法就会将所有可用的结果数据打印到 System.out。还有一个 log()方法和两个额外的print()方法的变体,一个接受 OutputStream,一个接受 Writer。例如,调用 print(System.err)将结果数据打印到 System.err,而调用print(myWriter)将结果数据打印到一个自定义的写入器。如果你想把结果数据记录下来,而不是打印出来,你可以调用 log()方法,它把结果数据记录为 org.springframework.test.web.servlet.result logging 类别下的一条 DEBUG 消息。

在某些情况下,你可能想直接访问结果,并验证一些以其他方式无法验证的东西。这可以通过在所有其他期望之后附加 .andReturn()来实现,如下面的例子所示。

  1. MvcResult mvcResult = mockMvc.perform(post("/persons")).andExpect(status().isOk()).andReturn();
  2. // ...

如果所有的测试都重复相同的期望,你可以在构建 MockMvc 实例时设置一次共同的期望,正如下面的例子所示。

  1. standaloneSetup(new SimpleController())
  2. .alwaysExpect(status().isOk())
  3. .alwaysExpect(content().contentType("application/json;charset=UTF-8"))
  4. .build()

请注意,共同的期望总是被应用的,如果不创建一个单独的 MockMvc 实例,就不能被重写。

当 JSON 响应内容包含用 Spring HATEOAS 创建的超媒体链接时,你可以通过使用 JsonPath 表达式来验证产生的链接,如下例所示。

  1. Map<String, String> ns = Collections.singletonMap("ns", "http://www.w3.org/2005/Atom");
  2. mockMvc.perform(get("/handle").accept(MediaType.APPLICATION_XML))
  3. .andExpect(xpath("/person/ns:link[@rel='self']/@href", ns).string("http://localhost:8080/people"));

异步请求

本节展示了如何使用 MockMvc 来测试异步请求处理。如果通过 WebTestClient 来使用 MockMvc,就没有什么特别的事情要做,因为WebTestClient 会自动完成本节中所描述的工作,从而使异步请求发挥作用。

在 Spring MVC 中支持的 Servlet 3.0 异步请求是通过退出 Servlet 容器线程并允许应用程序异步地计算响应,之后进行异步调度以完成 Servlet 容器线程的处理。

在 Spring MVC 测试中,可以通过首先断言产生的异步值,然后手动执行异步分派,最后验证响应来测试异步请求。下面是一个测试控制器方法的例子,这些方法返回 DeferredResult、Callable 或反应式类型,如 Reactor Mono:

  1. // static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.*
  2. @Test
  3. void test() throws Exception {
  4. MvcResult mvcResult = this.mockMvc.perform(get("/path"))
  5. .andExpect(status().isOk()) // 检查响应状态
  6. .andExpect(request().asyncStarted()) // 异步处理必须已经开始
  7. .andExpect(request().asyncResult("body")) // 等待并断言异步处理的结果
  8. .andReturn();
  9. this.mockMvc.perform(asyncDispatch(mvcResult)) // 手动执行 ASYNC 调度(因为没有运行的容器)。
  10. .andExpect(status().isOk()) // 验证最终的响应
  11. .andExpect(content().string("body"));
  12. }

流式响应

测试流响应(如服务器发送的事件)的最好方法是通过 WebTestClient,它可以作为测试客户端连接到 MockMvc 实例,在没有运行服务器的情况下对 Spring MVC 控制器进行测试。比如说。

  1. WebTestClient client = MockMvcWebTestClient.bindToController(new SseController()).build();
  2. FluxExchangeResult<Person> exchangeResult = client.get()
  3. .uri("/persons")
  4. .exchange()
  5. .expectStatus().isOk()
  6. .expectHeader().contentType("text/event-stream")
  7. .returnResult(Person.class);
  8. //使用 Project Reactor 的 StepVerifier 来测试流式响应。
  9. StepVerifier.create(exchangeResult.getResponseBody())
  10. .expectNext(new Person("N0"), new Person("N1"), new Person("N2"))
  11. .expectNextCount(4)
  12. .consumeNextWith(person -> assertThat(person.getName()).endsWith("7"))
  13. .thenCancel()
  14. .verify();

WebTestClient 还可以连接到一个实时服务器,并执行完整的端到端集成测试。这在 Spring Boot 中也得到了支持,你可以测试一个正在运行的服务器。

过滤器的注册

在设置 MockMvc 实例时,你可以注册一个或多个 Servlet Filter 实例,如下例所示。

  1. mockMvc = standaloneSetup(new PersonController()).addFilters(new CharacterEncodingFilter()).build();

注册的过滤器通过 spring-test 的 MockFilterChain 被调用,最后一个过滤器委托给 DispatcherServlet。

MockMvc 与端到端的测试

MockMVc 是建立在 spring-test 模块的 Servlet API 模拟实现上的,不依赖于运行中的容器。因此,与有实际客户端和运行中的服务器的完全端到端集成测试相比,有一些区别。

最简单的方法是,从一个空白的 MockHttpServletRequest 开始考虑这个问题。无论你向它添加什么,它都会变成一个请求。可能会让你吃惊的是,默认情况下没有上下文路径;没有 jsessionid cookie;没有转发、错误或异步分派;因此,没有实际的 JSP 渲染。相反,「转发 」和 「重定向」 的URL被保存在 MockHttpServletResponse 中,并且可以用期望来断言。

这意味着,如果你使用 JSP,你可以验证请求被转发到的 JSP 页面,但没有 HTML 被渲染。换句话说,JSP 没有被调用。然而,请注意,所有其他不依赖转发的渲染技术,如 Thymeleaf 和 Freemarker,都会按照预期将 HTML 渲染到响应体。通过 @ResponseBody 方法渲染 JSON、 XML 和其他格式也是如此。

另外,你也可以考虑通过 @SpringBootTest 从 Spring Boot 获得完整的端到端集成测试支持。请参阅 Spring Boot 参考指南

每种方法都有优点和缺点。在 Spring MVC 测试中提供的选项在从经典单元测试到完全集成测试的范围内是不同的。可以肯定的是,Spring MVC 测试中的所有选项都不属于经典单元测试的范畴,但它们离经典单元测试更近一些。例如,你可以通过将模拟服务注入控制器来隔离Web 层,在这种情况下,你只通过 DispatcherServlet 来测试 Web 层,但要使用实际的 Spring 配置,就像你可能在隔离上面的层来测试数据访问层一样。另外,你也可以使用独立的设置,一次只关注一个控制器,并手动提供使其工作所需的配置。

在使用 Spring MVC 测试时,另一个重要的区别是,从概念上讲,这种测试是服务器端的,所以你可以检查使用了什么处理程序,是否用 HandlerExceptionResolver 处理了一个异常,模型的内容是什么,有什么绑定错误,以及其他细节。这意味着写预期比较容易,因为服务器不是一个不透明的盒子,就像通过实际的 HTTP 客户端测试时那样。这通常是经典单元测试的一个优势。它更容易编写、推理和调试,但不能取代对完整集成测试的需要。同时,重要的是不要忽略了响应是最重要的检查内容这一事实。简而言之,即使在同一个项目中,这里也有多种风格和策略的测试空间。

更多的例子

该框架自己的测试包括 许多示例测试,旨在展示如何单独或通过 WebTestClient 使用 MockMvc。浏览这些例子可以获得更多的想法。