Spring 测试(第二部分)

3.6 Spring MVC测试框架

Spring MVC测试框架为测试Spring MVC代码提供了一流的支持,使用流畅的API可以与JUnit、TestNG或任何其他测试框架一起使用。它是在spring-test模块的Servlet API模拟对象上构建的,因此不使用正在运行的Servlet容器。它使用DispatcherServlet来提供完整的Spring MVC运行时行为,并支持使用TestContext框架加载实际的Spring配置,此外还有一个独立模式,在这个模式中,您可以手动实例化控制器并一次测试一个。

Spring MVC Test还为使用RestTemplate的测试代码提供了客户端支持。客户端测试模拟服务器响应,也不使用正在运行的服务器。

注意:Spring Boot提供了一个选项来编写完整的端到端集成测试,其中包括一个正在运行的服务器。如果这是您的目标,请参阅Spring引导参考指南。有关容器外测试和端到端集成测试之间的差异的更多信息,请参见Spring MVC测试和端到端测试。

3.6.1 服务器端测试

您可以使用JUnit或TestNG为Spring MVC控制器编写一个简单的单元测试。为此,实例化控制器,用模拟的或存根的依赖项注入它,并调用它的方法(根据需要传递MockHttpServletRequest、MockHttpServletResponse等)。但是,在编写这样的单元测试时,还有许多测试没有完成:例如,请求映射、数据绑定、类型转换、验证等等。此外,其他控制器方法,如@InitBinder、@ModelAttribute和@ExceptionHandler,也可以作为请求处理生命周期的一部分调用。

Spring MVC测试的目标是通过实际的DispatcherServlet执行请求并生成响应,从而提供一种有效的方法来测试控制器。

Spring MVC测试建立在Spring - Test模块中提供的Servlet API的熟悉的“mock”实现之上。这允许执行请求和生成响应,而不需要在Servlet容器中运行。在大多数情况下,除了一些明显的例外,一切都应该像运行时那样工作,如Spring MVC测试vs端到端测试中所解释的那样。下面这个基于JUnit类木星的例子使用了Spring MVC测试:

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

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

    MockMvc mockMvc;

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

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

注意:Kotlin中提供了一个专用的MockMvc DSL

前面的测试依赖于TestContext框架的WebApplicationContext支持,以从与测试类位于同一包中的XML配置文件加载Spring配置,但是也支持基于java和基于groovy的配置。参见这些示例测试。

MockMvc实例用于执行对/accounts/1的GET请求,并验证结果响应的状态为200,内容类型为application/json,响应主体具有一个名为name的json属性,其值为Lee。通过Jayway jsonPath项目支持jsonPath语法。本文档稍后将讨论用于验证所执行请求的结果的许多其他选项

静态导入
前一节示例中的fluent API需要一些静态导入,比如MockMvcRequestBuilders。*,MockMvcResultMatchers。*,MockMvcBuilders。*。查找这些类的一个简单方法是搜索与MockMvc*匹配的类型。如果您使用Eclipse或基于Eclipse的Spring工具套件,请确保将它们作为“最喜欢的静态成员”添加到Java→Editor→Content Assist→Favorites下的Eclipse首选项中。这样做可以让您在输入静态方法名的第一个字符后使用内容辅助。其他ide(如IntelliJ)可能不需要任何额外的配置。检查对静态成员的代码完成的支持。

设置选择
创建MockMvc实例有两个主要选项。第一种方法是通过TestContext框架加载Spring MVC配置,该框架加载Spring配置并将WebApplicationContext注入到测试中,用于构建MockMvc实例。下面的例子演示了如何做到这一点:

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

    MockMvc mockMvc;

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

    // ...

}

第二个选项是手动创建一个控制器实例,而不加载Spring配置。取而代之的是自动创建基本的默认配置,与MVC JavaConfig或MVC名称空间的配置大致相当。您可以对其进行一定程度的定制。下面的例子演示了如何做到这一点:

class MyWebTests {

    MockMvc mockMvc;

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

    // ...

}

应该使用哪个设置选项?
webAppContextSetup加载实际的Spring MVC配置,从而完成更完整的集成测试。因为TestContext框架缓存了加载的Spring配置,所以即使您在测试套件中引入了更多的测试,它也有助于保持测试快速运行。此外,您可以通过Spring配置将模拟服务注入控制器,以保持对web层的测试。下面的示例使用Mockito声明了一个模拟服务:


    

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

@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 builder功能的列表,请参阅ConfigurableMockMvcBuilder的javadoc,或者使用IDE查看可用的选项。
执行请求
你可以执行请求使用任何HTTP方法,如下面的例子所示:

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

您还可以执行内部使用MockMultipartHttpServletRequest的文件上传请求,这样就不需要对多部分请求进行实际的解析。相反,你必须把它设置成类似下面的例子:

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进行测试,请确保相应地设置contextPath和servletPath,以便让请求映射工作,如下面的示例所示:

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

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

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(..)调用来定义期望,如下面的示例所示:

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

MockMvcResultMatchers.*提供了许多期望,其中一些期望进一步嵌套了更详细的期望。
预期大致可分为两类。第一类断言验证响应的属性(例如,响应状态、标题和内容)。这些是最重要的结果。

第二类断言超出了响应。这些断言允许您检查Spring MVC的特定方面,例如哪个控制器方法处理了请求、是否引发并处理了异常、模型的内容是什么、选择了什么视图、添加了什么flash属性等等。它们还允许您检查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)将结果数据打印到系统。在调用print(myWriter)时,将结果数据打印到自定义写入器。如果希望将结果数据记录下来而不是打印出来,可以调用log()方法,该方法将结果数据作为单个调试消息记录在org.springframework.test.web.servlet下。结果日志类别。

在某些情况下,您可能希望直接访问结果并验证一些无法通过其他方式验证的内容。这可以通过在所有其他期望之后附加.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 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"));

异步请求
在Spring MVC中支持的Servlet 3.0异步请求的工作方式是退出Servlet容器线程并允许应用程序异步计算响应,然后进行异步分派以完成Servlet容器线程上的处理。

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

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

    this.mockMvc.perform(asyncDispatch(mvcResult)) //4
            .andExpect(status().isOk()) //5
            .andExpect(content().string("body"));
  1. 检查响应状态仍然保持不变
  2. 异步处理必须已经启动
  3. 等待并断言异步结果
  4. 手动执行异步分派(因为没有运行的容器)
  5. 验证最终响应

流媒体的反应
对于流响应的无容器测试,Spring MVC测试中没有内置选项。使用Spring MVC流选项的应用程序可以使用WebTestClient对运行中的服务器执行端到端的集成测试。这在Spring Boot中也得到了支持,您可以使用WebTestClient测试正在运行的服务器。一个额外的优点是能够使用project Reactor的StepVerifier,它允许声明对数据流的期望。

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

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

注册的过滤器通过来自spring-test的MockFilterChain调用,最后一个过滤器委托给DispatcherServlet。
Spring MVC测试vs端到端测试

Spring MVC测试构建于Spring - Test模块中的Servlet API模拟实现之上,不依赖于正在运行的容器。因此,与运行实际客户机和活动服务器的完整端到端集成测试相比,有一些不同之处。

最简单的方法是从一个空的MockHttpServletRequest开始。您向它添加的内容就是请求的内容。可能会让您吃惊的是,默认情况下没有上下文路径;没有jsessionid饼干;没有转发、错误或异步调度;因此,没有实际的JSP呈现。相反,“转发”和“重定向”的url保存在MockHttpServletResponse中,并且可以使用期望来断言。

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

或者,您可以考虑使用@SpringBootTest从SpringBoot获得完整的端到端集成测试支持。参见Spring引导参考指南。
每种方法都有利弊。Spring MVC测试中提供的选项是从传统的单元测试到全面集成测试的不同阶段。可以肯定的是,Spring MVC测试中的所有选项都不属于经典单元测试的范畴,但它们更接近于经典单元测试。例如,您可以通过将模拟服务注入到控制器中来隔离web层,在这种情况下,您只能通过DispatcherServlet测试web层,但是要使用实际的Spring配置,因为您可以在与上面的层隔离的情况下测试数据访问层。此外,您还可以使用独立设置,一次只关注一个控制器,并手动提供使其工作所需的配置。

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

进一步的例子
该框架自己的测试包括许多示例测试,目的是演示如何使用Spring MVC测试。您可以浏览这些示例以获得更多的想法。另外,Spring - MVC -showcase项目有基于Spring MVC测试的完整测试覆盖。

3.6.2. HtmlUnit Integration
Spring提供了MockMvc和HtmlUnit之间的集成。这简化了在使用基于html的视图时执行端到端的测试。这种集成让您:

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

注意:MockMvc使用不依赖于Servlet容器的模板技术(例如,Thymeleaf、FreeMarker等),但是它不适用于jsp,因为它们依赖于Servlet容器。

为什么HtmlUnit集成?
最明显的问题就是“为什么我需要这个?”最好通过研究一个非常基础的示例应用程序来找到答案。假设您有一个Spring MVC web应用程序,它支持消息对象上的CRUD操作。应用程序还支持对所有消息进行分页。你会如何测试它?

使用Spring MVC测试,我们可以很容易地测试我们是否能够创建一个消息,如下:

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

如果我们想要测试让我们创建消息的表单视图,该怎么办?例如,假设我们的表单如下:

我们如何确保表单生成正确的请求来创建新消息?一个幼稚的尝试可能类似如下:

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

这个测试有一些明显的缺点。如果我们更新控制器以使用参数消息而不是文本,则表单测试将继续通过,即使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验证,该怎么办?

总的问题是,测试web页面不涉及单个交互。相反,它是用户如何与web页面交互以及该web页面如何与其他资源交互的组合。例如,表单视图的结果用作用户创建消息的输入。此外,我们的表单视图可能会使用影响页面行为的其他资源,比如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();
}

注意:这是一个使用MockMvcWebClientBuilder的简单示例。有关高级用法,请参阅高级MockMvcWebClientBuilder。

这确保了任何引用localhost作为服务器的URL都被定向到我们的MockMvc实例,而不需要真正的HTTP连接。任何其他URL都是通过使用网络连接来请求的。这使我们能够轻松地测试CDNs的使用。

MockMvc和HtmlUnit的使用
现在我们可以像往常一样使用HtmlUnit,但不需要将应用程序部署到Servlet容器中。例如,我们可以请求视图创建一个消息如下:

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

注意:默认的上下文路径是“”。或者,我们可以指定上下文路径,就像高级MockMvcWebClientBuilder中描述的那样。

一旦我们有一个对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的行为。
有关使用HtmlUnit的更多信息,请参阅HtmlUnit文档。

先进MockMvcWebClientBuilder
在目前的示例中,我们已经以最简单的方式使用了MockMvcWebClientBuilder,方法是基于Spring TestContext框架为我们加载的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实例的更多信息,请参见设置选项。

MockMvc和WebDriver
在前面几节中,我们已经了解了如何将MockMvc与原始的HtmlUnit api结合使用。在本节中,我们将在Selenium WebDriver中使用额外的抽象来简化工作。

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

注意:尽管WebDriver是Selenium的一部分,但它并不需要Selenium服务器来运行您的测试。

假设我们需要确保消息被正确创建。这些测试包括查找HTML表单输入元素、填写它们以及进行各种断言。
这种方法会导致许多单独的测试,因为我们也想测试错误条件。例如,我们希望确保在只填写表单的一部分时出现错误。如果我们填写了整个表单,则应该在之后显示新创建的消息。

如果其中一个字段被命名为“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 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测试框架中使用Selenium WebDriver,请确保您的项目包含对org.seleniumhq.selenium: Selenium -htmlunit-driver的测试依赖。
我们可以使用MockMvcHtmlUnitDriverBuilder轻松创建一个与MockMvc集成的Selenium WebDriver,如下面的示例所示:

WebDriver driver;

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

注意:这是一个使用MockMvcHtmlUnitDriverBuilder的简单示例。有关更高级的用法,请参见高级MockMvcHtmlUnitDriverBuilder。

前面的示例确保将引用localhost作为服务器的任何URL定向到我们的MockMvc实例,而不需要真正的HTTP连接。任何其他URL都是通过使用网络连接来请求的。这使我们能够轻松地测试CDNs的使用。

MockMvc和WebDriver的使用
现在我们可以像往常一样使用WebDriver,但不需要将应用程序部署到Servlet容器。例如,我们可以请求视图创建一个消息如下:

CreateMessagePage page = CreateMessagePage.to(driver);

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

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

这通过利用页面对象模式改进了HtmlUnit测试的设计。正如我们在为什么WebDriver和MockMvc中提到的那样?,我们可以使用页面对象模式与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 createMessage(Class 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的PageFactory允许我们通过自动解析每个WebElement来从CreateMessagePage的HtmlUnit版本中删除大量代码。PageFactory#initElements(WebDriver,Class)方法通过使用字段名并在HTML页面中根据元素的id或名称查找,自动解析每个WebElement。
  3. 我们可以使用@FindBy注释来覆盖默认的查找行为。我们的示例展示了如何使用@FindBy注释使用css选择器(input[type=submit])查找提交按钮。

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

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

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

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

有关使用WebDriver的更多信息,请参阅Selenium WebDriver文档。
先进MockMvcHtmlUnitDriverBuilder
在目前的示例中,我们已经以最简单的方式使用了MockMvcHtmlUnitDriverBuilder,方法是基于Spring TestContext框架为我们加载的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实例构建web驱动程序,我们就可以轻松掌握MockMvc的全部功能。

有关创建MockMvc实例的更多信息,请参见设置选项。

MockMvc and Geb
在前一节中,我们了解了如何在WebDriver中使用MockMvc。在本节中,我们使用Geb使测试更加精彩。

为什么是Geb和MockMvc?
Geb是由WebDriver支持的,所以它提供了许多与WebDriver相同的好处。然而,Geb通过为我们处理一些样板代码使事情变得更加简单。

MockMvc和Geb设置
我们可以使用使用MockMvc的Selenium WebDriver轻松初始化Geb浏览器,如下所示:

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

这是一个使用MockMvcHtmlUnitDriverBuilder的简单示例。有关更高级的用法,请参见高级MockMvcHtmlUnitDriverBuilder。

这确保了任何引用localhost作为服务器的URL都被定向到我们的MockMvc实例,而不需要真正的HTTP连接。任何其他URL是通过正常使用网络连接请求的。这使我们能够轻松地测试CDNs的使用。

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。我们不讨论页面的细节,但是,总的来说,它包含所有页面的通用功能。我们定义了一个可以找到这个页面的URL。这让我们导航到该页面,如下所示:

to CreateMessagePage

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

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

我们在闭包中使用断言,这样我们就可以确定如果我们在错误的页面上,哪里出了问题。

接下来,我们创建一个内容闭包,它指定页面中所有感兴趣的区域。我们可以使用类似于jquery的Navigator API来选择感兴趣的内容。
最后,我们可以验证新消息创建成功,如下:

then:
at ViewMessagePage
success == 'Successfully created a new message'
id
date
summary == expectedSummary
message == expectedMessage

有关如何充分利用Geb的更多细节,请参阅Geb用户手册。
3.6.3 其他客户端测试
您可以使用客户端测试来测试内部使用RestTemplate的代码。其思想是声明预期的请求并提供“存根”响应,以便您可以集中精力在隔离状态下测试代码(即不运行服务器)。下面的例子演示了如何做到这一点:

RestTemplate restTemplate = new RestTemplate();

MockRestServiceServer mockServer = MockRestServiceServer.bindTo(restTemplate).build();
mockServer.expect(requestTo("/greeting")).andRespond(withSuccess());

// Test code that uses the above RestTemplate ...

mockServer.verify();

在前面的示例中,MockRestServiceServer(客户端REST测试的中心类)使用定制的ClientHttpRequestFactory配置RestTemplate,该工厂根据预期断言实际请求并返回“存根”响应。在这种情况下,我们期望一个请求/问候,并希望返回一个包含文本/纯内容的200个响应。我们可以根据需要定义额外的预期请求和存根响应。当我们定义预期的请求和存根响应时,RestTemplate可以像往常一样在客户端代码中使用。在测试结束时,可以使用mockServer.verify()来验证是否满足了所有的期望。

默认情况下,请求是按照声明期望的顺序出现的。您可以在构建服务器时设置ignoreExpectOrder选项,在这种情况下,所有的期望都会被检查(以便)为给定的请求找到一个匹配项。这意味着请求可以以任何顺序出现。下面的例子使用ignoreExpectOrder:

server = MockRestServiceServer.bindTo(restTemplate).ignoreExpectOrder(true).build();

即使是默认的无序请求,每个请求也只允许执行一次。expect方法提供了一个重载的变体,它接受一个ExpectedCount参数,该参数指定一个计数范围(例如,一次、多次、max、min、between,等等)。下面的例子使用times:

RestTemplate restTemplate = new RestTemplate();

MockRestServiceServer mockServer = MockRestServiceServer.bindTo(restTemplate).build();
mockServer.expect(times(2), requestTo("/something")).andRespond(withSuccess());
mockServer.expect(times(3), requestTo("/somewhere")).andRespond(withSuccess());

// ...

mockServer.verify();

注意,当ignoreExpectOrder未被设置时(默认),因此,请求按照声明的顺序被期望,那么该顺序只适用于任何期望请求的第一个。例如,如果“/something”被期望出现两次,然后“/somewhere”出现三次,那么在“/somewhere”之前应该有一个对“/something”的请求,但是,除了随后的“/something”和“/somewhere”,请求可以在任何时候出现。

作为上述所有方法的替代,客户端测试支持还提供了ClientHttpRequestFactory实现,您可以将其配置为RestTemplate,将其绑定到MockMvc实例。它允许使用实际的服务器端逻辑处理请求,但不需要运行服务器。下面的例子演示了如何做到这一点:

MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
this.restTemplate = new RestTemplate(new MockMvcClientHttpRequestFactory(mockMvc));

// Test code that uses the above RestTemplate ...

静态导入
与服务器端测试一样,客户端测试的fluent API需要一些静态导入。这些很容易通过搜索MockRest*找到。Eclipse用户应该添加MockRestRequestMatchers。*和MockRestResponseCreators。*作为“最喜欢的静态成员”在Eclipse首选项下的Java→编辑→内容辅助→收藏。这允许在输入静态方法名的第一个字符后使用内容辅助。其他ide(如IntelliJ)可能不需要任何额外的配置。检查是否支持在静态成员上完成代码。

客户端REST测试的进一步示例
Spring MVC测试自己的测试包括客户端REST测试的示例测试。
3.7 WebTestClient
WebTestClient是一个围绕WebClient的瘦shell,使用它来执行请求并公开一个专用的、流畅的API来验证响应。WebTestClient通过使用模拟请求和响应绑定到WebFlux应用程序,或者它可以通过HTTP连接测试任何web服务器。

Kotlin用户:请参阅本节关于WebTestClient的使用。

3.7.1 设置
要创建WebTestClient,您必须从几个服务器设置选项中选择一个。实际上,您可以将WebFlux应用程序配置为绑定或使用URL连接到正在运行的服务器。
绑定到控制器
下面的例子演示了如何创建一个服务器设置来测试一个@Controller:

client = WebTestClient.bindToController(new TestController()).build();

前面的示例加载WebFlux Java配置并注册给定的控制器。通过使用模拟请求和响应对象,在不使用HTTP服务器的情况下测试得到的WebFlux应用程序。在构建器上有更多的方法来定制默认的WebFlux Java配置。
绑定到路由器功能
下面的例子展示了如何从RouterFunction设置服务器:

RouterFunction route = ... client = WebTestClient.bindToRouterFunction(route).build();

在内部,配置被传递给RouterFunctions.toWebHandler。通过使用模拟请求和响应对象,在不使用HTTP服务器的情况下测试得到的WebFlux应用程序。
绑定到ApplicationContext
下面的例子展示了如何设置一个服务器从Spring配置你的应用程序或它的一些子集:

@SpringJUnitConfig(WebConfig.class) //1
class MyTests {

    WebTestClient client;

    @BeforeEach
    void setUp(ApplicationContext context) {  //2
        client = WebTestClient.bindToApplicationContext(context).build(); //3
    }
}
  1. 指定要加载的配置
  2. 注入的配置
  3. 创建WebTestClient

在内部,配置被传递给WebHttpHandlerBuilder来设置请求处理链。有关更多细节,请参见WebHandler API。通过使用模拟请求和响应对象,在不使用HTTP服务器的情况下测试得到的WebFlux应用程序。

绑定到服务器
下面的服务器设置选项让你连接到一个正在运行的服务器:

client = WebTestClient.bindToServer().baseUrl("http://localhost:8080").build();

客户端构建器
除了前面描述的服务器设置选项之外,您还可以配置客户端选项,包括基本URL、缺省标头、客户端过滤器等。这些选项在bindToServer之后很容易获得。对于所有其他配置,您需要使用configureClient()从服务器配置转换到客户端配置,如下所示:

client = WebTestClient.bindToController(new TestController())
        .configureClient()
        .baseUrl("/test")
        .build();

3.7.2章 编写测试
WebTestClient提供了与WebClient相同的API,直到使用exchange()执行请求为止。exchange()之后是一个链式API工作流,用于验证响应。
通常,首先声明响应状态和报头,如下所示:

client.get().uri("/persons/1")
            .accept(MediaType.APPLICATION_JSON)
            .exchange()
            .expectStatus().isOk()
            .expectHeader().contentType(MediaType.APPLICATION_JSON)

然后指定如何解码和使用响应体:

  • expectBody(Class):解码到单个对象。
  • expectBodyList(Class):解码和收集对象来列出
  • expectBody():将JSON内容或空体解码为byte[]。

然后可以对主体使用内置断言。下面的例子展示了一种方法:

client.get().uri("/persons")
        .exchange()
        .expectStatus().isOk()
        .expectBodyList(Person.class).hasSize(3).contains(person);

您还可以超越内置的断言,创建自己的断言,如下面的示例所示:

import org.springframework.test.web.reactive.server.expectBody

client.get().uri("/persons/1")
        .exchange()
        .expectStatus().isOk()
        .expectBody(Person.class)
        .consumeWith(result -> {
            // custom assertions (e.g. AssertJ)...
        });

你也可以退出工作流程,得到如下结果:

EntityExchangeResult result = client.get().uri("/persons/1")
        .exchange()
        .expectStatus().isOk()
        .expectBody(Person.class)
        .returnResult();

当需要用泛型解码目标类型时,查找接受ParameterizedTypeReference而不是接受Class的重载方法。

没有内容
如果响应没有内容(或者您不关心它是否有内容),那么使用Void。类,它确保资源被释放。下面的例子演示了如何做到这一点:

client.get().uri("/persons/123")
        .exchange()
        .expectStatus().isNotFound()
        .expectBody(Void.class);

或者,如果你想断言没有响应内容,你可以使用类似下面的代码:

client.post().uri("/persons")
        .body(personMono, Person.class)
        .exchange()
        .expectStatus().isCreated()
        .expectBody().isEmpty();

JSON内容
当您使用expectBody()时,响应将作为字节[]使用。这对于原始内容断言非常有用。例如,您可以使用JSONAssert来验证JSON内容,如下所示:

client.get().uri("/persons/1")
        .exchange()
        .expectStatus().isOk()
        .expectBody()
        .json("{\"name\":\"Jane\"}")

您还可以使用JSONPath表达式,如下所示:

client.get().uri("/persons")
        .exchange()
        .expectStatus().isOk()
        .expectBody()
        .jsonPath("$[0].name").isEqualTo("Jane")
        .jsonPath("$[1].name").isEqualTo("Jason");

流媒体的反应
要测试无限流(例如“文本/事件流”或“应用程序/流+json”),您需要在响应状态和报头断言之后立即退出链接API(通过使用returnResult),如下面的示例所示:

FluxExchangeResult result = client.get().uri("/events")
        .accept(TEXT_EVENT_STREAM)
        .exchange()
        .expectStatus().isOk()
        .returnResult(MyEvent.class);

现在您可以使用Flux,在解码对象到来时断言它们,然后在达到测试目标时取消它们。我们建议使用来自反应器测试模块的StepVerifier来实现,如下例所示:

Flux eventFlux = result.getResponseBody();

StepVerifier.create(eventFlux)
        .expectNext(person)
        .expectNextCount(4)
        .consumeNextWith(p -> ...)
        .thenCancel()
        .verify();

请求体
当涉及到构建请求时,WebTestClient提供了一个与WebClient相同的API,并且实现主要是一个简单的传递。有关如何准备带有主体的请求(包括提交表单数据、多部分请求等)的示例,请参阅WebClient文档。

4. 进一步的资源
有关测试的更多信息,请参阅以下参考资料:

  • JUnit:“一个对程序员友好的Java测试框架”。由Spring框架在其测试套件中使用,并在Spring TestContext框架中支持。
  • TestNG:一个受JUnit启发的测试框架,增加了对测试组、数据驱动测试、分布式测试和其他特性的支持。在Spring TestContext框架中支持
  • AssertJ:“Java的流畅断言”,包括对Java 8 lambdas、streams和其他特性的支持。
  • Mock Objects:Wikipedia中的文章。
  • MockObjects.com:专门用于模拟对象的Web站点,这是一种用于改进测试驱动开发中的代码设计的技术。
  • 基于Test Spy模式的Java模拟库。由Spring框架在其测试套件中使用。
  • Mockito:基于Test Spy模式的Java模拟库。由Spring框架在其测试套件中使用。
  • EasyMock: Java库“通过使用Java的代理机制动态生成模拟对象,为接口(以及通过类扩展生成的对象)提供模拟对象。”
  • JMock:支持使用模拟对象进行Java代码测试驱动开发的库。
  • DbUnit: JUnit扩展(也可以与Ant和Maven一起使用),它的目标是数据库驱动的项目,并且在测试运行之间将数据库置于已知的状态。
  • The Grinder: Java负载测试框架。
  • SpringMockK:支持使用MockK而不是Mockito用Kotlin编写的Spring引导集成测试。

 

你可能感兴趣的:(Spring,SpringTest)