1. Spring 3.2 及以上版本自動開啓檢測URL後綴,設置Response content-type功能, 若是不手動關閉這個功能,當url後綴與accept頭不一致時, Response的content-type將會和request的accept不一致,致使報406java
關閉URL後綴檢測的方法以下web
<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屬性的方法設置爲protectedspring
即 關閉自動匹配URL後綴, 忽略Accept頭, 自動設置Reponse Content-Type爲 URL後綴類型 的配置, 因此若是要使用Spring-Test測試返回類型爲JSON的@ResponseBody API, 必須將請求URL後綴改成.json和accept頭(application/json)相匹配json
一個可行的方案是繼承StandaloneMockMvcBuilder, 將其favorPathExtendsion改成false, 這樣既可禁用自動匹配URL後綴功能api
實際上須要測試一個Spring的MVC controller,主要要作的就是模擬一個真實的Spring的上下文環境, 同時mock出訪問這個MVC方法的request, 並經過斷言去判斷響應及方法內部個方法的調用狀況的正確性spring-mvc
<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>
Controllersession
@Controller @RequestMapping("/category") public class CategoryController extends AbstractController { @Resource CategoryService categoryService; /** * 課程類目管理頁面 * * @return */ @RequestMapping("/manage.htm") public ModelAndView categoryManage() { List<Category> categoryList = categoryService.fetchAllCategories(); return new ModelAndView("category/categoryList").addObject(categoryList); } }
測試類mvc
@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;
@Mock private CategoryService mockCategoryService; @InjectMocks private CategoryController categoryController; // @Resource // private WebApplicationContext webApplicationContext; @Before public 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 */ @Test public 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); } }
下面對各變量進行解釋app
@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 = GET Request URI = /category/manage.htm Parameters = {} Headers = {} Handler: Type = com.qunar.fresh.exam.controller.CategoryController Method = public org.springframework.web.servlet.ModelAndView com.qunar.fresh.exam.controller.CategoryController.categoryManage() Resolved Exception: Type = null ModelAndView: View name = category/categoryList View = null Attribute = categoryList value = [com.qunar.fresh.exam.bean.Category@60e390, com.qunar.fresh.exam.bean.Category@fc40ae] FlashMap: MockHttpServletResponse: Status = 200 Error message = null Headers = {} Content type = null Body = Forwarded URL = category/categoryList Redirected URL = null Cookies = []
待提交的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; @Mock private PostService mockPostService; @InjectMocks private PostController postController; @Before public void before() { MockitoAnnotations.initMocks(this); Mockito.reset(mockPostService); mockMvc = QMockMvcBuilders.standaloneSetup(postController).build(); } @Test public 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 { @Resource PostService 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; @Mock private PostService mockPostService; @InjectMocks private PostController postController; @Before public void before() { MockitoAnnotations.initMocks(this); Mockito.reset(mockPostService); mockMvc = QMockMvcBuilders.standaloneSetup(postController).build(); } @Test public 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 = POST Request URI = /post/add Parameters = {title=[274864264523756946214], content=[NaN]} Headers = {Content-Type=[application/x-www-form-urlencoded]} Handler: Type = com.qunar.mvcdemo.controller.PostController Method = 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 = false Async result = null Resolved Exception: Type = null ModelAndView: View name = null View = org.springframework.web.servlet.view.RedirectView: unnamed; URL [/post/addPage] Model = null FlashMap: Attribute = errMap value = {title=標題長度必須在2至20個字符之間} MockHttpServletResponse: Status = 302 Error message = null Headers = {Location=[/post/addPage]} Content type = null Body = Forwarded URL = null Redirected URL = /post/addPage Cookies = []
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 = POST Request URI = /category/add.json Parameters = {} Headers = {Content-Type=[application/json;charset=UTF-8], Accept=[application/json;charset=UTF-8]} Handler: Type = com.qunar.fresh.exam.controller.CategoryController Method = public java.lang.Object com.qunar.fresh.exam.controller.CategoryController.categoryAdd(com.qunar.fresh.exam.bean.Category) Resolved Exception: Type = null ModelAndView: View name = null View = null Model = null FlashMap: MockHttpServletResponse: Status = 200 Error message = null Headers = {Content-Type=[application/json;charset=UTF-8]} Content type = application/json;charset=UTF-8 Body = {"ret":false,"errcode":1,"errmsg":"分類名已存在"} Forwarded URL = null Redirected URL = null Cookies = []