Spring Boot入门(六)测试

主要内容:
 集成测试
 在服务器里测试应用程序
 Spring Boot的测试辅助工具

Spring的 SpringJUnit4ClassRunner 可以在基于JUnit的应用程序测试里加载Spring应用程序上下文。在测试Spring Boot应用程序时,Spring Boot除了拥有Spring的集成测试支持,还开启了自动配置和Web服务器,并提供了不少实用的测试辅助工具。

集成测试自动配置

Spring Framework的核心工作是将所有组件编织在一起,构成一个应用程序。整个过程就是读取配置说明(可以是XML、基于Java的配置、基于Groovy的配置或其他类型的配置),在应用程序上下文里初始化Bean,将Bean注入依赖它们的其他Bean中。
实例:用 SpringJUnit4ClassRunner 对Spring应用程序进行集成测试

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes=AddressBookConfiguration.class)  //加载上下文
public class AddressServiceTests {
		@Autowired  //注入地址服务
		private AddressService addressService;
		
		@Test  //测试地址服务
		public void testService() {
				Address address = addressService.findByLastName("Sheman");
				assertEquals("P", address.getFirstName());
				assertEquals("Sherman", address.getLastName());
				assertEquals("42 Wallaby Way", address.getAddressLine1());
				assertEquals("Sydney", address.getCity());
				assertEquals("New South Wales", address.getState());
				assertEquals("2000", address.getPostCode());
		}
}

AddressServiceTests 上加注了 @RunWith 和 @ContextConfiguration 注解。
@RunWith 的参数是 SpringJUnit4ClassRunner.class ,开启了Spring集成测试支持。与此同时, @ContextConfiguration 指定了如何加载应用程序上下文。此处我们让它加载 AddressBookConfiguration 里配置的Spring应用程序上下文。除了加载应用程序上下文, SpringJUnit4ClassRunner 还能通过自动织入从应用程序上下文里向测试本身注入Bean。因为这是一个针对 AddressService Bean的测试,所以需要将它注入测试。最后, testService() 方法调用地址服务并验证了结果。
@ContextConfiguration 并没有完全加载Spring Boot。Spring Boot应用程序最终是由 SpringApplication 加载的。
它可以显式加载,也可以使用 SpringBootServletInitializer 。 SpringApplication 不仅加载应用程序上下文,还会开启日志、加载外部属性(application.properties或application.yml),以及其他Spring Boot特性。
用 @ContextConfiguration 则得不到这些特性。要在集成测试里获得这些特性,可以把@ContextConfiguration 替换为Spring Boot的@SpringApplicationConfiguration :

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes=AddressBookConfiguration.class)
public class AddressServiceTests {
...
}

在大多数情况下,为Spring Boot应用程序编写测试时应该用 @SpringApplicationConfiguration 代替 @ContextConfiguration 。

测试Web应用程序

Spring MVC优点之一:编程模型围绕POJO展开的,在POJO上添加注解,声明如何处理Web请求。这种编程模型简单,还让你能像对待应用程序中的其他组件一样对待这些控制器。你还可以针对这些控制器编写测试,就像测试POJO一样。
举例来说,ReadingListController 里的 addToReadingList() 方法:

@RequestMapping(method=RequestMethod.POST)
public String addToReadingList(Book book) {
		book.setReader(reader);
		readingListRepository.save(book);
		return "redirect:/readingList";
}

若忽略 @RequestMapping 注解,就是一个相当基础的Java方法。假设现在这样一个测试,提供一个ReadingListRepository 的模拟实现,直接调用 addToReadingList() ,判断返回值并验证ReadingListRepository 的 save() 方法有过调用。
然而,它没有测试该方法处理/readingList的 POST 请求的情况,也没有测试表单域绑定到 Book 参数的情况。虽然可以判断返回的 String 包含特定值,但无法明确测试请求在方法处理完之后是否真的会重定向到 /readingList。
要恰当地测试一个Web应用程序,你需要投入一些实际的HTTP请求,确认它能正确地处理那些请求。
Spring Boot开发者有两个可选的方案能实现这类测试:

 Spring Mock MVC:能在一个近似真实的模拟Servlet容器里测试控制器,而不用实际启动
应用服务器。
 Web集成测试:在嵌入式Servlet容器(比如Tomcat或Jetty)里启动应用程序,在真正的应
用服务器里执行测试。

模拟 Spring MVC
要在测试里设置Mock MVC,可以使用 MockMvcBuilders ,该类提供了两个静态方法。

 standaloneSetup() :构建一个Mock MVC,提供一个或多个手工创建并配置的控制器。
 webAppContextSetup() :使用Spring应用程序上下文来构建Mock MVC,该上下文里可以包含一个或多个配置好的控制器。

主要区别在于, standaloneSetup() 希望你手工初始化并注入你要测试的控制器,而 webAppContextSetup() 则基于一个 WebApplicationContext 的实例,通常由Spring加载。

webAppContextSetup() 接受一个 WebApplicationContext 参数。因此,我们需要为测试类加上 @WebAppConfiguration 注解,使用 @Autowired 将 WebApplicationContext 作为实例变量注入测试类。
实例:为集成测试控制器创建Mock MVC

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = ReadingListApplication.class)
@WebAppConfiguration           //开启Web上下文测试                                                
public class MockMvcWebTests {
		@Autowired
		private WebApplicationContext webContext;  //注入WebApplicationContext
		private MockMvc mockMvc;
		@Before
		public void setupMockMvc() {		//设置MockMvc
				mockMvc = MockMvcBuilders
						.webAppContextSetup(webContext)
						.build();
		}
}

@WebAppConfiguration 注解声明,由 SpringJUnit4ClassRunner 创建的应用程序上下文应该是一个 WebApplicationContext (相对于基本的非Web ApplicationContext )。
setupMockMvc() 方法上添加了JUnit的 @Before 注解,表明它应该在测试方法之前执行。它将 WebApplicationContext 注入 webAppContextSetup() 方法,然后调用 build() 产生了一个 MockMvc 实例,该实例赋给了一个实例变量,供测试方法使用。
现在我们有了一个 MockMvc ,已经可以开始写测试方法了。
实例1:向/readingList发送一个HTTP GET 请求,判断模型和视图是否满足我们的期望:

@Test
public void homePage() throws Exception {
	mockMvc.perform(MockMvcRequestBuilders.get("/readingList"))
		.andExpect(MockMvcResultMatchers.status().isOk())
		.andExpect(MockMvcResultMatchers.view().name("readingList"))
		.andExpect(MockMvcResultMatchers.model().attributeExists("books"))
		.andExpect(MockMvcResultMatchers.model().attribute("books",Matchers.is(Matchers.empty())));
}

这个测试方法里使用了很多静态方法,包括Spring的 MockMvcRequestBuilders 和 MockMvcResultMatchers 里的静态方法,还有Hamcrest库的 Matchers 里的静态方法。在深入探讨这个测试方法前,先添加一些静态 import ,这样代码看起来更清爽一些:

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

测试方法可以稍作调整:

@Test
public void homePage() throws Exception {
	mockMvc.perform(get("/readingList"))
		.andExpect(status().isOk())
		.andExpect(view().name("readingList"))
		.andExpect(model().attributeExists("books"))
		.andExpect(model().attribute("books", is(empty())));
}

解读:
首先向/readingList发起一个 GET 请求,
接下来希望该请求处理成功( isOk() 会判断HTTP 200响应码),
并且视图的逻辑名称为 readingList 。
测试还要断定模型包含一个名为 books 的属性,
该属性是一个空集合。

此处完全不需要将应用程序部署到Web服务器上,它是运行在模拟的Spring MVC中的,刚好能通过 MockMvc 实例处理我们给它的HTTP请求。

实例2:测试提交一本新书

//先将一本书通过post放入,再通过get检查该书是否存在
@Test
public void postBook() throws Exception {
		//执行post
		mockMvc.perform(post("/readingList")
				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
				.param("title", "BOOK TITLE")
				.param("author", "BOOK AUTHOR")
				.param("isbn", "1234567890")
				.param("description", "DESCRIPTION"))
				.andExpect(status().is3xxRedirection())
				.andExpect(header().string("Location", "/readingList"));
		
		//配置一本书
		Book expectedBook = new Book();
		expectedBook.setId(1L);
		expectedBook.setReader("craig");
		expectedBook.setTitle("BOOK TITLE");
		expectedBook.setAuthor("BOOK AUTHOR");
		expectedBook.setIsbn("1234567890");
		expectedBook.setDescription("DESCRIPTION");
		
		//执行get请求
		mockMvc.perform(get("/readingList"))
				.andExpect(status().isOk())
				.andExpect(view().name("readingList"))
				.andExpect(model().attributeExists("books"))
				.andExpect(model().attribute("books", hasSize(1)))
				.andExpect(model().attribute("books",contains(samePropertyValuesAs(expectedBook))));
}

在提交图书时,我们必须确保内容类型(通过 MediaType.APPLICATION_FORM_URLENCODED )设置为application/x-www-form-urlencoded,这才是运行应用程序时浏览器会发送的内容类型。随后,要用 MockMvcRequestBuilders 的 param 方法设置表单域,模拟要提交的表单。一旦请求执行,我们要检查响应是否是一个到/readingList的重定向。假定以上测试都通过,我们进入第二部分。首先设置一个 Book 对象,包含想要的值。我们用这个对象和首页获取的模型的值进行对比。

随后要对/readingList发起一个 GET 请求,大部分内容和我们之前测试主页时一样,只是之前模型中有一个空集合,而现在有一个集合项。这里要检查它的内容是否和我们创建的 expected-Book 一致。如此一来,我们的控制器看来保存了发送给它的图书,完成了工作。

测试Web安全
Spring Security的使用必须在项目里添加Spring Security的测试模块。要在Gradle里做到这一点,你需要的就是以下testCompile 依赖:

testCompile("org.springframework.security:spring-security-test")

如果你用的是Maven,则添加以下 :

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>

应用程序的Classpath里有了Spring Security的测试模块之后,只需在创建 MockMvc 实例时运用Spring Security的配置器。

@Before
public void setupMockMvc() {
	mockMvc = MockMvcBuilders
		.webAppContextSetup(webContext)
		.apply(springSecurity())
		.build();
}

springSecurity() 方法 springSecurity() 是 SecurityMockMvcConfigurers的一个静态方法。
开启了Spring Security之后,在请求主页的时候,我们便不能只期待HTTP 200响应。如果请求未经身份验证,我们应该期待重定向到登录页面:

@Test
public void homePage_unauthenticatedUser() throws Exception {
		mockMvc.perform(get("/"))
				.andExpect(status().is3xxRedirection())
				.andExpect(header().string("Location","http://localhost/login"));
}
注意:如果你不想使用安全验证,你可以自己写一个aop进行权限管理

不过,经过身份验证的请求又该如何发起呢?Spring Security提供了两个注解。
@WithMockUser :加载安全上下文,其中包含一个 UserDetails ,使用了给定的用户名、密码和授权。
@WithUserDetails :根据给定的用户名查找 UserDetails 对象,加载安全上下文。在这两种情况下,Spring Security的安全上下文都会加载一个 UserDetails 对象,添加了该注解的测试方法在运行过程中都会使用该对象。 @WithMockUser 注解是两者里比较基础的那个,允许显式声明一个 UserDetails ,并加载到安全上下文。

@Test
@WithMockUser(username="craig",password="password",roles="READER")
public void homePage_authenticatedUser() throws Exception {
...
}

@WithMockUser 绕过了对 UserDetails 对象的正常查询,用给定的值创建了一个 UserDetails 对象取而代之。
但我们的测试需要 Reader (实现了 UserDetails )而非 @WithMockUser 创建的通用 UserDetails 。为此,我们需要@WithUserDetails 。
@WithUserDetails 注解使用事先配置好的 UserDetailsService 来加载 UserDetails 对象。
实例:测试带有用户身份验证的安全加固方法

@Test
@WithUserDetails("craig") 		//使用craig用户
public void homePage_authenticatedUser() throws Exception {
       //配置期望值
		Reader expectedReader = new Reader();
		expectedReader.setUsername("craig");
		expectedReader.setPassword("password");
		expectedReader.setFullname("Craig Walls");
		
		//发起get请求
		mockMvc.perform(get("/"))
			.andExpect(status().isOk())
			.andExpect(view().name("readingList"))
			.andExpect(model().attribute("reader",samePropertyValuesAs(expectedReader)))
			.andExpect(model().attribute("books", hasSize(0)));
}

通过 @WithUserDetails 注解声明要在测试方法执行过程中向安全上下文里加载craig用户。 Reader 会放入模型,该测试方法先创建了一个期望的 Reader 对象,后续可以用来进行比较。随后 GET 请求发起,也有了针对视图名和模型内容的断言,其中包括名为reader 的模型属性。

测试运行中的应用程序

Spring Boot支持将Tomcat或Jetty这样的嵌入式Servlet容器作为运行中的应用程序的一部分,可以运用相同的机制,在测试过程中用嵌入式Servlet容器来启动应用程序。

Spring Boot的 @WebIntegrationTest 注解就是这么做的。在测试类上添加 @WebIntegrationTest 注解,可以声明你不仅希望Spring Boot为测试创建应用程序上下文,还要启动一个嵌入式的Servlet容器。一旦应用程序运行在嵌入式容器里,你就可以发起真实的HTTP请求,断言结果了。

实例:采用 @WebIntegrationTest ,在服务器里启动了应用程序,以Spring的 RestTemplate 对应用程序发起HTTP请求。

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes=ReadingListApplication.class)
@WebIntegrationTest		//在服务器中运行测试
public class SimpleWebTest {
		@Test(expected=HttpClientErrorException.class)
		public void pageNotFound() {
				try {
						RestTemplate rest = new RestTemplate();
						rest.getForObject("http://localhost:8080/bogusPage", String.class);	//发起Get请求
						fail("Should result in HTTP 404");
						
				} catch (HttpClientErrorException e) {
						assertEquals(HttpStatus.NOT_FOUND, e.getStatusCode());	//判断HTTP 404响应
						throw e;
				}
		}
}

要判断实际启动的服务器究竟是哪个,可以遵循在命令行里运行应用程序时的逻辑。默认情况下,会有一个监听8080端口的Tomcat启动。但是,如果Classpath里有的话,Jetty或者Undertow也能启动这些服务器。

用随机端口启动服务器
一种办法是将server.port 属性设置为 0 ,让Spring Boot选择一个随机的可用端口。 @WebIntegrationTest
的 value 属性接受一个 String 数组,数组中的每项都是键值对,形如 name=value ,用来设置测试中使用的属性。要设置 server.port :

@WebIntegrationTest(value={"server.port=0"})

另外,因为只要设置一个属性,所以还能有更简单的形式:

@WebIntegrationTest("server.port=0")

@WebIntegrationTest 还提供了一个randomPort 属性,更明确地表示让服务器在随机端口上启动。你可以将 randomPort 设置为true ,启用随机端口:

@WebIntegrationTest(randomPort=true)

此时的 getForObject() 方法在URL里硬编码了8080端口。如果端口是随机选择的,那在构造请求时又该怎么确定正确的端口呢?
Spring Boot将 local.server.port 的值设置为了选中的端口。我们只需使用Spring的 @Value 注解将其注入即可:

@Value("${local.server.port}")
private int port;
...
rest.getForObject(
"http://localhost:{port}/bogusPage", String.class, port);
...

使用 Selenium 测试 HTML 页面
对于HTML应用程序测试,有一个更好的选择——Selenium(www.seleniumhq.org),它的功能远不止提交请求和获取结果。*它能实际打开一个Web浏览器,在浏览器的上下文中执行测试。*Selenium尽量接近手动执行测试,但与手工测试不同。Selenium的测试是自动的,而且可以重复运行。

用Selenium测试阅读列表应用程序,让我们先写一个测试来获取首页,为新书填写表单,
提交表单,随后判断返回的页面里是否包含新添加的图书。
首先需要把Selenium作为测试依赖添加到项目里:

testCompile("org.seleniumhq.selenium:selenium-java:2.45.0")

在Spring Boot里使用Selenium测试的模板:

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes=ReadingListApplication.class)
@WebIntegrationTest(randomPort=true)		//随机端口启动
public class ServerWebTests {
		private static FirefoxDriver browser;
		
		@Value("${local.server.port}")		//端口注入
		private int port;
		
		@BeforeClass
		public static void openBrowser() {
				browser = new FirefoxDriver();
				browser.manage().timeouts()
					.implicitlyWait(10, TimeUnit.SECONDS);		//配置浏览器驱动
		}
		
		@AfterClass
		public static void closeBrowser() {
				browser.quit();		//关闭浏览器
		}
}

静态方法 openBrowser() 会创建一个 FirefoxDriver 的实例,它将打开Firefox浏览器(需要在运行测试的服务器上安装该浏览器)。

选择浏览器 虽然我们用Firefox进行了测试,但Selenium还提供了不少其他浏览器
的驱动,包括IE、Google的Chrome,还有Apple的Safari。测试可以使用其他浏览器。你
也可以使用你想支持的各种浏览器,这也许也是个不错的想法。

开始编写测试方法:我们想要加载首页,填充并发送表单,然后判断登录的页面是否包含刚刚添加的新书。

@Test
public void addBookToEmptyList() {
		String baseUrl = "http://localhost:" + port;
		
		browser.get(baseUrl);
		
		//判断图书列表是否为空
		assertEquals("You have no books in your book list",browser.findElementByTagName("div").getText());
		
		//填充并发送表单
		browser.findElementByName("title").sendKeys("BOOK TITLE");
		browser.findElementByName("author").sendKeys("BOOK AUTHOR");
		browser.findElementByName("isbn").sendKeys("1234567890");
		browser.findElementByName("description").sendKeys("DESCRIPTION");
		browser.findElementByTagName("form").submit();
		
		//判断列表中是否包含新书
		WebElement dl = browser.findElementByCssSelector("dt.bookHeadline");
		assertEquals("BOOK TITLE by BOOK AUTHOR (ISBN: 1234567890)",dl.getText());
		WebElement dt = browser.findElementByCssSelector("dd.bookDescription");
		assertEquals("DESCRIPTION", dt.getText());
}

该测试方法所做的第一件事是使用 FirefoxDriver 来发起 GET 请求,获取阅读列表的主页,随后查找页面里的一个 div元素,从它的文本里判断列表里没有图书。接下来的几行查找表单里的元素,使用驱动的 sendKeys() 方法模拟敲击键盘事(实际上就是用给定的值填充那些表单域)。最后,找到 form 元素并提交。提交的表单经处理后,浏览器就会跳到一个页面,上面的列表包含了新添加的图书。因此最后几行查找列表里的 dt 和 dd 元素,判断其中是否包含测试表单里提交的数据。
运行测试时,你会看到浏览器打开,加载阅读列表应用程序。

参考文献:Spring Boot实战 ,丁雪丰 (译者)

猜你喜欢

转载自blog.csdn.net/weixin_43247186/article/details/82853626