• Spring Test, JUnit, Mockito, Hamcrest 集成 Web 测试


    关于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 {
    
        @Resource
        CategoryService 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;

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

    下面对各变量进行解释

    @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 = []

    对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 = 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 = []

    The End

  • 相关阅读:
    JSF教程(8)——生命周期之Apply Request Values Phase
    使Gallery时设置居左显示
    Android---06---2中动画效果
    java之UDP(datagramsocket,datagramPacket)实例
    Android NDK开发篇(四):Java与原生代码通信(原生方法声明与定义与数据类型)
    固态硬盘(SSD) 和机 械硬盘(HDD) 优缺点比較
    使用单例时的三种单例写法
    POJ 2777 Count Color
    Linux下画原理图和PCB
    hdu3685(几何重心与凸包结合)
  • 原文地址:https://www.cnblogs.com/zemliu/p/3369476.html
Copyright © 2020-2023  润新知