14.6 Spring MVC 测试框架(翻译)

14.6 Spring MVC 测试框架(每天翻译一点点)

Spring MVC测试框架对 Spring MVC 代码提供一流的测试支持 ,拥有一个 fluent API ,可以和JUnit, TestNG 或其它任何测试框架协同使用。 此测试框架基于 spring-test 模块的Servlet API mock objects 的,因此不需要依赖运行Servlet 容器来完成测试。它使用DispatcherServlet  来提供完整的Spring MVC运行时行为(full Spring MVC runtime behavior),并且支持加载项目实际使用的spring 配置,通过TestContext框架实现除此之外,使用单例模式(standalone mode)创建的 controller 可以手动实例化,一次只测试一个实例。 

Spring MVC测试框架还提供了客户端(Client-side)测试支持,使用RestTemplate客户端测试可以模拟服务器的响应,并且通常不需要启动服务器。

[Tip]

Spring Boot 框架可以编写完善的、端到端的集成测试,包括对运行服务器的测试。假如你需要测试时同时测试服务器等运行环境,  可以浏览一下 Spring Boot reference page. 更多关于容器外测试和端到端集成测试的区别,请看 the section called “Differences between Out-of-Container and End-to-End Integration Tests”.

14.6.1 服务器端测试

很容易写一个普通的测试来测试Spring MVC 的controller,使用JUnit或TestNG:简单的实例化一个 controller,并注入模拟依赖,然后直接通过 MockHttpServletRequestMockHttpServletResponse等调用 controller 的方法就可以了。然而,这样一个测试方法,还有很多东西没有测到:比如请求映射,数据绑定,类型转换,验证等等。 更有甚者, controller 的其它方法,例如 @InitBinder@ModelAttribute,和@ExceptionHandler 也可能作为请求进程生命周期的一部分被调用。

Spring MVC 测试的目标是通过实际的 DispatcherServlet发送请求和生成响应,为测试 controller 提供一个高效的途径。

Spring MVC Test builds on the familiar "mock" implementations of the Servlet API available in the spring-test module. 如此便可以在不用运行服务器的情况下发送请求和生成响应。绝大多数情况,这都和运行在 runtime 毫无区别,少数明显的区别参考 “基于容器测试和端到端集成测试的区别. 下面是一个基于JUnit 的 Spring MVC 测试的例子。

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration("test-servlet-context.xml")
public class ExampleTests {

    @Autowired
    private WebApplicationContext wac;

    private MockMvc mockMvc;

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

    @Test
    public void getAccount() throws Exception {
        this.mockMvc.perform(get("/accounts/1").accept(MediaType.parseMediaType("application/json;charset=UTF-8")))
            .andExpect(status().isOk())
            .andExpect(content().contentType("application/json"))
            .andExpect(jsonPath("$.name").value("Lee"));
    }

}

上面的测试依赖于TestContext框架的  WebApplicationContext 类的支持,这个类用于从与被测代码同一个包下的 XML 配置文件中加载 Spring 的配置,另外,也支持 Java-based 或 Groovy-based 的配置方式,参考 sample tests.

上面代码中的 MockMvc 类的实例是用来发送一个  GET 请求给 "/accounts/1" ,然后验证响应的状态码是否 200,响应类型是否是"application/json", 以及响应主体是否有一个 "name" 属性,它的值是 "Lee". 代码中 jsonPath 的语法参考 Jayway 提供的 JsonPath project. 除上文提及的之外,还许多请求响应结果的验证方式将会在下文讨论。

静态导入

上述例子中的 fluent API 需要导入一些静态依赖,例如MockMvcRequestBuilders.*MockMvcResultMatchers.*, 和MockMvcBuilders.*. 一个简单的找到这些类的方法是,搜索匹配表达式"MockMvc*" 的类型。假如使用的是Eclipse,一定要将这些项添加到“favorite static members”里,在Eclipse的preference下的Java → Editor → Content Assist → Favorites.这将使得你只需要输入这些静态方法的第一个字母,content assist 就会帮你弹出方法全名。其它IDE (如 IntelliJ) 或许不需要任何额外配置,只需要检查是否开启了对静态成员的自动代码完成功能 .

可选创建MockMvc实例的方式

现在主要有两种创建MockMvc类实例的方式。第一种是通过TestContext 框架加载Spring-MVC的配置文件,然后将 WebApplicationContext 的对象注入到测试代码,并用它作为初始化参数来构建一个  MockMvc 实例:

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration("my-servlet-context.xml")
public class MyWebTests {

    @Autowired
    private WebApplicationContext wac;

    private MockMvc mockMvc;

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

    // ...

}

第二种只是手动创建测试控制器(如:new AccountController())而不用加载Spring配置文件。与默认配置方式不一样,这种方式与Spring MVC基于Java类或MVC命名空间的配置方式很类似,都是能自动创建上下文并且能通过配置进行定制。

public class MyWebTests {

    private MockMvc mockMvc;

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

    // ...

}

你更青睐于使用哪种初始化方式呢?

"webAppContextSetup" 方式加载的是你真正的Spring MVC配置文件,因此是更纯粹更彻底的集成测试方法。而因为 TestContext  框架会缓存了已加载的Spring配置,所以这种方式能让测试跑得更快,即便它的测试套件(test suite)中包含了更多测试案例(test case)。更有用的是,你可以通过Spring配置文件将mock出的service注入到Controller 中,以此集中精力进行 web 层面的测试,而不用分散精力来对付Service层。如下,使用 Mockito 框架声明一个 mock 服务类:

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

然后,你可以将mock 服务类的对象注入到测试中,用来构建Controller 以及验证异常:

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration("test-servlet-context.xml")
public class AccountTests {

    @Autowired
    private WebApplicationContext wac;

    private MockMvc mockMvc;

    @Autowired
    private AccountService accountService;

    // ...

}

"standaloneSetup" 方式有点像单元测试,它在某一个时间点只能测试一个Controller,这个 Controller 也可以手动注入mock依赖,并且它不会加载Spring配置文件。这种方式更关注于测试风格,使得我们更容易辨别要测试的是哪个类,而不用去管使用的是哪个配置文件。"standaloneSetup" 方式可以非常方便的写出 ad-hoc 测试,用来验证代码特定特点或者调试某个问题。

和任何“集成测试和单元测试”之争一样,没有谁对谁错。然而,使用"standaloneSetup" 方式没有实现测试"webAppContextSetup"的需求,也就没有办法验证你的Spring MVC配置是否正确。要想避免,你就得所有测试都使用 "webAppContextSetup"  方式创建,以便能测试到你真正的Spring MVC配置。

发送请求

使用perform()来发送任何HTTP请求都很简单:

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

你也可以在perform()内部使用  MockMultipartHttpServletRequest   对象发送文件上传请求,这样虽没有真正解析一个的 multipart 请求,但看起来和真正解析了请求并没什么区别:

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

你也可以使用URI模板(URI template的方式设置琴酒参数:

mockMvc.perform(get("/hotels?foo={foo}", "bar"));

或者以表单参数(query of form parameters)方式增加一个请求参数:

mockMvc.perform(get("/hotels").param("foo", "bar"));

如果程序依赖于请求的参数,但没严格的(explicitly )检查查询字符串(这样的事经常发生),那么不管你使用上面哪种方式来携带参数都不会出现问题。但需要注意的是,如果使用URI模板方式,这些参数将会被解码(decoded),而使用 param(…​) 方式的话,则这些参数会被看做早已解码了,也就不在解码了。 

多数情况下,我们都不需要考虑 context path 和 Servlet path。但是如果你确实需要测试完整的URI,请确保设置好了相应的 contextPath和 servletPath  ,这样请求映射(request mapping) 才能起作用。

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

从上面的例子可见,如果发送一个模拟请求都设置一下 contextPath 和 servletPath,那是相当的麻烦。所以,你可以通过设置“请求的默认属性”来代替:

public class MyWebTests {

    private MockMvc mockMvc;

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

上面例子中通过 mockMvc 设置的的属性对每个请求都起作用。假如一个属性设置了默认值,但在某个请求里又被赋了新值,那么新值就会覆盖掉默认值。这也是为什么在默认请求里设置的HTTP方法和URI没什么用,因为每个请求都会重新指定它们。 

定义运行结果期望(Expectations)

运行结果期望可以在perform()方法后附加一个或多个 .andExpect(..) 来定义:

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

MockMvcResultMatchers.*  类下面提供了一系列期望,一些还嵌套了更具体的期望。

这些期望可以分为两大类。第一类断言验证响应的属性,例如,响应的状态,响应头,响应内容。对于断言来说,这些响应结果是最重要的验证对象。 

第二类断言放在响应的后面。这种类型的断言可以对Spring MVC的某些特殊对象进行检查。例如,检查控制器的哪个方法处理了请求,是否抛出了某个异常以及该异常是否已被处理,检查模型对象的内容,检查控制器返回了哪个视图,或者是否添加了某个flash属性等等。除此之外,这些断言也可以用来检查原生的Servlet的属性,例如requestsession的属性。

下面的测试使用第二类断言,假设数据绑定或校验失败:

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.打印出来。 SSpring 4.2包含一个 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 创建的超链接。t则这个链接可以使用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"));
注册过滤器

在配置好 MockMvc 实例后,可以在其后注册若干个 Filter 实例:

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

已注册的过滤器可以通过 spring-test   包提供的 MockFilterChain 。来调用,并且过滤器链的最后一个过滤器会被委托成 DispatcherServlet  

 基于容器测试和端到端集成测试的区别

前面曾提到过,对Spring MVC的测试是基于 spring-test 模块提供的实现了Servlet APImock对象的,并且测试不需要启动Servlet容器。因此,使用spring test来进行的基于容器测试与使用实际浏览器和服务器来进行的端到端测试是有很大差异的。

最简单的办法是从空白的 MockHttpServletRequest 入手。不去管你在请求里添加了什么,也不用管这是一个什么样的请求。让人惊讶的是,这里面没有默认的上下文路径,没有包含jsessionid 的cookie,没有跳转、错误以及异步分派请求,因此没有发生实际的jsp渲染。实际上,“转发”和“重定向”的链接被保存到了MockHttpServletResponse 对象里面,并且可以被断言。

这意味着如果使用jsp,你可以验证jsp被哪个请求转发的,但jsp里的html不会被渲染。换句话说,JSP不会被调用。需要注意,所有不依赖于转发的渲染技术例如 Thymeleaf, Freemarker, and Velocity 还是会渲染html作为响应体。,对JSONXML和其它格式的通过 @ResponseBody @ResponseBody注解标注的方法里返回的数据也一样。

另外,你也可以考虑使用Spring Boot提供的 @WebIntegrationTest.注解进行端到端的集成测试,参看 Spring Boot reference.

两种测试方式各有利弊。Spring MVC Test测试方式不能简单认为是一种规模介于经典单元测试和集成测试之间的测试。确切的说,Spring MVC Test测试方式不是一种经典的单元测试,但和经典的单元测试有点像。例如,你可以通过注入模拟的服务对象到控制器,从而将web层独立出来,这样,你就可以通过 DispatcherServlet ,使用项目真实的配置文件来对web层进行单独的测试。这在操作上,已经和你从DAO的上一层来独立测试DAO层没区别了。你也可以使用单例模式启动,来关注某个Controller在某个时间点的运行情况并且手动提供所需配置,使其运行。

使用Spring MVC Test的另外一个重要区别是,在概念上,该测试是服务端测试,因此你可以检查诸如哪个控制器被调用,是否某个异常已经被HandlerExceptionResolver处理了,模型的内容以及模型绑定的错误等。这使得编写一个断言变得很容易,因为此时,服务器端并不是像使用实际的HTTP客户端进行测试时那样,是一个黑箱子,是不可见的。

更多服务器端测试例子

框架本身的测试例子里包含许多例子,演示如何使用Spring Test。你可以浏览这些示例,获得更多灵感。同样,在spring-mvc-showcase里面也有许多完全基于spring test的测试例子。

14.6.2 HtmlUnit 集成

Spring支持MockMvcHtmlUnit.集成。它能简化使用html执行一个端到端测试的过程。集成之后,开发者可以:

  • 使用HtmlUnitWebDriver Geb 等工具来测试html网页将变得很简单,不需要再部署到服务器。
  • 可以测试页面内的javascript代码
  • 使用mock服务配置测试,提高测试速度
  • 复用端到端测试和基于容器测试的逻辑。
[Note]

MockMvc works with templating technologies that do not rely on a Servlet Container (e.g., Thymeleaf, Freemarker, Velocity, etc.), but it does not work with JSPs since they rely on the Servlet Container.

为什么使用 HtmlUnit 集成?

你可能会想,为什么我需要用它?答案是,从一个最简单的程序里可以找到答案。假设你有一个Spring MVC网站程序,这个程序有一个 Message 对象,对象有CURD等操作。这个程序可以分页显示所有的消息,你将如何测试它?

使用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"));

但是,假如Message对象是从表单里创建的,该如何测试?举个例子,假如我们的有表单如下:

<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>

如何保证表单会生成一个正确的请求来创建Message对象?有人会天真的使用下面的方法:

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

这个测试有明显的缺陷。假如我们更新了Controller,将参数名 text修改成message 。我们的测试还会照常发送请求,即使表单没有同步更新。对此,我们可以使用拼接语句的方法,如下:

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数据校验代码怎么办?

总的来说问题是,测试网页不是针对单一的交互。相反的,它掺杂了用户如何与网页交互以及网页如何和其它资源交互。举个例子,表单视图的显示结果是用来给用户输入以创建Message对象。更有甚者,我们的表单可能潜在使用了其它可能影响页面行为的资源,例如javascript数据校验。

使用端到端集成测试来解救?

为解决上节所述问题,我们可以使用端到端的集成测试,但其同样有缺陷。考虑消息列表页,其可以通过分页浏览多个消息。我们需要测试下列方面:

  • 当消息列表为空时,页面是否会显示一个提示信息,告诉用户当前没有结果可展示?
  • 页面是否正确显示一条消息?

  • 分页浏览是否正确?

运行这些测试之前,我们要确保我们的数据库已经包含正确的消息数据了。这有导致了一系列其它挑战。

  • 确认数据库里的消息数据正确无误是一个乏味而繁琐的过程,要考虑外键约束。

  • 测试会变得很慢,因为每次测试都要确认数据库无误。

  • 因为数据库需要置于某个特定状态才能进行测试,所以多个测试不能并行的进行。
  • 对自动生成的idtimesstamp很难进行断言

这些挑战并不意味着我们是要禁止所有的端到端测试,而是,通过重构复杂的测试,使用执行更快,更可靠,并且没有边际效应的模拟服务,我们能够减少端到端测试的次数。之后我们可以实施少数几次真正的端到端集成测试,只用来验证简单的工作流以确保多个页面协同工作正常。

使用HtmlUnit集成测试

那么如何达到既能测试页面交互又能保持良好的测试性能呢?答案是:“把MockMvcHtmlUnit集成起来”。

HtmlUnit集成选项

有多种方式可集成MockMvc HtmlUnit:

  • MockMvcHtmlUnit:如果你想使用原生的HtmlUnit,那就用这种方法。

  • MockMvcWebDriver:这种方式可以减少开发量,重用集成和端到端测试的代码。
  • MockMvc Geb:假如你想在Groovy下测试,可以使用这种方式,同样减少开发量,重用集成和端到端测试的代码。
MockMvc加 HtmlUnit 方式

本节介绍集成 MockMvc HtmlUnit。假如你想要使用原生的HtmlUnit库就可以使用这种方式。


集成MockMvcHtmlUnit的启动

首先,你要将依赖 net.sourceforge.htmlunit:htmlunit包含进来。为了在Apache HttpComponents4.5+上使用,你的HtmlUnit版本至少要2.18及以上。

使用 MockMvcWebClientBuilder ,可以很容易的创建一个集成了 MockMvc  HtmlUnit  WebClient 对象,如下所示:

@Autowired
WebApplicationContext context;

WebClient webClient;

@Before
public void setup() {
	webClient = MockMvcWebClientBuilder
		.webAppContextSetup(context)
		.build();
}
[Note]

This is a simple example of using MockMvcWebClientBuilder. For advanced usage see the section called “Advanced MockMvcWebClientBuilder”

上述代码可以确保任何指向 localhost URL会定向到 MockMvc 实例来处理,而不会发生真实的HTTP连接。任何其它请求还是使用真实网络连接,与普通浏览器一样。这样,即使使用了CDN,测试起来也很简单。

集成MockMvc HtmlUnit的用法

现在,我们可以如使用普通浏览器一样使用HtmlUnit,但不需要部署应用到服务器。例如,我们可以用如下代码,请求创建创新消息的视图:

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

The default context path is "". Alternatively, we can specify the context path as illustrated in the section called “Advanced MockMvcWebClientBuilder”.

一旦获得 HtmlPage, 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 engine来校验Javascript,这意味着我们也可以测试页面包含的Javascript代码。.

更多HtmlUnit的使用方法请参看HtmlUnit参考文档。

MockMvcWebClientBuilder高级用法

在前面的例子中,我们尽可能用最简单的方法使用 MockMvcWebClientBuilder 创建一个 WebClient ,基于Spring TestContext框架加载的 WebApplicationContext。下面重复这个方法:

@Autowired
WebApplicationContext context;

WebClient webClient;

@Before
public void setup() {
	webClient = MockMvcWebClientBuilder
		.webAppContextSetup(context)
		.build();
}

我们还可以指定更多配置选项。

WebClient webClient;

@Before
public 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 的全部能力。

[Tip]

For additional information on creating a MockMvc instance refer to the section called “Setup Options”.

MockMvc WebDriver方式

上一节我们知道了如何将MockMvc HtmlUint结合起来。这一节,我们将利用另外一些 WebDriver定义的概念来使测试变得更容易。

为何要使用WebDriver MockMvc集成?

我们已经使用了HtmlUnit  MockMvc,为什么还要去使用 WebDriver呢?这是因为 WebDriver提供了一套非常优雅的API,使我们能够很容易的组织代码。为了明白这点,我们来看个例子。

[Note]

Despite being a part of Selenium, WebDriver does not require a Selenium Server to run your tests.

假如我们现在需要确认消息已经被正确的创建,测试的过程包括找到HTML表单的input元素,填充,然后编写多个断言。

这种方法会导致数不胜数的,彼此独立的测试,因为我们也要测试出错情况。例如,要确认如果只是填充表单的一部分的话,会抛出错误,而填充整个表单,则新创建的表单稍后会显示出来。

假如表单里有一个名为 "summary" 的域,像那么下面的代码在我们测试中可能会经常出现:

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

假如将 id 设为"smmry"会发生什么?这会迫使我们修改所有测试方法,来适应这次更改。这违反了DRY原则,因此,我们应该将这些相同的代码提取到一个方法中。

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

这就保证我们在改变UI的时候无需更新所有的测试方法。

还可再进一步的,把这些代码逻辑放入当前网页的 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的启动

要在Spring MVC测试框架里使用WebDriver框架,需先加入依赖org.seleniumhq.selenium:selenium-htmlunit-driver.到项目中。

我们可以很容易的使用 MockMvcHtmlUnitDriverBuilder 集成 WebDriver  MockMvc ,代码如下:

@Autowired
WebApplicationContext context;

WebDriver driver;

@Before
public void setup() {
	driver = MockMvcHtmlUnitDriverBuilder
		.webAppContextSetup(context)
		.build();
}
[Note]

This is a simple example of using MockMvcHtmlUnitDriverBuilder. For more advanced usage, refer to the section called “Advanced MockMvcHtmlUnitDriverBuilder”

上述代码可以确保任何指向 localhost URL会定向到 MockMvc 实例来处理,而不会发生真实的HTTP连接。任何其它请求还是使用真实网络连接,与普通浏览器一样。这样,即使使用了CDN,测试起来也很简单。

集成MockMvc WebDriver的用法

现在,我们可以如使用普通浏览器一样使用WebDriver,但不需要部署应用到服务器。例如,我们可以用如下代码,请求创建创新消息的视图:

CreateMessagePage page = CreateMessagePage.to(driver);

我们可以填充表单然后提交以创建一条新的消息。

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

这个测试使用了页面对象模式,改进了之前使用HtmlUnit的测试方法。正如在“为何要使用WebDriver MockMvc集成”章节里提到的,我们可以在集成HtmlUnit框架后使用页面对象模式,但在WebDriver里使用将会更容易。下面请看新的 CreateMessagePage 类的实现:

public class CreateMessagePage
		extends AbstractPage { 1

	2
	private WebElement summary;
	private WebElement text;

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

1

你首先要注意的是,CreateMessagePage 类继承自AbstractPage。我们不会详述AbstractPage类的细节,但总的来说,它包含了所有页面的公共方法。就好像,假如应用程序里有一个导航条,或者全局错误信息之类,可以把这些逻辑可以放到一个共享的地方一样。

2

另外一个值得注意的地方是,每个我们感兴趣的html部位,都有一个成员变量与之对应。WebElement.WebDriver类型的页面工厂类通过自动解析每个网页元素,允许我们移除大量HtmlUnit版本的CreateMessagePage 方法里的代码。PageFactory#initElements(WebDriver,Class<T>)方法通过成员变量名称,查找网页内所有元素的id和name,自动解析每个网页元素.

3

我们可以使用 @FindBy 注解覆盖默认的查找方式。我们的例子演示如何使用 @FindBy 注解,通过css选择器input[type=submit],来查找提交按钮。

最后,我们可以检验新消息已经创建成功。下面的断言使用了FEST assertion库。

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  的实例。
@After
public void destroy() {
	if (driver != null) {
		driver.close();
	}
}

更过使用WebDriver框架的信息,请参看“WebDriver参考文档”。

 MockMvcHtmlUnitDriverBuilder高级用法

在前面的例子中,我们尽可能用最简单的方法使用 MockMvcHtmlUnitDriverBuilder :创建一个 WebDriver 对象,基于Spring TestContext框架加载的 WebApplicationContext。下面重复这个方法:

@Autowired
WebApplicationContext context;

WebDriver driver;

@Before
public void setup() {
	driver = MockMvcHtmlUnitDriverBuilder
		.webAppContextSetup(context)
		.build();
}

我们还可以指定更多配置选项。

WebDriver driver;

@Before
public 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 的全部能力。

[Tip]

For additional information on creating a MockMvc instance refer to the section called “Setup Options”.

MockMvc Geb方式

上一个章节,我们已经知道如何使用 MockMvc  WebDriver,本章节,我们将使用Geb来使我们的测试更Groovy化。

为何要集成 Geb MockMvc?

Geb框架是有WebDriver作为后盾的,因此它的优点和WebDriver是一样的。Geb框架处理掉了样板代码,使得测试更简洁,更简单。

集成MockMvcGeb方式启动

通过如下使用了 MockMvc  WebDriver 对象,我们可以很容易的实例化一个Geb 框架的  Browser 对象。

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

This is a simple example of using MockMvcHtmlUnitDriverBuilder. For more advanced usage, refer to the section called “Advanced MockMvcHtmlUnitDriverBuilder”

上述代码可以确保任何指向 localhost URL会定向到 MockMvc 实例来处理,而不会发生真实的HTTP连接。任何其它请求还是使用真实网络连接,与普通浏览器一样。这样,即使使用了CDN,测试起来也很简单。

集成MockMvcGeb的用法

现在,我们可以如使用普通浏览器一样使用Geb,但不需要部署应用到服务器。例如,我们可以用如下代码,请求创建创新消息的视图:

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.')
[Note]

We use an assertion in the closure, so that we can determine where things went wrong if we were at the wrong page.

然后我们创建一个 content 代码块,用来指定页面内所有感兴趣的区域。我们可以使用jQuery-ish Navigator API 来选择我们感兴趣的内容。

最后,我们可以检验新消息是否创建成功。
then:
at ViewMessagePage
success == 'Successfully created a new message'
id
date
summary == expectedSummary
message == expectedMessage

更多关于如何最大化的使用Geb,请参考Geb用户手册一书。

14.6.3 Client-Side REST Tests

Client-side tests are for code using the RestTemplate. The goal is to define expected requests and provide "stub" responses:

RestTemplate restTemplate = new RestTemplate();

MockRestServiceServer mockServer = MockRestServiceServer.createServer(restTemplate);
mockServer.expect(requestTo("/greeting")).andRespond(withSuccess("Hello world", MediaType.TEXT_PLAIN));

// use RestTemplate ...

mockServer.verify();

In the above example, MockRestServiceServer — the central class for client-side REST tests — configures the RestTemplate with a customClientHttpRequestFactory that asserts actual requests against expectations and returns "stub" responses. In this case we expect a single request to "/greeting" and want to return a 200 response with "text/plain" content. We could define as many additional requests and stub responses as necessary.

Once expected requests and stub responses have been defined, the RestTemplate can be used in client-side code as usual. At the end of the testsmockServer.verify() can be used to verify that all expected requests were performed.

Static Imports

Just like with server-side tests, the fluent API for client-side tests requires a few static imports. Those are easy to find by searching "MockRest*". Eclipse users should add"MockRestRequestMatchers.*" and "MockRestResponseCreators.*" as "favorite static members" in the Eclipse preferences under Java → Editor → Content Assist → Favorites. That allows using content assist after typing the first character of the static method name. Other IDEs (e.g. IntelliJ) may not require any additional configuration. Just check the support for code completion on static members.

Further Examples of Client-side REST Tests

Spring MVC Test’s own tests include example tests of client-side REST tests.

14.7 PetClinic Example

The PetClinic application, available on GitHub, illustrates several features of the Spring TestContext Framework in a JUnit environment. Most test functionality is included in theAbstractClinicTests, for which a partial listing is shown below:

import static org.junit.Assert.assertEquals;
// import ...

@ContextConfiguration
public abstract class AbstractClinicTests extends AbstractTransactionalJUnit4SpringContextTests {

    @Autowired
    protected Clinic clinic;

    @Test
    public void getVets() {
        Collection<Vet> vets = this.clinic.getVets();
        assertEquals("JDBC query must show the same number of vets",
            super.countRowsInTable("VETS"), vets.size());
        Vet v1 = EntityUtils.getById(vets, Vet.class, 2);
        assertEquals("Leary", v1.getLastName());
        assertEquals(1, v1.getNrOfSpecialties());
        assertEquals("radiology", (v1.getSpecialties().get(0)).getName());
        // ...
    }

    // ...
}

Notes:

  • This test case extends the AbstractTransactionalJUnit4SpringContextTests class, from which it inherits configuration for Dependency Injection (through theDependencyInjectionTestExecutionListener) and transactional behavior (through the TransactionalTestExecutionListener).
  • The clinic instance variable — the application object being tested — is set by Dependency Injection through @Autowired semantics.
  • The getVets() method illustrates how you can use the inherited countRowsInTable() method to easily verify the number of rows in a given table, thus verifying correct behavior of the application code being tested. This allows for stronger tests and lessens dependency on the exact test data. For example, you can add additional rows in the database without breaking tests.
  • Like many integration tests that use a database, most of the tests in AbstractClinicTests depend on a minimum amount of data already in the database before the test cases run. Alternatively, you might choose to populate the database within the test fixture set up of your test cases — again, within the same transaction as the tests.

The PetClinic application supports three data access technologies: JDBC, Hibernate, and JPA. By declaring @ContextConfiguration without any specific resource locations, the AbstractClinicTests class will have its application context loaded from the default location, AbstractClinicTests-context.xml, which declares a common DataSource. Subclasses specify additional context locations that must declare a PlatformTransactionManager and a concrete implementation of Clinic.

For example, the Hibernate implementation of the PetClinic tests contains the following implementation. For this example, HibernateClinicTests does not contain a single line of code: we only need to declare @ContextConfiguration, and the tests are inherited from AbstractClinicTests. Because @ContextConfiguration is declared without any specific resource locations, the Spring TestContext Framework loads an application context from all the beans defined inAbstractClinicTests-context.xml (i.e., the inherited locations) and HibernateClinicTests-context.xml, with HibernateClinicTests-context.xmlpossibly overriding beans defined in AbstractClinicTests-context.xml.

@ContextConfiguration
public class HibernateClinicTests extends AbstractClinicTests { }

In a large-scale application, the Spring configuration is often split across multiple files. Consequently, configuration locations are typically specified in a common base class for all application-specific integration tests. Such a base class may also add useful instance variables — populated by Dependency Injection, naturally — such as aSessionFactory in the case of an application using Hibernate.

As far as possible, you should have exactly the same Spring configuration files in your integration tests as in the deployed environment. One likely point of difference concerns database connection pooling and transaction infrastructure. If you are deploying to a full-blown application server, you will probably use its connection pool (available through JNDI) and JTA implementation. Thus in production you will use a JndiObjectFactoryBean or <jee:jndi-lookup> for the DataSource andJtaTransactionManager. JNDI and JTA will not be available in out-of-container integration tests, so you should use a combination like the Commons DBCPBasicDataSource and DataSourceTransactionManager or HibernateTransactionManager for them. You can factor out this variant behavior into a single XML file, having the choice between application server and a 'local' configuration separated from all other configuration, which will not vary between the test and production environments. In addition, it is advisable to use properties files for connection settings. See the PetClinic application for an example.

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值