当前位置: 代码迷 >> 综合 >> 【Java.JUnit】Spring Test, JUnit, Mockito, Hamcrest 集成 Web 测试
  详细解决方案

【Java.JUnit】Spring Test, JUnit, Mockito, Hamcrest 集成 Web 测试

热度:84   发布时间:2023-12-14 18:51:04.0

关于Spring 3.2

1. Spring 3.2 及以上版本自动开启检测URL后缀,设置Response content-type功能, 如果不手动关闭这个功能,当url后缀与accept头不一致时, Response的content-type将会和request的accept不一致,导致报406

关闭URL后缀检测的方法如下

    <mvc:annotation-driven content-negotiation-manager="contentNegotiationManager" /><bean id="contentNegotiationManager" class="org.springframework.web.accept.ContentNegotiationManagerFactoryBean"><property name="favorPathExtension" value="false" /><property name="favorParameter" value="false" /></bean>

 

2. Spring-Test框架无法应用关闭Spring自动URL后缀检测的设置, 且StandaloneMockMvcBuilder将设置favorPathExtendsion属性的方法设置为protected

即 关闭自动匹配URL后缀, 忽略Accept头, 自动设置Reponse Content-Type为 URL后缀类型 的配置, 所以如果要使用Spring-Test测试返回类型为JSON的@ResponseBody API, 必须将请求URL后缀改为.json和accept头(application/json)相匹配

一个可行的方案是继承StandaloneMockMvcBuilder, 将其favorPathExtendsion改为false, 这样既可禁用自动匹配URL后缀功能

 

前言

实际上需要测试一个Spring的MVC controller,主要要做的就是模拟一个真实的Spring的上下文环境, 同时mock出访问这个MVC方法的request, 并通过断言去判断响应及方法内部个方法的调用情况的正确性

 

需要准备的Maven依赖

    <dependencies><dependency><groupId>org.codehaus.jackson</groupId><artifactId>jackson-core-asl</artifactId><version>1.9.9</version><scope>test</scope></dependency><dependency><groupId>org.codehaus.jackson</groupId><artifactId>jackson-mapper-asl</artifactId><version>1.9.9</version><scope>test</scope></dependency><!-- spring --><dependency><groupId>org.springframework</groupId><artifactId>spring-webmvc</artifactId><version>3.2.4.RELEASE</version></dependency><!-- servlet --><dependency><groupId>javax.servlet</groupId><artifactId>servlet-api</artifactId><version>3.0.1</version></dependency><dependency><groupId>javax.servlet</groupId><artifactId>jstl</artifactId><version>1.2</version></dependency><!-- logger --><dependency><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId><version>1.7.5</version></dependency><dependency><groupId>org.slf4j</groupId><artifactId>jcl-over-slf4j</artifactId><version>1.7.5</version></dependency><dependency><groupId>ch.qos.logback</groupId><artifactId>logback-classic</artifactId><version>1.0.13</version></dependency><!-- test --><dependency><groupId>org.springframework</groupId><artifactId>spring-test</artifactId><version>3.2.4.RELEASE</version><scope>test</scope></dependency><dependency><groupId>org.mockito</groupId><artifactId>mockito-core</artifactId><version>1.9.5</version><scope>test</scope><exclusions><exclusion><artifactId>hamcrest-core</artifactId><groupId>org.hamcrest</groupId></exclusion></exclusions></dependency><dependency><groupId>junit</groupId><artifactId>junit</artifactId><version>4.11</version><scope>test</scope><exclusions><exclusion><artifactId>hamcrest-core</artifactId><groupId>org.hamcrest</groupId></exclusion></exclusions></dependency><dependency><groupId>org.hamcrest</groupId><artifactId>hamcrest-all</artifactId><version>1.3</version><scope>test</scope></dependency><!-- validation --><dependency><groupId>javax.validation</groupId><artifactId>validation-api</artifactId><version>1.1.0.Final</version></dependency><dependency><groupId>org.hibernate</groupId><artifactId>hibernate-validator</artifactId><version>5.0.1.Final</version></dependency></dependencies>

 

 

对转发到页面的Controller方法进行测试

Controller

@Controller
@RequestMapping("/category")
public class CategoryController extends AbstractController {@ResourceCategoryService categoryService;/*** 课程类目管理页面* * @return*/@RequestMapping("/manage.htm")public ModelAndView categoryManage() {List<Category> categoryList = categoryService.fetchAllCategories();return new ModelAndView("category/categoryList").addObject(categoryList);}
}

 

测试类

@WebAppConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = { "classpath:spring/access-control.xml", "classpath:spring/dao.xml","classpath:spring/property.xml", "classpath:spring/service.xml" })
// "file:src/main/webapp/WEB-INF/spring-servlet.xml" })
public class CategoryControllerTest {private MockMvc mockMvc;@Mockprivate CategoryService mockCategoryService;@InjectMocksprivate CategoryController categoryController;// @Resource// private WebApplicationContext webApplicationContext;
@Beforepublic void before() throws Exception {MockitoAnnotations.initMocks(this); // 初始化mock对象Mockito.reset(mockCategoryService); // 重置mock对象/** 如果要使用完全默认Spring Web Context, 例如不需要对Controller注入,则使用 WebApplicationContext mockMvc =* MockMvcBuilders.webAppContextSetup(webApplicationContext).build();*/// mockMvc = MockMvcBuilders.standaloneSetup(categoryController).build();mockMvc = QMockMvcBuilders.standaloneSetup(categoryController).build();}/*** 课程分类管理测试* * @throws Exception*/@Testpublic void testCategoryManage() throws Exception {// 构建测试数据Category c1 = new CategoryBuilder().id(1).name("cat1").build();Category c2 = new CategoryBuilder().id(2).name("cat2").build();// 定义方法行为
        when(mockCategoryService.fetchAllCategories()).thenReturn(ImmutableList.of(c1, c2));// 构造http请求及期待响应行为mockMvc.perform(get("/category/manage.htm")).andDo(print()) // 输出请求和响应信息
                .andExpect(status().isOk()).andExpect(view().name("category/categoryList"))// .andExpect(forwardedUrl("/WEB-INF/jsp/category/categoryList.jsp")).andExpect(model().attribute("categoryList", hasSize(2))).andExpect(model().attribute("categoryList",hasItem(allOf(hasProperty("id", is(1)), hasProperty("name", is("cat1")))))).andExpect(model().attribute("categoryList",hasItem(allOf(hasProperty("id", is(2)), hasProperty("name", is("cat2"))))));verify(mockCategoryService, times(1)).fetchAllCategories();verifyNoMoreInteractions(mockCategoryService);}
}

下面对各变量进行解释

@WebAppConfiguration: 表明该类会使用web应用程序的默认根目录来载入ApplicationContext, 默认的更目录是"src/main/webapp", 如果需要更改这个更目录可以修改该注释的value值

@RunWith: 使用 Spring-Test 框架

@ContextConfiguration(location = ): 指定需要加载的spring配置文件的地址

@Mock: 需要被Mock的对象

@InjectMocks: 需要将Mock对象注入的对象, 此处就是Controller

@Before: 在每次Test方法之前运行的方法

 

特别需要注意的是, MockMvc就是用来模拟我们的MVC环境的对象, 他负责模拟Spring的MVC设置, 例如对Controller方法的RequestMapping等的扫描, 使用什么ViewResolver等等, 一般我们使用默认配置即可

由于此处我们需要将Controller mock掉, 所以我们不能使用真实的Spring MVC环境, 要使用与原web程序一样的真实的Spring MVC环境, 请使用

MockMvcBuilders.webAppContextSetup(webApplicationContext).build()

此处我们使用自定义的web MVC环境, controller也是自己注入的

        // mockMvc = MockMvcBuilders.standaloneSetup(categoryController).build();mockMvc = QMockMvcBuilders.standaloneSetup(categoryController).build();

注意这里使用的是QMockMvcBuilders, 而不是mockito提供的MockMvcBuilders, 原因就是Spring3.2 默认开启的忽略accept, url后缀匹配自动设置response content-type,这样容易导致406

所以我想把自动关闭后缀匹配, 又由于MockMvcBuilders无法读取spring-mvc的配置文件, 无法关闭该特性, 且MockMvcBuilders提供的关闭该特性(关闭favorPathExtension属性)内部方法居然是protected的,所以我只好继承该类去关闭该特性了

package com.qunar.fresh.exam.web.mockmvc;/*** @author zhenwei.liu created on 2013 13-10-15 上午1:19* @version 1.0.0*/
public class QMockMvcBuilders {public static StandaloneMockMvcBuilderWithNoPathExtension standaloneSetup(Object... controllers) {return new StandaloneMockMvcBuilderWithNoPathExtension(controllers);}
}
package com.qunar.fresh.exam.web.mockmvc;import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
import org.springframework.web.accept.ContentNegotiationManagerFactoryBean;/*** 一个favorPathExtension=false的StandaloneMockMvcBuilder* * @author zhenwei.liu created on 2013 13-10-15 上午12:30* @version 1.0.0*/
public class StandaloneMockMvcBuilderWithNoPathExtension extends StandaloneMockMvcBuilder {/*** 重设 ContentNegotiationManager, 关闭自动URL后缀检测* * @param controllers 控制器*/protected StandaloneMockMvcBuilderWithNoPathExtension(Object... controllers) {super(controllers);ContentNegotiationManagerFactoryBean factory = new ContentNegotiationManagerFactoryBean();factory.setFavorPathExtension(false); // 关闭URL后缀检测
        factory.afterPropertiesSet();setContentNegotiationManager(factory.getObject());}
}

另外还有个工具类, 和一个用来创建测试数据的builder

package com.qunar.fresh.exam.web.mockmvc;import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
import org.springframework.web.accept.ContentNegotiationManagerFactoryBean;/*** 一个favorPathExtension=false的StandaloneMockMvcBuilder* * @author zhenwei.liu created on 2013 13-10-15 上午12:30* @version 1.0.0*/
public class StandaloneMockMvcBuilderWithNoPathExtension extends StandaloneMockMvcBuilder {/*** 重设 ContentNegotiationManager, 关闭自动URL后缀检测* * @param controllers 控制器*/protected StandaloneMockMvcBuilderWithNoPathExtension(Object... controllers) {super(controllers);ContentNegotiationManagerFactoryBean factory = new ContentNegotiationManagerFactoryBean();factory.setFavorPathExtension(false); // 关闭URL后缀检测
        factory.afterPropertiesSet();setContentNegotiationManager(factory.getObject());}
}
package com.qunar.fresh.exam.controller.category;import com.qunar.fresh.exam.bean.Category;/*** 用于创建的Category测试数据** @author zhenwei.liu created on 2013 13-10-14 下午12:00* @version 1.0.0*/
public class CategoryBuilder {private int id;private String name;public CategoryBuilder id(int id) {this.id = id;return this;}public CategoryBuilder name(String name) {this.name = name;return this;}public Category build() {return new Category(id, name);}
}

 

最后看看返回结果

 

MockHttpServletRequest:HTTP Method = GETRequest URI = /category/manage.htmParameters = {}Headers = {}Handler:Type = com.qunar.fresh.exam.controller.CategoryControllerMethod = public org.springframework.web.servlet.ModelAndView com.qunar.fresh.exam.controller.CategoryController.categoryManage()Resolved Exception:Type = nullModelAndView:View name = category/categoryListView = nullAttribute = categoryListvalue = [com.qunar.fresh.exam.bean.Category@60e390, com.qunar.fresh.exam.bean.Category@fc40ae]FlashMap:MockHttpServletResponse:Status = 200Error message = nullHeaders = {}Content type = nullBody = Forwarded URL = category/categoryListRedirected URL = nullCookies = []

 

 

对表单提交方法进行测试

待提交的bean结构和验证内容

/*** @author zhenwei.liu created on 2013 13-10-15 下午4:19* @version 1.0.0*/
@WebAppConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:spring/service.xml")
public class PostControllerTest {private MockMvc mockMvc;@Mockprivate PostService mockPostService;@InjectMocksprivate PostController postController;@Beforepublic void before() {MockitoAnnotations.initMocks(this);Mockito.reset(mockPostService);mockMvc = QMockMvcBuilders.standaloneSetup(postController).build();}@Testpublic void testPostAddWhenTitleExceeds20() throws Exception {mockMvc.perform(post("/post/add").contentType(MediaType.APPLICATION_FORM_URLENCODED).param("title", TestUtil.createStringWithLength(21)).param("content", "NaN")).andDo(print()).andExpect(status().isMovedTemporarily()).andExpect(redirectedUrl("/post/addPage")).andExpect(flash().attributeCount(1)).andExpect(flash().attribute("errMap", hasKey("title"))).andExpect(flash().attribute("errMap", hasValue("标题长度必须在2至20个字符之间")));}
}

 

Controller方法

import java.util.HashMap;
import java.util.Map;import javax.annotation.Resource;
import javax.validation.Valid;import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.servlet.view.RedirectView;import com.qunar.mvcdemo.bean.Post;
import com.qunar.mvcdemo.service.PostService;/*** @author zhenwei.liu created on 2013 13-10-12 下午11:51* @version 1.0.0*/
@Controller
@RequestMapping("/post")
public class PostController {@ResourcePostService postService;@RequestMapping("/list")public ModelAndView list() {ModelAndView mav = new ModelAndView("post/list");mav.addObject(postService.fetchPosts());return mav;}@RequestMapping("/addPage")public ModelAndView addPage(@ModelAttribute HashMap<String, String> errMap) {return new ModelAndView("post/add");}@RequestMapping(value = "/add", method = RequestMethod.POST)public ModelAndView add(@Valid Post post, BindingResult bindingResult, RedirectAttributes redirectAttributes) {// 个人认为Spring的错误信息局限性太大,不如自己取出来手动处理if (bindingResult.hasErrors()) {Map<String, String> errMap = new HashMap<String, String>();for (FieldError fe : bindingResult.getFieldErrors()) {errMap.put(fe.getField(), fe.getDefaultMessage());}redirectAttributes.addFlashAttribute("errMap", errMap);return new ModelAndView(new RedirectView("/post/addPage"));}postService.addPost(post);return new ModelAndView(new RedirectView("/post/list"));}
}

测试方法

/*** @author zhenwei.liu created on 2013 13-10-15 下午4:19* @version 1.0.0*/
@WebAppConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:spring/service.xml")
public class PostControllerTest {private MockMvc mockMvc;@Mockprivate PostService mockPostService;@InjectMocksprivate PostController postController;@Beforepublic void before() {MockitoAnnotations.initMocks(this);Mockito.reset(mockPostService);mockMvc = QMockMvcBuilders.standaloneSetup(postController).build();}@Testpublic void testPostAddWhenTitleExceeds20() throws Exception {mockMvc.perform(post("/post/add").contentType(MediaType.APPLICATION_FORM_URLENCODED).param("title", TestUtil.createStringWithLength(21)).param("content", "NaN")).andDo(print()).andExpect(status().isMovedTemporarily()).andExpect(redirectedUrl("/post/addPage")).andExpect(flash().attributeCount(1)).andExpect(flash().attribute("errMap", hasKey("title"))).andExpect(flash().attribute("errMap", hasValue("标题长度必须在2至20个字符之间")));}
}

注意的点

1. 这个请求链使用了 RedirectAttribute的flashAttribute, flashAttribute的是一个基于Session的临时数据, 他使用session暂时存储, 接收方使用@ModelAttribte 来接受参数使用.

2. 使用了flash().attribute()来判断错误信息是否是期待值

查看输出

MockHttpServletRequest:HTTP Method = POSTRequest URI = /post/addParameters = {title=[274864264523756946214], content=[NaN]}Headers = {Content-Type=[application/x-www-form-urlencoded]}Handler:Type = com.qunar.mvcdemo.controller.PostControllerMethod = public org.springframework.web.servlet.ModelAndView com.qunar.mvcdemo.controller.PostController.add(com.qunar.mvcdemo.bean.Post,org.springframework.validation.BindingResult,org.springframework.web.servlet.mvc.support.RedirectAttributes)Async:Was async started = falseAsync result = nullResolved Exception:Type = nullModelAndView:View name = nullView = org.springframework.web.servlet.view.RedirectView: unnamed; URL [/post/addPage]Model = nullFlashMap:Attribute = errMapvalue = {title=标题长度必须在2至20个字符之间}MockHttpServletResponse:Status = 302Error message = nullHeaders = {Location=[/post/addPage]}Content type = nullBody = Forwarded URL = nullRedirected URL = /post/addPageCookies = []

 

对REST API测试

Controller接口

    /*** 添加分类* * @param category* @return*/@ResponseBody@RequestMapping(value = "/add.json", method = RequestMethod.POST)public Object categoryAdd(@RequestBody @Valid Category category) {if (!loginCheck()) {return getRedirectView("/loginPage.htm");}// 检查类目名是否重复Map<String, Object> params = Maps.newHashMap();params.put("name", category.getName());List<Category> test = categoryService.fetchCategories(params);if (test != null && test.size() != 0) { // 重复类目return JsonUtils.errorJson("分类名已存在");}categoryService.addCategory(category);logService.addLog(session.getAttribute(USERNAME).toString(), LogType.ADD, "新增课程类目: " + category.getName());return JsonUtils.dataJson("");}

测试方法

    /*** 添加已存在课程分类测试 期待返回错误信息JSON数据* * @throws Exception*/@Test@SuppressWarnings("unchecked")public void testCategoryAddWhenNameDuplicated() throws Exception {Category duplicatedCategory = new CategoryBuilder().id(1).name(TestUtil.createStringWithLength(5)).build();String jsonData = new ObjectMapper().writeValueAsString(duplicatedCategory);when(mockSession.getAttribute(SessionUtil.USERNAME)).thenReturn(TestUtil.createStringWithLength(5));when(mockCategoryService.fetchCategories(anyMap())).thenReturn(ImmutableList.of(duplicatedCategory));mockMvc.perform(post("/category/add.json").contentType(TestUtil.APPLICATION_JSON_UTF8).accept(TestUtil.APPLICATION_JSON_UTF8).content(jsonData)).andDo(print()).andExpect(status().isOk()).andExpect(content().contentType(TestUtil.APPLICATION_JSON_UTF8)).andExpect(jsonPath("$.ret", is(false))).andExpect(jsonPath("$.errcode", is(1))).andExpect(jsonPath("$.errmsg", is("分类名已存在")));verify(mockSession, times(1)).getAttribute(SessionUtil.USERNAME);verifyNoMoreInteractions(mockSession);verify(mockCategoryService, times(1)).fetchCategories(anyMap());verifyNoMoreInteractions(mockCategoryService);}

需要注意的是这里需要将请求数据序列化为JSON格式post过去,我们需要设置Accept头和request content-type以及response content-type

最后是验证返回的JSON数据是否符合预期要求,这里使用jsonpath来获取json的特定属性

输出如下

MockHttpServletRequest:HTTP Method = POSTRequest URI = /category/add.jsonParameters = {}Headers = {Content-Type=[application/json;charset=UTF-8], Accept=[application/json;charset=UTF-8]}Handler:Type = com.qunar.fresh.exam.controller.CategoryControllerMethod = public java.lang.Object com.qunar.fresh.exam.controller.CategoryController.categoryAdd(com.qunar.fresh.exam.bean.Category)Resolved Exception:Type = nullModelAndView:View name = nullView = nullModel = nullFlashMap:MockHttpServletResponse:Status = 200Error message = nullHeaders = {Content-Type=[application/json;charset=UTF-8]}Content type = application/json;charset=UTF-8Body = {"ret":false,"errcode":1,"errmsg":"分类名已存在"}Forwarded URL = nullRedirected URL = nullCookies = []

 

The End

  相关解决方案