文章

Spring Mvc Test - MockMvc

MockMvc提供了对spring mvc层的测试能力。它能够模拟server的运行过程(实际上并没有真的server在运行),处理mock的请求和响应。说白了就是:client和server本来应该是多进程的行为,现在不仅都放在一个进程里,甚至都放在了同一个线程里执行!通过MockMvc,直接在一个线程里执行servlet!

It performs full Spring MVC request handling but via mock request and response objects instead of a running server.

MockMvc还能插入WebTestClient,让client完整测试http api,但实际servlet还是在同一个线程里串行执行的。

It can also be used through the WebTestClient where MockMvc is plugged in as the server to handle requests with. The advantage of WebTestClient is the option to work with higher level objects instead of raw data as well as the ability to switch to full, end-to-end HTTP tests against a live server and use the same test API.

The WebTestClient provides a fluent API without static imports.

  1. 为什么用MockMvc
  2. 初始化MockMvc
    1. 直接绑定controller
    2. 使用SpringMVC配置
  3. 使用MockMvc
    1. 构建MockMvc
    2. 构造请求 - MockMvcRequestBuilders
    3. 断言响应 - MockMvcResultMatchers
    4. 一些其他操作 - MockMvcResultHandlers
    5. 异步servlet
    6. MockMvc vs End-to-End Tests
  4. MockMvc模拟servlet容器:springmvc的本质
    1. WebApplicationContext
      1. ServletContext - war包里共享的servlet配置
      2. 创建WebApplicationContext
      3. 双向关联ServletContextWebApplicationContext
      4. 填充WebApplicationContext
        1. 填充controller、controller advice
        2. 填充@RequestMapping处理器
        3. 填充ViewResolver
        4. 填充session相关bean
        5. 填充自定义拓展的bean
    2. servlet
      1. ServletConfig - servlet独有的配置,不共享
      2. 创建DispatcherServlet
    3. 完成MockMvc
    4. 请求
      1. 构造请求
      2. 处理请求 - perform()
        1. DispatcherServlet处理请求
    5. 结果处理
    6. tomcat在哪儿?
  5. 感想

为什么用MockMvc

为什么用MockMvc呢?直接实例化一个controller,注入依赖,测它的方法不行吗?可以,但这样测的就仅仅是controller的部分,这只是spring mvc流程里非常小的一部分(纯业务部分)。如果仅仅测试这一部分,其实和只测试service差不太多。这样测试controller,并没有办法测试http在SpringMVC里的整个流动:

  1. 测不了api映射、数据参数绑定、rest数据转换等等:such tests do not verify request mappings, data binding, message conversion, type conversion, validation;
  2. 更测不了SpringMVC提供的异常处理器等组件:and nor do they involve any of the supporting @InitBinder, @ModelAttribute, or @ExceptionHandler methods;

测试controller的精髓就在于测试SpringMVC这一套是否都正确运行,否则直接测service就差不多了。MockMvc就是spring提供的专门用来测SpringMVC的框架。

初始化MockMvc

有两种初始化MockMvc的方法:

直接绑定controller

在配置上完全不考虑spring的语境,只指定要测试的controller(可以额外加上filter、controller advice等组件),由MockMvc自动创建一个WebApplicationContext,用于构建MockMvc

1
2
3
4
5
6
7
8
9
10
11
12
class MyWebTests {

    MockMvc mockMvc;

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

    // ...

}

这种方式更像单元测试,一次只测试一个controller。它需要 手动 给controller注入需要的依赖(可以是真实的,也可以是mock的)。

优点是非常简单:可以快速构建test、用来debug某个issue。

等价的WebTestClient用法:

  • https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#webtestclient-controller-config

使用SpringMVC配置

指定SpringMVC配置文件,根据配置文件自动创建好一个WebApplicationContext,用于构建MockMvc

1
2
3
4
5
6
7
8
9
10
11
12
13
@SpringJUnitWebConfig(locations = "my-servlet-context.xml")
class MyWebTests {

    MockMvc mockMvc;

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

    // ...

}

这种方法更“集成”一些,因为它加载的是真实的SpringMVC配置,而且可以在配置里指定很多bean,能把这些bean直接@Autowired到test class里。不需要手动注入依赖到controller里。

当然,也可以配置文件里声明mock的bean,未必都是真实的:

1
2
3
<bean id="accountService" class="org.mockito.Mockito" factory-method="mock">
    <constructor-arg value="org.example.AccountService"/>
</bean>

config class同理。

而且这个配置会被TestContext framework缓存下来,所以当这种测试变多的时候,速度会更快。

等价的WebTestClient用法:

  • https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#webtestclient-context-config

使用MockMvc

以使用第一种方式初始化MockMvc为例——

构建MockMvc

StandaloneMockMvcBuilder有一堆配置方法,可以完善mvc组件,注册filter,也可以给请求默认添加一些全局设置,比如:

1
2
3
4
5
6
7
8
// static import of MockMvcBuilders.standaloneSetup

MockMvc mockMvc = standaloneSetup(new MusicController())
    .addFilters(new CharacterEncodingFilter())
    .defaultRequest(get("/").accept(MediaType.APPLICATION_JSON))
    .alwaysExpect(status().isOk())
    .alwaysExpect(content().contentType("application/json;charset=UTF-8"))
    .build();

上面的示例给所有request默认添加accept header,内容为application/json。如果传入的请求设置了同样的properties,以请求为准MockMvc本身不构建请求,它只提供一些默认的RequestBuilder。如果请求没设置的properties,使用默认的RequestBuilder设置一下。

DefaultMockMvcBuilder同理。

还可以通过MockMvcConfigurer做配置(很像springboot自动配置提供的各种configurer,用于自定义自动配置的bean)。比如SharedHttpSessionConfigurer,能够让同一MockMvc的所有test method共享session。

其实就是在构建完MockMvc之后给它添加一个默认的ResultHandlerRequestBuilder。前者用于从response里取出session并保存下来,后者用于给传入的request设置上这个session(当然第一次request是设置不上这个session了,因为第一次请求之后才有session)。

构造请求 - MockMvcRequestBuilders

发送请求之前先使用MockMvcRequestBuilders构造请求。

一个post请求,带上accept header:

1
post("/hotels/{id}", 42).accept(MediaType.APPLICATION_JSON)

file upload, multipart:

1
multipart("/doc").file("a1", "ABC".getBytes("UTF-8"))

通过URI template指定query parameters

1
get("/hotels?thing={thing}", "somewhere")

使用param()方法给Servlet request添加query parameters或者form parameters

1
get("/hotels").param("thing", "somewhere")

query parameter和form parameter只有在check query string的时候才有区别

  • ServletRequest定义了获取parameter的方法public String getParameter(String name): Returns the value of a request parameter as a String, or null if the parameter does not exist. Request parameters are extra information sent with the request. For HTTP servlets, parameters are contained in the query string or posted form data.
  • HttpServletRequest才有获取query string的方法public String getQueryString(): Returns the query string that is contained in the request URL after the path. This method returns null if the URL does not have a query string.

所以处理HttpServletRequest的时候,如果不调用getQueryString(),其实http用的到底是query parameter还是form parameter,并没什么区别。事实上,我们从来没有直接处理过http,都是用的包装后的HttpServletRequest,调用的也基本都是getParameter()

If application code relies on Servlet request parameters and does not check the query string explicitly (as is most often the case), it does not matter which option you use.

既然getParameter()对于query parameter和form parameter来说都没有什么区别,MockHttpServletRequestBuilder的实现就非常粗暴,直接把param()方法传入的parameter放到了MultiValueMap<String, String>里,干脆就不区分了。反正在构建为MockHttpServletRequest的时候,也是放到了Map<String, String[]> parameters里。

tomcat对HttpServletRequest的正式实现里,parameters用了一个自定义的Parameters对象,但它里面包装的其实还是一个Map<String,ArrayList<String>>,所以区别不大。

无论通过URI template还是param()指定参数,传入的都是url decode后的值。但是二者是有区别的:

  • 前者传入的decode值是原始值,后续发请求的时候会被encode,处理的时候会再次被decode;
  • 后者传入的decode值是逻辑上已经被url decode了的值,因为后续调用ServletRequest#getParameter的时候,直接就把它返回了;

虽然表面上看,他们都是url decode的值,但理解这两点区别还是很重要的,说明理解了他们在整个请求流程里所处的位置。

Keep in mind, however, that query parameters provided with the URI template are decoded while request parameters provided through the param(…) method are expected to already be decoded.

MockMvc推荐直接测controller mapping,不要加上context path和servlet path。如果非要带上这俩测完整的url,要把他们指明了。否则MockMvc会把整个url当做controller mapping处理,不考虑context path和servlet path:

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

context path和servlet path也可以设置到MockMvcdefaultRequest()里,就不用在每个请求里单独设置了:

1
2
3
4
5
6
7
8
9
10
11
class MyWebTests {

    MockMvc mockMvc;

    @BeforeEach
    void setup() {
        mockMvc = standaloneSetup(new AccountController())
            .defaultRequest(get("/").contextPath("/app").servletPath("/main").accept(MediaType.APPLICATION_JSON))
            .build();
    }
}

断言响应 - MockMvcResultMatchers

使用andExpect()

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

断言来自MockMvcResultMatchers

使用andExpectAll()的好处是,会断言所有,即使有的失败了,也会继续执行后续的断言:

1
2
3
4
mockMvc.perform(get("/accounts/1")).andExpectAll(
    status().isOk(),
    content().contentType("application/json;charset=UTF-8")
);

断言主要有两大类:

  1. 断言response本身:比如response status, headers, and content等等;
  2. 断言SpringMVC方面的东西
    1. which controller method processed the request,
    2. whether an exception was raised and handled,
    3. what the content of the model is,
    4. what view was selected,
    5. what flash attributes were added,
    6. and so on.
    7. 包括servlet相关的:such as request and session attributes.

model()方法返回ModelResultMatchers,然后使用ModelResultMatchers断言binding or validation failed:

1
2
3
mockMvc.perform(post("/persons"))
    .andExpect(status().isOk())
    .andExpect(model().attributeHasErrors("person"));

如果感觉断言的还不够,可以使用andReturn()获取结果,自己直接获取结果里的某一部分做断言:

1
2
MvcResult mvcResult = mockMvc.perform(post("/persons")).andExpect(status().isOk()).andReturn();
// ...

也可以通过MockMvc给所有请求加上默认断言,alwaysExpect()

1
2
3
4
standaloneSetup(new SimpleController())
    .alwaysExpect(status().isOk())
    .alwaysExpect(content().contentType("application/json;charset=UTF-8"))
    .build()

和默认request builder不一样的是,alwaysExpect不能被覆盖:Note that common expectations are always applied and cannot be overridden without creating a separate MockMvc instance.

一些其他操作 - MockMvcResultHandlers

比如print,把response输出到System.out

1
2
3
4
mockMvc.perform(post("/persons"))
    .andDo(print())
    .andExpect(status().isOk())
    .andExpect(model().attributeHasErrors("person"));

当然还能传参输出到OutputStream或者Writer。还有一个log()方法,输出为DEBUG信息到org.springframework.test.web.servlet.result日志。

异步servlet

Servlet - NIO & Async中介绍过异步servlet,主要应用场景是server push:由一个工作线程管理一堆长http request,并在有消息的时候push回客户端。

主要原因在于:虽然request很长,还没结束,但是servlet线程可以结束了。之前同步servlet做不到这一点,对于长request,只能一直耗着。

MockMvc测试异步servlet时生动地揭示了spring test和异步servlet的本质:

  1. 先测试返回的异步结果;
  2. 再手动调用异步dispatch,然后校验真正的(异步计算出来的)结果

它不仅生动地说明了异步servlet就像Future一样分两部分:Future本身、通过Future获取到的真正的结果。还揭示了spring test的本质:其实是在一个线程里手动执行servlet

In Spring MVC Test, async requests can be tested by asserting the produced async value first, then manually performing the async dispatch, and finally verifying the response.

第一次执行,因为是异步的,所以直接返回了,没有实质的结果。第二次执行的时候把第一次的结果放进去,并手动执行async逻辑,再对(真正的)结果进行判断:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void test() throws Exception {
    MvcResult mvcResult = this.mockMvc.perform(get("/path"))
            .andExpect(status().isOk()) 
            .andExpect(request().asyncStarted()) 
            .andExpect(request().asyncResult("body")) 
            .andReturn();

    this.mockMvc.perform(asyncDispatch(mvcResult)) 
            .andExpect(status().isOk()) 
            .andExpect(content().string("body"));
}

MockMvc vs End-to-End Tests

MockMvc是测试SpringMVC的mvc层的重要手段!它其实介于单元测试和集成测试之间,就好像我们单独测service层一样,不能说是集成测试,但比单元测试又多了一些。MockMvc是spring test出的专门测web layer的非常方便的工具!

  • https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#spring-mvc-test-vs-end-to-end-integration-tests

MockMvc模拟servlet容器:springmvc的本质

以一个纯手工打造StandaloneMockMvcBuilder的例子为入口,分析一下MockMvc初始化和处理请求的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@ExtendWith(MockitoExtension.class)
public class SuperHeroControllerMockMvcStandaloneTest {

    private MockMvc mvc;

    @Mock
    private SuperHeroRepository superHeroRepository;

    @InjectMocks
    private SuperHeroController superHeroController;

    @BeforeEach
    public void setup() {
        // MockMvc standalone approach
        mvc = MockMvcBuilders.standaloneSetup(superHeroController)
                .setControllerAdvice(new SuperHeroExceptionHandler())
                .addFilters(new SuperHeroFilter())
                .build();
    }

    @Test
    public void canRetrieveByIdWhenExists() throws Exception {
        // given
        given(superHeroRepository.getSuperHero(2))
                .willReturn(new SuperHero("Rob", "Mannon", "RobotMan"));

        // when
        MockHttpServletResponse response = mvc.perform(
                get("/superheroes/2")
                        .accept(MediaType.APPLICATION_JSON))
                .andReturn().getResponse();

        // then
        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
        assertThat(response.getContentAsString()).isEqualTo(
                jsonSuperHero.write(new SuperHero("Rob", "Mannon", "RobotMan")).getJson()
        );
    }
}

MockMvcbuilder的前期设置无非是在set一些属性,只有最后的build()生成MockMvc对象这一步,揭示了MockMvc的本质,同时也很大程度上揭示了SpringMVC的本质

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
	public final MockMvc build() {
		WebApplicationContext wac = initWebAppContext();
		ServletContext servletContext = wac.getServletContext();
		MockServletConfig mockServletConfig = new MockServletConfig(servletContext);

		for (MockMvcConfigurer configurer : this.configurers) {
			RequestPostProcessor processor = configurer.beforeMockMvcCreated(this, wac);
			if (processor != null) {
				if (this.defaultRequestBuilder == null) {
					this.defaultRequestBuilder = MockMvcRequestBuilders.get("/");
				}
				if (this.defaultRequestBuilder instanceof ConfigurableSmartRequestBuilder) {
					((ConfigurableSmartRequestBuilder) this.defaultRequestBuilder).with(processor);
				}
			}
		}

		Filter[] filterArray = this.filters.toArray(new Filter[0]);

		return super.createMockMvc(filterArray, mockServletConfig, wac, this.defaultRequestBuilder,
				this.defaultResponseCharacterEncoding, this.globalResultMatchers, this.globalResultHandlers,
				this.dispatcherServletCustomizers);
	}

下面一步一步来分解——

WebApplicationContext

对于StandaloneMockMvcBuilder来说,因为没有spring WebApplicationContext,所以首先要搞一个WebApplicationContext,wac:

1
2
3
4
5
6
7
8
    	@Override
    	protected WebApplicationContext initWebAppContext() {
    		MockServletContext servletContext = new MockServletContext();
    		StubWebApplicationContext wac = new StubWebApplicationContext(servletContext);
    		registerMvcSingletons(wac);
    		servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, wac);
    		return wac;
    	}

本质:SpringMVC是基于servlet做的,所以要把自己接入servlet标准。即:servlet要持有SpringMVC的WebApplicationContext

因此首先要搞一个ServletContext,然后才能让它关联wac。

ServletContext - war包里共享的servlet配置

ServletContext里放的是一个war包里所有servlet需要共享的配置,可以认为它是从web.xml读的数据。因为servlet需要这些数据,所以servlet有getServletContext方法,以获取ServletContext。之后再调用它的:

  • getInitParameter获取自定义的初始化参数
  • getAttribute/setAttribute

等方法以获取数据、临时保存数据。

MockMvc里的ServletContext实现是MockServletContext

1
2
3
4
5
6
7
8
9
10
11
12
    	public MockServletContext(String resourceBasePath, @Nullable ResourceLoader resourceLoader) {
    		this.resourceLoader = (resourceLoader != null ? resourceLoader : new DefaultResourceLoader());
    		this.resourceBasePath = resourceBasePath;

    		// Use JVM temp dir as ServletContext temp dir.
    		String tempDir = System.getProperty(TEMP_DIR_SYSTEM_PROPERTY);
    		if (tempDir != null) {
    			this.attributes.put(WebUtils.TEMP_DIR_CONTEXT_ATTRIBUTE, new File(tempDir));
    		}

    		registerNamedDispatcher(this.defaultServletName, new MockRequestDispatcher(this.defaultServletName));
    	}
  • 默认设置的resource地址(war包地址)是""
  • 默认注册了RequestServlet(实现为MockRequestDispatcher),名字就叫"default"。这个dispatcher貌似是作为默认请求dispatcher,能够重定向request到别的resource或servlet;
  • 默认设置了context path(默认为""

这个dispatcher应该用不到了,因为加下来给DispatcherServlet设置的context path和servlet name都是空字符串,所以一定都能匹配上请求,也就不需要这个RequestDispatcher了。

创建WebApplicationContext

有了ServletContext,就可以创建WebApplicationContext了,这里用到的是WebApplicationContext的mock实现,StubWebApplicationContext。创建它需要传入ServletContext

StubWebApplicationContext是一种偷懒的实现,除了getServletContext()wac比ApplicationContext多出来的唯一一个方法就是ServletContext getServletContext()),其他方法(ApplicationContext接口定义的方法)都delegate到内部包装的一个StubBeanFactory处理了。后者基于spring core的StaticListableBeanFactory实现,但是很多方法都是空实现。毕竟是测试用的。

双向关联ServletContextWebApplicationContext

这里的关联是双向的

  1. WebApplicationContext持有ServletContext:set ServletContext到wac;
  2. ServletContext持有WebApplicationContextorg.springframework.web.context.WebApplicationContext.ROOT -> WebApplicationContext

第二个关联比较tricky:没办法像第一种方式一样去做关联,即使把wac set到ServletContext里,仅根据ServletContext的接口仍然不能把wac get出来。毕竟ServletContext在前一层,不可能为SpringMVC提供专门的getWebApplicationContext()方法,没提供wac相关的setter/getter。但是servlet还是给基于它的框架提供了一种通用的实现:ServletContext#setAttribute/getAttribute

1
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, wac);

这里用到的key是org.springframework.web.context.WebApplicationContext.ROOT它是和ServletContext关联的root wac

SpringMVC里有非常多的这种关联方式。servlet只能提供这种关联方式了,没办法。

填充WebApplicationContext

手动给wac塞一堆mvc所需要的bean,其实就是塞到wac内部持有的BeanFactory里了。

填充controller、controller advice

填充的都是SpringMVC需要的bean:

1
2
3
4
5
6
7
8
9
10
11
    		wac.addBeans(this.controllers);
    		wac.addBeans(this.controllerAdvice);

    		FormattingConversionService mvcConversionService = config.mvcConversionService();
    		wac.addBean("mvcConversionService", mvcConversionService);
    		ResourceUrlProvider resourceUrlProvider = config.mvcResourceUrlProvider();
    		wac.addBean("mvcResourceUrlProvider", resourceUrlProvider);
    		ContentNegotiationManager mvcContentNegotiationManager = config.mvcContentNegotiationManager();
    		wac.addBean("mvcContentNegotiationManager", mvcContentNegotiationManager);
    		Validator mvcValidator = config.mvcValidator();
    		wac.addBean("mvcValidator", mvcValidator);

填充@RequestMapping处理器

1
2
3
4
5
6
7
8
9
10
    		RequestMappingHandlerAdapter ha = config.requestMappingHandlerAdapter(mvcContentNegotiationManager,
    				mvcConversionService, mvcValidator);
    		if (sc != null) {
    			ha.setServletContext(sc);
    		}
    		ha.setApplicationContext(wac);
    		ha.afterPropertiesSet();
    		wac.addBean("requestMappingHandlerAdapter", ha);

    		wac.addBean("handlerExceptionResolver", config.handlerExceptionResolver(mvcContentNegotiationManager));
  • HandlerMapping的实现是RequestMappingHandlerMapping:根据url mapping找到处理请求的handler
  • RequestMappingHandlerAdapter:处理url mapping对应的请求

RequestMappingHandlerMapping是一个InitializingBean

1
2
3
4
5
6
7
8
    	/**
    	 * Detects handler methods at initialization.
    	 * @see #initHandlerMethods
    	 */
    	@Override
    	public void afterPropertiesSet() {
    		initHandlerMethods();
    	}

bean创建之后会自动调用注册功能,把所有controller的mapping注册到MappingRegistry

实际上注册的时候并没有区分是不是controller,而是把所有的bean都遍历一遍,把找到的所有controller都注册好。因为用的是StandaloneMockMvcBuilder,所以只注册了一个controller的mapping。

一个注册好的MappingRegistry的示例:

  • 每个HTTP METHOD + URL是一个registry;
  • 还有path lookup,前缀树?
  • 还有method lookup,是controller的方法名;

填充ViewResolver

1
2
3
4
    		wac.addBeans(initViewResolvers(wac));
    		wac.addBean(DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME, this.localeResolver);
    		wac.addBean(DispatcherServlet.THEME_RESOLVER_BEAN_NAME, new FixedThemeResolver());
    		wac.addBean(DispatcherServlet.REQUEST_TO_VIEW_NAME_TRANSLATOR_BEAN_NAME, new DefaultRequestToViewNameTranslator());

用的是InternalResourceViewResolver

填充session相关bean

1
2
    		this.flashMapManager = new SessionFlashMapManager();
    		wac.addBean(DispatcherServlet.FLASH_MAP_MANAGER_BEAN_NAME, this.flashMapManager);

填充自定义拓展的bean

1
    		extendMvcSingletons(sc).forEach(wac::addBean);

它是protected方法,且默认实现返回空,摆明是让子类拓展的:

1
2
3
    	protected Map<String, Object> extendMvcSingletons(@Nullable ServletContext servletContext) {
    		return Collections.emptyMap();
    	}

servlet

准备好了ServletContextWebApplicationContext,接下来就是要初始化servlet。

ServletConfig - servlet独有的配置,不共享

ServletConfig是每个servlet独有的一份配置信息。它和ServletContext一样,也可以读取自定义的初始化参数:

  • getInitParameter自定义的初始化参数

ServletConfig还关联了ServletContext(因为共享的配置肯定要被独有的配置获取嘛),有获取它的方法:

  • ServletContext getServletContext()

所以创建MockServletConfig的时候,把之前创建的ServletContext放了进去。

ServletConfig vs. ServletContext

  1. ServletConfig的主要作用,就是初始化servlet:在Servlet#init的时候从里面读取一些配置信息,过后就基本不用了
  2. ServletContext则是上下文信息,除了能获取一些公共配置之外,还能当做全局容器通过getAttribute/setAttribute放一些东西,起到传参的作用

SpringMVC就一个servlet——DispatcherServlet,所以创建的ServletConfig也仅仅是针对它一个人的config。config里设置的servlet name是"",也就是说DispatcherServlet对应的名字是""。即:url里不需要指定servlet的名字了。

创建DispatcherServlet

ServletConfig也准备好了,接下来就要创建SpringMVC里唯一的servlet了——DispatcherServlet。这里用到的实现是TestDispatcherServlet:它override了DispatcherServlet,把DispatcherServlet处理后的结果放到了spring test的MvcResult里,其他跟DispatcherServlet没啥区别。

DispatcherServlet还要从wac里面取bean初始化自己呢,所以要把wac设置进来。根据ServletConfig初始化DispatcherServlet,初始化过后ServletConfig就不需要了。

现在能初始化DispatcherServlet的材料有哪些?都设置了什么值?

  1. ServletConfig
    1. servlet name设置为空
    2. init parameters没有设置;
  2. ServletConfig内含ServletContextServletContext的名字为”MockServletContext“,不过这个名字没啥卵用。
    1. context path设置为空
    2. default servlet name=”default“;
    3. 通过一个特殊的key在attribute关联了wac:org.springframework.web.context.WebApplicationContext.ROOT -> WebApplicationContext
    4. resource base path设置为空;

开始初始化。javadoc的介绍是,先把servlet里的配置参数放到bean里:Map config parameters onto bean properties of this servlet, and invoke subclass initialization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    	@Override
    	public final void init() throws ServletException {

    		// Set bean properties from init parameters.
    		PropertyValues pvs = new ServletConfigPropertyValues(getServletConfig(), this.requiredProperties);
    		if (!pvs.isEmpty()) {
    			try {
    				BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(this);
    				ResourceLoader resourceLoader = new ServletContextResourceLoader(getServletContext());
    				bw.registerCustomEditor(Resource.class, new ResourceEditor(resourceLoader, getEnvironment()));
    				initBeanWrapper(bw);
    				bw.setPropertyValues(pvs, true);
    			}
    			catch (BeansException ex) {
    				if (logger.isErrorEnabled()) {
    					logger.error("Failed to set bean properties on servlet '" + getServletName() + "'", ex);
    				}
    				throw ex;
    			}
    		}

    		// Let subclasses do whatever initialization they like.
    		initServletBean();
    	}

它所做的就是先把ServletConfig里的init parameter取出来,放到了properties里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    		public ServletConfigPropertyValues(ServletConfig config, Set<String> requiredProperties)
    				throws ServletException {

    			Set<String> missingProps = (!CollectionUtils.isEmpty(requiredProperties) ?
    					new HashSet<>(requiredProperties) : null);

    			Enumeration<String> paramNames = config.getInitParameterNames();
    			while (paramNames.hasMoreElements()) {
    				String property = paramNames.nextElement();
    				Object value = config.getInitParameter(property);
    				addPropertyValue(new PropertyValue(property, value));
    				if (missingProps != null) {
    					missingProps.remove(property);
    				}
    			}

    			// Fail if we are still missing properties.
    			if (!CollectionUtils.isEmpty(missingProps)) {
    				throw new ServletException(
    						"Initialization from ServletConfig for servlet '" + config.getServletName() +
    						"' failed; the following required properties were missing: " +
    						StringUtils.collectionToDelimitedString(missingProps, ", "));
    			}
    		}

所以在web.xml里设置值和在spring配置文件里设置,都一样。

然后继续init servlet,Javadoc的介绍是,在bean properties设置完毕后,开始创建wac:invoked after any bean properties have been set. Creates this servlet’s WebApplicationContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    	@Override
    	protected final void initServletBean() throws ServletException {
    		getServletContext().log("Initializing Spring " + getClass().getSimpleName() + " '" + getServletName() + "'");
    		if (logger.isInfoEnabled()) {
    			logger.info("Initializing Servlet '" + getServletName() + "'");
    		}
    		long startTime = System.currentTimeMillis();

    		try {
    			this.webApplicationContext = initWebApplicationContext();
    			initFrameworkServlet();
    		}
    		catch (ServletException | RuntimeException ex) {
    			logger.error("Context initialization failed", ex);
    			throw ex;
    		}

    		if (logger.isDebugEnabled()) {
    			String value = this.enableLoggingRequestDetails ?
    					"shown which may lead to unsafe logging of potentially sensitive data" :
    					"masked to prevent unsafe logging of potentially sensitive data";
    			logger.debug("enableLoggingRequestDetails='" + this.enableLoggingRequestDetails +
    					"': request parameters and headers will be " + value);
    		}

    		if (logger.isInfoEnabled()) {
    			logger.info("Completed initialization in " + (System.currentTimeMillis() - startTime) + " ms");
    		}
    	}

这里又创建了一个wac,不过它是属于DispatcherServlet的wac。之前创建的那个wac是和ServletContext关联的wac,是root wac。

所以之前用来关联root wac的key是org.springframework.web.context.WebApplicationContext.ROOT,尾缀为root。不同wac之间出现了层级关系。

TestDispatcherServlet的wac直接设置为了root wac,主要是为了省事儿。可以参考Spring Web MVC hierarchy。

对于DispatcherServlet来说,它的wac需要init这些东西:

1
2
3
4
5
6
7
8
9
10
11
    	protected void initStrategies(ApplicationContext context) {
    		initMultipartResolver(context);
    		initLocaleResolver(context);
    		initThemeResolver(context);
    		initHandlerMappings(context);
    		initHandlerAdapters(context);
    		initHandlerExceptionResolvers(context);
    		initRequestToViewNameTranslator(context);
    		initViewResolvers(context);
    		initFlashMapManager(context);
    	}

ApplicationContext里取出相关组件,设置到DispatcherServlet里,比如ViewResolverHandlerMappingHandlerAdapter等等。

servlet的wac创建好了之后,要把自己的wac也放到ServletContext里!key为FrameworkServlet.class.getName() + ".CONTEXT." + getServletName()。因为TestDispatcherServlet的servlet name是空字符串,所以它的key就是org.springframework.web.servlet.FrameworkServlet.CONTEXT.

完成MockMvc

DispatcherServlet也创建好了,把DispatcherServletfilterServletContext都放在一起,创建出MockMvc实例(MockMvc为什么要拿到这些组件?因为没有servlet容器,它自己要调用servlet、filter的执行方法!):

1
    MockMvc mockMvc = new MockMvc(dispatcherServlet, filters);

怎么获取ServletContext?都有servlet了,自然就有ServletContext

1
    this.servletContext = servlet.getServletContext();

Servlet既能获取ServletContext,又能获取ServletConfig

  • ServletContext getServletContext()
  • ServletConfig getServletConfig()

最后MockMvc会设置这几个东西以方便对结果做出处理:

1
2
3
4
    		mockMvc.setDefaultRequest(defaultRequestBuilder);
    		mockMvc.setGlobalResultMatchers(globalResultMatchers);
    		mockMvc.setGlobalResultHandlers(globalResultHandlers);
    		mockMvc.setDefaultResponseCharacterEncoding(defaultResponseCharacterEncoding);

请求

先构造请求,再使用MockMvc处理请求。

构造请求

构造请求的时候,肯定涉及到url。使用UriComponentsBuilder生成uri的方法不错,学学

1
2
3
4
5
6
    	private static URI initUri(String url, Object[] vars) {
    		Assert.notNull(url, "'url' must not be null");
    		Assert.isTrue(url.startsWith("/") || url.startsWith("http://") || url.startsWith("https://"), "" +
    				"'url' should start with a path or be a complete HTTP URL: " + url);
    		return UriComponentsBuilder.fromUriString(url).buildAndExpand(vars).encode().toUri();
    	}

还可以调用urlencode,太方便了!

构造请求的时候,会判断请求url是否符合context path,不符合tomcat就处理不了,趁早报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    	private void updatePathRequestProperties(MockHttpServletRequest request, String requestUri) {
    		if (!requestUri.startsWith(this.contextPath)) {
    			throw new IllegalArgumentException(
    					"Request URI [" + requestUri + "] does not start with context path [" + this.contextPath + "]");
    		}
    		request.setContextPath(this.contextPath);
    		request.setServletPath(this.servletPath);

    		if ("".equals(this.pathInfo)) {
    			if (!requestUri.startsWith(this.contextPath + this.servletPath)) {
    				throw new IllegalArgumentException(
    						"Invalid servlet path [" + this.servletPath + "] for request URI [" + requestUri + "]");
    			}
    			String extraPath = requestUri.substring(this.contextPath.length() + this.servletPath.length());
    			this.pathInfo = (StringUtils.hasText(extraPath) ?
    					UrlPathHelper.defaultInstance.decodeRequestString(request, extraPath) : null);
    		}
    		request.setPathInfo(this.pathInfo);
    	}

因为context path设置的是"",所以必然符合。同样,还会校验servlet path。然后给url去掉context path,去掉servlet path,剩下的url作为mapping url,交给controller处理。处理之前会做urldecode。

所以模拟的还挺全,先给url encode了,再给它decode了。

处理请求 - perform()

构建完request之后,开始用MockMvc处理请求,得到结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
    	public ResultActions perform(RequestBuilder requestBuilder) throws Exception {
    		if (this.defaultRequestBuilder != null && requestBuilder instanceof Mergeable) {
    			requestBuilder = (RequestBuilder) ((Mergeable) requestBuilder).merge(this.defaultRequestBuilder);
    		}

    		MockHttpServletRequest request = requestBuilder.buildRequest(this.servletContext);

    		AsyncContext asyncContext = request.getAsyncContext();
    		MockHttpServletResponse mockResponse;
    		HttpServletResponse servletResponse;
    		if (asyncContext != null) {
    			servletResponse = (HttpServletResponse) asyncContext.getResponse();
    			mockResponse = unwrapResponseIfNecessary(servletResponse);
    		}
    		else {
    			mockResponse = new MockHttpServletResponse();
    			servletResponse = mockResponse;
    		}

    		if (this.defaultResponseCharacterEncoding != null) {
    			mockResponse.setDefaultCharacterEncoding(this.defaultResponseCharacterEncoding.name());
    		}

    		if (requestBuilder instanceof SmartRequestBuilder) {
    			request = ((SmartRequestBuilder) requestBuilder).postProcessRequest(request);
    		}

    		MvcResult mvcResult = new DefaultMvcResult(request, mockResponse);
    		request.setAttribute(MVC_RESULT_ATTRIBUTE, mvcResult);

    		RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
    		RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, servletResponse));

    		MockFilterChain filterChain = new MockFilterChain(this.servlet, this.filters);
    		filterChain.doFilter(request, servletResponse);

    		if (DispatcherType.ASYNC.equals(request.getDispatcherType()) &&
    				asyncContext != null && !request.isAsyncStarted()) {
    			asyncContext.complete();
    		}

    		applyDefaultResultActions(mvcResult);
    		RequestContextHolder.setRequestAttributes(previousAttributes);

    		return new ResultActions() {
    			@Override
    			public ResultActions andExpect(ResultMatcher matcher) throws Exception {
    				matcher.match(mvcResult);
    				return this;
    			}
    			@Override
    			public ResultActions andDo(ResultHandler handler) throws Exception {
    				handler.handle(mvcResult);
    				return this;
    			}
    			@Override
    			public MvcResult andReturn() {
    				return mvcResult;
    			}
    		};
    	}

MockMvc为什么能得到结果,它又不是servlet容器?谜底在这两行:

1
2
    		MockFilterChain filterChain = new MockFilterChain(this.servlet, this.filters);
    		filterChain.doFilter(request, servletResponse);

servlet(DispatcherServlet)被包装成一个filter,注册到filter chain的最后!然后执行这个filter chain的时候就执行了servlet的逻辑!所以MockMvc是在一个线程里调用了servlet的逻辑!!!单线程执行!!!

Registered filters are invoked through the MockFilterChain from spring-test, and the last filter delegates to the DispatcherServlet.

1
2
3
4
5
6
7
8
9
10
    	public MockFilterChain(Servlet servlet, Filter... filters) {
    		Assert.notNull(filters, "filters cannot be null");
    		Assert.noNullElements(filters, "filters cannot contain null values");
    		this.filters = initFilterList(servlet, filters);
    	}
    	
    	private static List<Filter> initFilterList(Servlet servlet, Filter... filters) {
    		Filter[] allFilters = ObjectUtils.addObjectToArray(filters, new ServletFilterProxy(servlet));
    		return Arrays.asList(allFilters);
    	}

这个被包装成的filter就是个servlet的wrapper,包装的方式也很直白,就是Servlet#service的逻辑放到了Filter#doFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    	/**
    	 * A filter that simply delegates to a Servlet.
    	 */
    	private static final class ServletFilterProxy implements Filter {

    		private final Servlet delegateServlet;

    		private ServletFilterProxy(Servlet servlet) {
    			Assert.notNull(servlet, "servlet cannot be null");
    			this.delegateServlet = servlet;
    		}

    		@Override
    		public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
    				throws IOException, ServletException {

    			this.delegateServlet.service(request, response);
    		}

    		@Override
    		public void init(FilterConfig filterConfig) throws ServletException {
    		}

    		@Override
    		public void destroy() {
    		}

    		@Override
    		public String toString() {
    			return this.delegateServlet.toString();
    		}
    	}

从代码来看,整个filter只执行了第一个,而不是foreach遍历,为什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    	@Override
    	public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
    		Assert.notNull(request, "Request must not be null");
    		Assert.notNull(response, "Response must not be null");
    		Assert.state(this.request == null, "This FilterChain has already been called!");

    		if (this.iterator == null) {
    			this.iterator = this.filters.iterator();
    		}

    		if (this.iterator.hasNext()) {
    			Filter nextFilter = this.iterator.next();
    			nextFilter.doFilter(request, response, this);
    		}

    		this.request = request;
    		this.response = response;
    	}

因为每一个filter处理逻辑的最后都要有filterChain.doFilter(servletRequest, servletResponse)这么一句话:

1
2
3
4
5
6
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
            var httpServletResponse = (HttpServletResponse) servletResponse;
            httpServletResponse.setHeader("X-SUPERHERO-APP", "super-header");
            filterChain.doFilter(servletRequest, servletResponse);
        }

上一个filter执行完后,如果请求符合条件,当前filter会主动触发filter chian的下一个filter,让请求继续执行下去。

如果不调用下一个filter,就起到了阻止请求处理的作用,请求就返回了(不调用filter,也不会调用最后的servlet,请求就相当于提前结束了!)。比如spring security,就是通过这个阻止那些验证不通过的请求的!

Either invoke the next entity in the chain using the FilterChain object (chain.doFilter()), or not pass on the request/response pair to the next entity in the filter chain to block the request processing

DispatcherServlet处理请求

DispatcherServlet处理请求的关键在于根据url mapping找到处理它的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
    	@Override
    	@Nullable
    	protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
    		String lookupPath = initLookupPath(request);
    		this.mappingRegistry.acquireReadLock();
    		try {
    			HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
    			return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
    		}
    		finally {
    			this.mappingRegistry.releaseReadLock();
    		}
    	}

registry还有读写锁。查的时候先获取read lock。

找到匹配的方法之后(这个方法就是handler啊!!!),就去设置handler execution chain了(其实就是加上HandlerInterceptor

后面的流程就不说了,都在SpringMVC:HTTP请求处理全流程里了。

结果处理

结果处理可以直接andExpect(),也可以通过andReturn()获取MvcResult,或者更进一步andReturn().getResponse(),获取HttpServletResponse

tomcat在哪儿?

没有tomcat!MockMvc的整个流程的重点其实就是构造出DispatcherServlet,之后手动运行Servlet#service获取结果

  1. 构造DispatcherServlet
    1. 构造ServletContext
    2. 构造root WebApplicationContext,填充mvc相关的bean
    3. 构造ServletConfig,创建DispatcherServlet,初始化servlet
  2. 获取请求
  3. 手动执行filter、DispatcherServlet#service
  4. 获取结果为MvcResult

servlet处理后的结果还被TestDispatcherServlet移花接木到了MvcResult上。所以整个流程里并不需要tomcat。

MockMvc为了绕过tomcat,煞费苦心了。

感想

MockMvc是测试SpringMVC的mvc层的重要手段,是spring test出的专门测web layer的非常方便的工具!大好评!

无心插柳柳成荫,本来是研究springboot test的,没想到通过spring test的MockMvc,更加深刻地理解了SpringMVC :D

本文由作者按照 CC BY 4.0 进行授权