Spring测试:MockMvc

https://docs.spring.io/spring-framework/reference/testing/spring-mvc-test-framework.html

Spring MVC 测试框架,也被称为 MockMvc,为测试 Spring MVC 应用程序提供支持。它通过模拟请求和响应对象而不是运行中的服务器来执行完整的 Spring MVC 请求处理。

MockMvc 可以独立使用来执行请求并验证响应。它也可以通过 WebTestClient 使用,其中 MockMvc 作为服务器插入以处理请求。WebTestClient 的优势在于,它可以选择使用高级对象而不是原始数据进行工作,并且还能够切换到针对实时服务器的完整端到端 HTTP 测试,同时使用相同的测试 API。

概述

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

Spring MVC 测试框架,也被称为 MockMvc,旨在在不运行服务器的情况下为 Spring MVC 控制器提供更完整的测试。它通过调用 DispatcherServlet 并从 spring-test 模块传递 Servlet API 的“模拟”实现来实现这一目标,这些实现会复制完整的 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,请使用以下方式:

class MyWebTests {

	MockMvc mockMvc;

	@BeforeEach
	void setup() {
		this.mockMvc = MockMvcBuilders.standaloneSetup(new AccountController()).build();
	}

	// ...

}

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

要通过 Spring 配置设置 MockMvc,请使用以下代码:

@SpringJUnitWebConfig(locations = "my-servlet-context.xml")
class MyWebTests {

	MockMvc mockMvc;

	@BeforeEach
	void setup(WebApplicationContext wac) {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
	}

	// ...

}

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

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

webAppContextSetup 加载了实际的 Spring MVC 配置,从而实现了更完整的集成测试。由于 TestContext 框架会缓存已加载的 Spring 配置,因此即使在测试套件中引入更多测试时,也有助于保持测试快速运行。此外,可以通过 Spring 配置将模拟服务注入到控制器中,以便专注于测试 Web 层。以下示例使用 Mockito 声明了一个模拟服务:

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

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

@SpringJUnitWebConfig(locations = "test-servlet-context.xml")
class AccountTests {

	@Autowired
	AccountService accountService;

	MockMvc mockMvc;

	@BeforeEach
	void setup(WebApplicationContext wac) {
		this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
	}

	// ...

}

另一方面,standaloneSetup 更接近于单元测试。它一次测试一个控制器。你可以手动将控制器与模拟依赖项进行注入,并且它不涉及加载 Spring 配置。这样的测试更注重于样式,并更容易看出哪个控制器正在被测试,是否需要特定的 Spring MVC 配置才能工作,等等。standaloneSetup 也是一种非常便捷的方式来编写特定行为的临时测试或调试问题。

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

设置功能

无论使用哪种 MockMvc 构建器,所有的 MockMvcBuilder 实现都提供了一些共同且非常有用的功能。例如,可以为所有请求声明一个 Accept 头,并期望所有响应的状态码为 200 以及包含一个 Content-Type 头,如下所示:

// static import of MockMvcBuilders.standaloneSetup

MockMvc mockMvc = standaloneSetup(new MusicController())
	.defaultRequest(get("/").accept(MediaType.APPLICATION_JSON))
	.alwaysExpect(status().isOk())
	.alwaysExpect(content().contentType("application/json;charset=UTF-8"))
	.build();

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

// static import of SharedHttpSessionConfigurer.sharedHttpSession

MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
		.apply(sharedHttpSession())
		.build();

// Use mockMvc to perform requests...

执行请求

这一节展示了如何使用 MockMvc 本身来执行请求并验证响应。

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

// static import of MockMvcRequestBuilders.*

mockMvc.perform(post("/hotels/{id}", 42).accept(MediaType.APPLICATION_JSON));

你还可以执行文件上传请求,该请求内部使用 MockMultipartHttpServletRequest,因此实际上不会解析multipart 请求。相反,你需要将其设置为类似于以下示例:

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

你可以以 URI 模板样式指定查询参数,如下所示:

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

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

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

如果应用程序代码依赖于 Servlet 请求参数,并且没有显式检查查询字符串(这是最常见的情况),那么你使用哪种选项都没有关系。然而,请记住,使用 URI 模板提供的查询参数会被解码,而通过 param(…) 方法提供的请求参数则预期已经是解码过的。

在大多数情况下,最好将上下文路径和 Servlet 路径从请求 URI 中排除。如果你必须使用完整的请求 URI 进行测试,请确保相应地设置 contextPathservletPath,以便请求映射正常工作,如下所示:

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

在前面的例子中,每次执行请求时都设置 contextPathservletPath 会很繁琐。相反,你可以设置默认的请求属性,如下所示:

class MyWebTests {

	MockMvc mockMvc;

	@BeforeEach
	void setup() {
		mockMvc = standaloneSetup(new AccountController())
			.defaultRequest(get("/")
			.contextPath("/app").servletPath("/main")
			.accept(MediaType.APPLICATION_JSON)).build();
	}
}

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

定义期望(Defining Expectations)

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

// static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.*

mockMvc.perform(get("/accounts/1")).andExpect(status().isOk());

你可以在执行请求后通过添加 andExpectAll(..) 来定义多个期望,如下所示。与 andExpect(..) 不同,andExpectAll(..) 保证所有提供的期望都将被断言,并且所有的失败都会被追踪和报告。

// static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.*

mockMvc.perform(get("/accounts/1")).andExpectAll(
	status().isOk(),
	content().contentType("application/json;charset=UTF-8"));

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

期望通常分为两大类。第一类断言用于验证响应的属性(例如,响应状态、头部和内容)。这些是需要断言的最重要的结果。

第二类断言超出了响应的范畴。这些断言允许你检查 Spring MVC 的特定方面,例如哪个控制器方法处理了请求、是否引发了异常并进行了处理、模型的内容是什么、选择了哪个视图、添加了哪些一次性属性等。它们还允许你检查 Servlet 的特定方面,例如请求和会话属性。

以下测试断言绑定或验证失败:

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

在编写测试时,多次将执行请求的结果输出到控制台会很有用。你可以像下面这样操作,这里的 print() 是从 MockMvcResultHandlers 中静态导入的:

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

只要请求处理没有导致未处理的异常,print() 方法就会将所有可用的结果数据打印到 System.out。此外,还有一个 log() 方法以及 print() 方法的两个变体,一个接受 OutputStream,另一个接受 Writer。例如,调用 print(System.err) 将结果数据打印到 System.err,而调用 print(myWriter) 则将结果数据打印到自定义的 writer。如果你希望将结果数据记录到日志中而不是打印出来,你可以调用 log() 方法,该方法会将结果数据作为一条单独的 DEBUG 消息记录到 org.springframework.test.web.servlet.result 日志类别下。

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

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

如果所有的测试都重复相同的期望,你可以在构建 MockMvc 实例时一次性设置常见的期望,如下所示:

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

请注意,常见的期望总是会被应用,并且如果不创建单独的 MockMvc 实例,则无法覆盖它们。

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

mockMvc.perform(get("/people").accept(MediaType.APPLICATION_JSON))
	.andExpect(jsonPath("$.links[?(@.rel == 'self')].href").value("http://localhost:8080/people"));

当 XML 响应内容包含使用 Spring HATEOAS 创建的超媒体链接时,您可以使用 XPath 表达式来验证生成的链接:

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

异步请求(Async Requests)

本节展示了如何使用 MockMvc 来单独测试异步请求处理。如果通过 WebTestClient 使用 MockMvc,则无需进行任何特殊操作即可使异步请求工作,因为 WebTestClient 会自动执行本节中描述的操作。

Spring MVC 支持的 Servlet 异步请求通过退出 Servlet 容器线程并允许应用程序异步计算响应来工作,之后通过异步分派在 Servlet 容器线程上完成处理。

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

// static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.*

@Test
void test() throws Exception {
       MvcResult mvcResult = this.mockMvc.perform(get("/path"))
               .andExpect(status().isOk())
               .andExpect(request().asyncStarted())
               .andExpect(request().asyncResult("body"))
               .andReturn();

       this.mockMvc.perform(asyncDispatch(mvcResult))
               .andExpect(status().isOk())
               .andExpect(content().string("body"));
   }

流式响应(Streaming Responses)

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

WebTestClient client = MockMvcWebTestClient.bindToController(new SseController()).build();

FluxExchangeResult<Person> exchangeResult = client.get()
		.uri("/persons")
		.exchange()
		.expectStatus().isOk()
		.expectHeader().contentType("text/event-stream")
		.returnResult(Person.class);

// Use StepVerifier from Project Reactor to test the streaming response

StepVerifier.create(exchangeResult.getResponseBody())
		.expectNext(new Person("N0"), new Person("N1"), new Person("N2"))
		.expectNextCount(4)
		.consumeNextWith(person -> assertThat(person.getName()).endsWith("7"))
		.thenCancel()
		.verify();

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

过滤器注册

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

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

已注册的过滤器通过来自 spring-testMockFilterChain 进行调用,最后一个过滤器委托给 DispatcherServlet

MockMvc 与端到端测试

MockMvc 是基于 spring-test 模块中的 Servlet API 模拟实现构建的,不依赖于正在运行的容器。因此,与具有实际客户端和正在运行的服务器的完整端到端集成测试相比,存在一些差异。

最简单的理解方式是从一个空白的 MockHttpServletRequest 开始。你添加到它里面的任何东西,都将成为请求的内容。默认情况下,它没有任何上下文路径;没有 jsessionid cookie;没有转发、错误或异步分派;因此,也没有实际的 JSP 渲染。相反,“转发”和“重定向”的 URL 会保存在 MockHttpServletResponse 中,可以用预期来断言它们。

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

或者,你也可以考虑使用 Spring Boot 的 @SpringBootTest 来支持完整的端到端集成测试。

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

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

HtmlUnit 集成

Spring 提供了 MockMvc 和 HtmlUnit 之间的集成。这在使用基于 HTML 的视图时简化了端到端测试的执行。这种集成使你能够:

  • 无需部署到 Servlet 容器,即可轻松使用 HtmlUnit、WebDriver 和 Geb 等工具测试 HTML 页面。
  • 测试页面中的 JavaScript。
  • 可选地,使用模拟服务进行测试以加快测试速度。
  • 在容器内的端到端测试和容器外的集成测试之间共享逻辑。

MockMvc 可以与不依赖于 Servlet 容器的模板技术(例如 Thymeleaf、FreeMarker 等)协同工作,但它不能与 JSP 协同工作,因为 JSP 依赖于 Servlet 容器。

为什么要集成 HtmlUnit?

最显而易见的问题是“我为什么需要这个?”要回答这个问题,最好的方式是探索一个非常基础的示例应用。假设你有一个 Spring MVC Web 应用,它支持对 Message 对象的 CRUD 操作。这个应用还支持对所有消息的分页浏览。你会如何进行测试呢?

使用 Spring MVC Test,我们可以轻松地测试是否能够创建一个 Message,如下所示:

MockHttpServletRequestBuilder createMessage = post("/messages/")
		.param("summary", "Spring Rocks")
		.param("text", "In case you didn't know, Spring Rocks!");

mockMvc.perform(createMessage)
		.andExpect(status().is3xxRedirection())
		.andExpect(redirectedUrl("/messages/123"));

如果我们想要测试用于创建消息的表单视图呢?例如,假设我们的表单看起来像这样:

<form id="messageForm" action="/messages/" method="post">
	<div class="pull-right"><a href="/messages/">Messages</a></div>

	<label for="summary">Summary</label>
	<input type="text" class="required" id="summary" name="summary" value="" />

	<label for="text">Message</label>
	<textarea id="text" name="text"></textarea>

	<div class="form-actions">
		<input type="submit" value="Create" />
	</div>
</form>

我们如何确保我们的表单能够产生正确的请求来创建一个新的消息?一个简单的尝试可能如下:

mockMvc.perform(get("/messages/form"))
		.andExpect(xpath("//input[@name='summary']").exists())
		.andExpect(xpath("//textarea[@name='text']").exists());

这个测试有一些明显的缺点。如果我们更新控制器以使用参数 message 而不是 text,我们的表单测试仍然会通过,尽管 HTML 表单已经与控制器不一致了。为了解决这个问题,我们可以将这两个测试结合起来,如下所示:

String summaryParamName = "summary";
String textParamName = "text";
mockMvc.perform(get("/messages/form"))
		.andExpect(xpath("//input[@name='" + summaryParamName + "']").exists())
		.andExpect(xpath("//textarea[@name='" + textParamName + "']").exists());

MockHttpServletRequestBuilder createMessage = post("/messages/")
		.param(summaryParamName, "Spring Rocks")
		.param(textParamName, "In case you didn't know, Spring Rocks!");

mockMvc.perform(createMessage)
		.andExpect(status().is3xxRedirection())
		.andExpect(redirectedUrl("/messages/123"));

这样做会减少测试错误通过的风险,但仍然存在一些问题:

  • 如果我们的页面上有多个表单怎么办?诚然,我们可以更新 XPath表达式,但随着我们考虑更多因素,它们会变得更加复杂:字段的类型是否正确?字段是否启用?等等。
  • 另一个问题是,我们做了比预期多一倍的工作。我们必须首先验证视图,然后用刚刚验证过的相同参数提交视图。理想情况下,这些应该一次完成。
  • 最后,我们仍然无法解释一些情况。例如,如果表单有我们想要测试的 JavaScript 验证怎么办?

总体问题在于测试一个网页并不涉及单一的交互。相反,它是用户如何与网页交互以及该网页如何与其他资源交互的结合。例如,表单视图的结果被用作用户创建消息的输入。此外,我们的表单视图可能会使用影响页面行为的其他资源,例如 JavaScript 验证。

集成测试来帮忙了?

为了解决前面提到的问题,我们可以进行端到端集成测试,但这也有一些缺点。考虑测试让我们可以分页浏览消息的视图。我们可能需要以下测试:

  • 当消息为空时,我们的页面是否向用户显示一个通知,表明没有可用的结果?
  • 我们的页面是否正确显示单条消息?
  • 我们的页面是否正确支持分页?

为了设置这些测试,我们需要确保我们的数据库包含正确的消息。这导致了一系列额外的挑战:

  • 确保数据库中包含正确的消息可能会很繁琐。(考虑外键约束)
  • 测试可能会变得缓慢,因为每个测试都需要确保数据库处于正确的状态。
  • 由于我们的数据库需要处于特定状态,因此我们无法并行运行测试。
  • 对自动生成的 ID、时间戳等项目执行断言可能会很困难。

这些挑战并不意味着我们应该完全放弃端到端集成测试。相反,我们可以通过重构我们的详细测试,使用运行更快、更可靠且没有副作用的模拟服务,来减少端到端集成测试的数量。然后,我们可以实现少量真正的端到端集成测试,以验证简单的工作流程,确保所有内容都能正确协同工作。

引入 HtmlUnit 集成

那么,我们如何在测试页面交互的同时,依然保持测试套件的良好性能呢?答案是:“通过将 MockMvc 与 HtmlUnit 集成。”

HtmlUnit 集成选项

当你想要将 MockMvc 与 HtmlUnit 集成时,你有一些选项:

  • MockMvc 和 HtmlUnit:如果你想要使用原始的 HtmlUnit 库,可以选择这个选项。
  • MockMvc 和 WebDriver:这个选项可以让你在集成测试和端到端测试之间更轻松地开发和重用代码。
  • MockMvc 和 Geb:如果你想要使用 Groovy 进行测试,更轻松地开发,以及在集成测试和端到端测试之间重用代码,可以选择这个选项。

MockMvc 和 HtmlUnit

本节描述了如何集成 MockMvc 和 HtmlUnit。如果你想要使用原始的 HtmlUnit 库,可以选择这个选项。

MockMvc 和 HtmlUnit 的设置

首先,请确保你已经将 net.sourceforge.htmlunit:htmlunit 作为测试依赖项包含在内。为了与 Apache HttpComponents 4.5+ 一起使用 HtmlUnit,需要使用 HtmlUnit 2.18 或更高版本。

我们可以使用 MockMvcWebClientBuilder 轻松地创建一个与 MockMvc 集成的 HtmlUnit WebClient,如下所示:

WebClient webClient;

@BeforeEach
void setup(WebApplicationContext context) {
	webClient = MockMvcWebClientBuilder
			.webAppContextSetup(context)
			.build();
}

这确保了任何引用 localhost 作为服务器的 URL 都会被导向我们的 MockMvc 实例,而无需建立真正的 HTTP 连接。其他任何 URL 都会像平常一样通过网络连接进行请求。这使我们能够轻松地测试 CDN 的使用。

MockMvc 和 HtmlUnit 的使用

现在我们可以像平常一样使用 HtmlUnit,而无需将我们的应用程序部署到 Servlet 容器中。例如,我们可以使用以下方式请求创建消息的视图:

HtmlPage createMsgFormPage = webClient.getPage("http://localhost/messages/form");

默认的上下文路径是“”

一旦我们获得了对 HtmlPage 的引用,我们就可以填写表单并提交以创建消息,如下所示:

HtmlForm form = createMsgFormPage.getHtmlElementById("messageForm");
HtmlTextInput summaryInput = createMsgFormPage.getHtmlElementById("summary");
summaryInput.setValueAttribute("Spring Rocks");
HtmlTextArea textInput = createMsgFormPage.getHtmlElementById("text");
textInput.setText("In case you didn't know, Spring Rocks!");
HtmlSubmitInput submit = form.getOneHtmlElementByAttribute("input", "type", "submit");
HtmlPage newMessagePage = submit.click();

最后,我们可以验证新消息是否已成功创建。以下断言使用了 AssertJ 库:

assertThat(newMessagePage.getUrl().toString()).endsWith("/messages/123");
String id = newMessagePage.getHtmlElementById("id").getTextContent();
assertThat(id).isEqualTo("123");
String summary = newMessagePage.getHtmlElementById("summary").getTextContent();
assertThat(summary).isEqualTo("Spring Rocks");
String text = newMessagePage.getHtmlElementById("text").getTextContent();
assertThat(text).isEqualTo("In case you didn't know, Spring Rocks!");

前面的代码在很多方面改进了我们的 MockMvc 测试。首先,我们不再需要显式地验证表单,然后创建一个看起来像表单的请求。相反,我们请求表单,填写它,并提交它,从而显著减少了开销。

另一个重要的因素是,HtmlUnit 使用 Mozilla Rhino 引擎来评估 JavaScript。这意味着我们还可以测试页面中 JavaScript 的行为。

高级MockMvcWebClientBuilder

在前面的例子中,我们尽可能以最简单的方式使用了 MockMvcWebClientBuilder,通过基于 Spring TestContext Framework 为我们加载的 WebApplicationContext 构建一个 WebClient。这种方法在以下示例中得到了重复:

WebClient webClient;

@BeforeEach
void setup(WebApplicationContext context) {
	webClient = MockMvcWebClientBuilder
			.webAppContextSetup(context)
			.build();
}

我们还可以指定额外的配置选项,如下例所示:

WebClient webClient;

@BeforeEach
void setup() {
	webClient = MockMvcWebClientBuilder
		// demonstrates applying a MockMvcConfigurer (Spring Security)
		.webAppContextSetup(context, springSecurity())
		// for illustration only - defaults to ""
		.contextPath("")
		// By default MockMvc is used for localhost only;
		// the following will use MockMvc for example.com and example.org as well
		.useMockMvcForHosts("example.com","example.org")
		.build();
}

作为一种替代方案,我们可以分别配置 MockMvc 实例,并将其提供给MockMvcWebClientBuilder,从而执行完全相同的设置,如下所示:

MockMvc mockMvc = MockMvcBuilders
		.webAppContextSetup(context)
		.apply(springSecurity())
		.build();

webClient = MockMvcWebClientBuilder
		.mockMvcSetup(mockMvc)
		// for illustration only - defaults to ""
		.contextPath("")
		// By default MockMvc is used for localhost only;
		// the following will use MockMvc for example.com and example.org as well
		.useMockMvcForHosts("example.com","example.org")
		.build();

虽然这种方式更加冗长,但是通过使用 MockMvc 实例来构建 WebClient,我们可以轻松地使用 MockMvc 的全部功能。

MockMvc 和 WebDriver

在本节中,我们使用了 Selenium WebDriver 中的额外抽象,使事情变得更加简单。

为什么选择 WebDriver 和 MockMvc?

我们已经可以使用 HtmlUnit 和 MockMvc,那么我们为什么还要使用 WebDriver 呢?Selenium WebDriver 提供了一个非常优雅的 API,让我们能够轻松地组织代码。为了更好地展示它是如何工作的,我们在本节中探索一个例子。

尽管 WebDriver 是 Selenium 的一部分,但它并不需要 Selenium Server 来运行你的测试。

假设我们需要确保消息被正确地创建。这些测试包括查找 HTML 表单输入元素、填写它们,以及进行各种断言。

这种方法会导致许多单独的测试,因为我们也想测试错误条件。例如,我们想要确保在只填写表单的一部分时会出现错误。如果我们填写了整个表单,新创建的消息应该随后显示出来。

如果其中一个字段被命名为“summary”,我们的测试中可能会多次出现类似以下内容的东西:

HtmlTextInput summaryInput = currentPage.getHtmlElementById("summary");
summaryInput.setValueAttribute(summary);

那么,如果我们把id改为smmry会发生什么呢?这样做会迫使我们更新所有的测试,以包含这一更改。这违反了DRY(Don’t Repeat Yourself Principle)原则,所以理想情况下,我们应该将这段代码提取到它自己的方法中,如下所示:

public HtmlPage createMessage(HtmlPage currentPage, String summary, String text) {
	setSummary(currentPage, summary);
	// ...
}

public void setSummary(HtmlPage currentPage, String summary) {
	HtmlTextInput summaryInput = currentPage.getHtmlElementById("summary");
	summaryInput.setValueAttribute(summary);
}

这样做可以确保,如果我们更改用户界面,就不必更新所有的测试。

我们甚至可以更进一步,将这个逻辑放置在一个代表当前所在的 HtmlPage 的对象中,如下所示:

public class CreateMessagePage {

	final HtmlPage currentPage;

	final HtmlTextInput summaryInput;

	final HtmlSubmitInput submit;

	public CreateMessagePage(HtmlPage currentPage) {
		this.currentPage = currentPage;
		this.summaryInput = currentPage.getHtmlElementById("summary");
		this.submit = currentPage.getHtmlElementById("submit");
	}

	public <T> T createMessage(String summary, String text) throws Exception {
		setSummary(summary);

		HtmlPage result = submit.click();
		boolean error = CreateMessagePage.at(result);

		return (T) (error ? new CreateMessagePage(result) : new ViewMessagePage(result));
	}

	public void setSummary(String summary) throws Exception {
		summaryInput.setValueAttribute(summary);
	}

	public static boolean at(HtmlPage page) {
		return "Create Message".equals(page.getTitleText());
	}
}

以前,这种模式被称为页面对象模式。虽然我们可以使用 HtmlUnit 来实现这种模式,但 WebDriver 提供了一些工具,我们将在接下来的部分中探讨这些工具,以便更容易地实现这种模式。

MockMvc和WebDriver设置

要使用Selenium WebDriver与Spring MVC测试框架,请确保你的项目包含了对org.seleniumhq.selenium:selenium-htmlunit-driver的测试依赖。

通过使用MockMvcHtmlUnitDriverBuilder,我们可以轻松地创建一个与MockMvc集成的Selenium WebDriver,如下所示:

WebDriver driver;

@BeforeEach
void setup(WebApplicationContext context) {
	driver = MockMvcHtmlUnitDriverBuilder
			.webAppContextSetup(context)
			.build();
}

前面的示例确保任何引用localhost作为服务器的URL都被导向我们的MockMvc实例,而无需实际的HTTP连接。其他任何URL都会像平常一样通过网络连接来请求。这使我们能够轻松地测试CDN的使用。

MockMvc和WebDriver的使用

现在我们可以像平常一样使用WebDriver,而无需将应用程序部署到Servlet容器中。例如,我们可以使用以下方式请求创建消息的视图:

CreateMessagePage page = CreateMessagePage.to(driver);

然后,我们可以填写表单并提交它以创建一条消息,如下所示:

ViewMessagePage viewMessagePage =
		page.createMessage(ViewMessagePage.class, expectedSummary, expectedText);

这通过利用页面对象模式改进了我们的HtmlUnit测试设计。我们可以将页面对象模式与HtmlUnit一起使用,但使用WebDriver会更加容易。请考虑以下CreateMessagePage的实现:

public class CreateMessagePage extends AbstractPage {

	
	private WebElement summary;
	private WebElement text;

	@FindBy(css = "input[type=submit]")
	private WebElement submit;

	public CreateMessagePage(WebDriver driver) {
		super(driver);
	}

	public <T> T createMessage(Class<T> resultPage, String summary, String details) {
		this.summary.sendKeys(summary);
		this.text.sendKeys(details);
		this.submit.click();
		return PageFactory.initElements(driver, resultPage);
	}

	public static CreateMessagePage to(WebDriver driver) {
		driver.get("http://localhost:9990/mail/messages/form");
		return PageFactory.initElements(driver, CreateMessagePage.class);
	}
}

CreateMessagePage 继承自 AbstractPageAbstractPage包含了我们所有页面的通用功能。例如,如果我们的应用程序有一个导航栏、全局错误消息和其他特性,我们可以将这些逻辑放在一个共享的位置。

对于HTML页面中我们感兴趣的每个部分,我们都有一个成员变量。这些变量的类型是WebElement。WebDriver的PageFactory允许我们自动解析每个WebElement,从而从CreateMessagePage的HtmlUnit版本中删除大量代码。PageFactory#initElements(WebDriver, Class<T>)方法通过使用字段名称,并在HTML页面中根据元素的idname查找,来自动解析每个WebElement

我们可以使用@FindBy注解来覆盖默认的查找行为。我们的示例展示了如何使用@FindBy注解通过CSS选择器(input[type=submit])来查找提交按钮。

最后,我们可以验证是否成功创建了一条新消息。以下断言使用了AssertJ断言库:

assertThat(viewMessagePage.getMessage()).isEqualTo(expectedMessage);
assertThat(viewMessagePage.getSuccess()).isEqualTo("Successfully created a new message");

我们可以看到,我们的ViewMessagePage允许我们与自定义的域模型进行交互。例如,它提供了一个返回Message对象的方法:

public Message getMessage() throws ParseException {
	Message message = new Message();
	message.setId(getId());
	message.setCreated(getCreated());
	message.setSummary(getSummary());
	message.setText(getText());
	return message;
}

然后,我们可以在断言中使用丰富的域对象。

最后,我们必须在测试完成时关闭WebDriver实例,如下所示:

@AfterEach
void destroy() {
	if (driver != null) {
		driver.close();
	}
}

高级MockMvcHtmlUnitDriverBuilder

在之前的示例中,我们以尽可能简单的方式使用了MockMvcHtmlUnitDriverBuilder,基于Spring TestContext Framework为我们加载的WebApplicationContext来构建WebDriver。这种方法在这里重复,如下所示:

WebDriver driver;

@BeforeEach
void setup(WebApplicationContext context) {
	driver = MockMvcHtmlUnitDriverBuilder
			.webAppContextSetup(context)
			.build();
}

我们还可以指定额外的配置选项,如下所示:

WebDriver driver;

@BeforeEach
void setup() {
	driver = MockMvcHtmlUnitDriverBuilder
			// demonstrates applying a MockMvcConfigurer (Spring Security)
			.webAppContextSetup(context, springSecurity())
			// for illustration only - defaults to ""
			.contextPath("")
			// By default MockMvc is used for localhost only;
			// the following will use MockMvc for example.com and example.org as well
			.useMockMvcForHosts("example.com","example.org")
			.build();
}

作为一种替代方案,我们可以分别配置MockMvc实例,并将其提供给MockMvcHtmlUnitDriverBuilder,以实现相同的设置,如下所示:

MockMvc mockMvc = MockMvcBuilders
		.webAppContextSetup(context)
		.apply(springSecurity())
		.build();

driver = MockMvcHtmlUnitDriverBuilder
		.mockMvcSetup(mockMvc)
		// for illustration only - defaults to ""
		.contextPath("")
		// By default MockMvc is used for localhost only;
		// the following will use MockMvc for example.com and example.org as well
		.useMockMvcForHosts("example.com","example.org")
		.build();

虽然这种方式更为冗长,但通过使用MockMvc实例来构建WebDriver,我们可以充分利用MockMvc的全部功能。

MockMvc 和 Geb

为什么选择Geb和MockMvc?

Geb是基于WebDriver的,因此它提供了与WebDriver相同的许多优势。然而,Geb通过为我们处理一些样板代码,使事情变得更加简单。

MockMvc和Geb的设置

我们可以轻松地使用使用MockMvc的Selenium WebDriver来初始化一个Geb Browser ,如下所示:

def setup() {
	browser.driver = MockMvcHtmlUnitDriverBuilder
		.webAppContextSetup(context)
		.build()
}

这确保了任何将localhost作为服务器的URL都会被重定向到我们的MockMvc实例,而无需实际的HTTP连接。其他任何URL都会像平常一样通过网络连接进行请求。这使我们能够轻松地测试CDN的使用。

MockMvc和Geb的使用

现在我们可以像平常一样使用Geb,而无需将应用程序部署到Servlet容器中。例如,我们可以使用以下方式请求创建消息的视图:

to CreateMessagePage

然后,我们可以填写表单并提交以创建消息,如下所示:

when:
form.summary = expectedSummary
form.text = expectedMessage
submit.click(ViewMessagePage)

任何未识别的方法调用、属性访问或引用,如果找不到,都将被转发给当前的页面对象。这大大减少了我们直接使用WebDriver时所需要的样板代码。

与直接使用WebDriver一样,通过使用页面对象模式,我们改进了HtmlUnit测试的设计。可以将页面对象模式与HtmlUnit和WebDriver一起使用,但在Geb中则更加简单。考虑我们新的基于Groovy的CreateMessagePage实现:

class CreateMessagePage extends Page {
	static url = 'messages/form'
	static at = { assert title == 'Messages : Create'; true }
	static content =  {
		submit { $('input[type=submit]') }
		form { $('form') }
		errors(required:false) { $('label.error, .alert-error')?.text() }
	}
}

我们的CreateMessagePage类继承了Page类。Page类包含了所有页面共有的通用功能。我们在其中定义了一个URL,用于找到这个页面。这使我们能够导航到该页面,如下所示:

to CreateMessagePage

此外,我们还提供了一个at闭包,用于确定我们是否处于指定的页面。如果我们在正确的页面上,它应该返回true。这就是为什么我们可以断言我们在正确的页面上,如下所示:

then:
at CreateMessagePage
errors.contains('This field is required.')

我们在闭包中使用断言,以便在处于错误页面时能够确定哪里出了问题。

接下来,我们创建一个content闭包,它指定了页面内所有我们感兴趣的区域。我们可以使用类似jQuery的Navigator API来选择我们感兴趣的内容。

最后,我们可以验证新消息是否已成功创建,如下所示:

then:
at ViewMessagePage
success == 'Successfully created a new message'
id
date
summary == expectedSummary
message == expectedMessage
  • 18
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值