Post

SpringMVC - MockMvc, @WebMvcTest코드까보기

real-world project 구현 과정 카테고리 보기

개요

테스트 코드를 짜면서 어쩌다보니 쓰고 있는 이 클래스, 어노테이션들 정리할 필요를 느꼈다.

😎 MockMvc, @WebMvcTest

@WebMvcTest어노테이션을 알기 전 MockMvc에 대해 알고 있어야한다. @WebMvcTest 문서를 보면 MockMvc에 대한 언급이 나오기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@WebMvcTest(controllers = UsersController.class)
class UsersControllerTest {

    void signup(UserSignupRequest userSignupRequest) throws Exception {
        @Autowired
        MockMvc mockMvc;

        ...
        //when , then
        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(userSignupRequest))
                        .with(csrf())
                )

        ...
    }

    ...
}

이런식으로 사용하고 있는 mockMvc

mockMvc는 컨트롤러에 가짜 HTTP요청을 보낸다.

Spring은 DispatcherServlet이라는 실제 웹 요청을 처리하는 서블릿을 가지고 있는데 mockMvc는 DispatcherServlet을 모방하여 요청을 보낸다.

이러한 가짜 서블릿을 통해 실제 웹 서버가 뜨지 않고도 컨트롤러에 요청을 보낼 수 있어서 테스트가 가능하다.

단점으로는 실제 웹 서버와 다르게 동작할 수 있어서 정확한 테스트가 어려울 수도 있다는 것이다.

죽 mockMvc는

  1. 컨트롤러와 관련된 정보를 가지고 가짜 HTTP 요청 생성을 한다.
  2. 가짜 HTTP 요청을 컨트롤러에 전달한다. 이 요청은 DispatcherServlet을 모방한 방식대로 처리됨. 요청한 컨트롤러를 찾고, 요청을 전달한다.
  3. 컨트롤러의 응답을 검증한다. 이는 .andExpect()같은 함수를 통해 검증이 가능하다.

그러면 MockMvc는 어떻게 가짜 servlet을 만들 수 있는걸까?

그러면 다시 @WebMvcTest에 대해 간단히 언급해야한다.

이는 @SpringBootApplication가 자동설정을 도와주듯 해당 어노테이션도 테스트에 필요한 설정들을 자동화해준다고 현재는 이해하자.

아무튼 @WebMvcTest 코드를 보면

1
2
3
4
5
@Bean
@ConditionalOnMissingBean
public MockMvc mockMvc(MockMvcBuilder builder) {
    return builder.build();
}

@ConditionalOnMissingBean는 이미 등록된 빈이 없을때 새로 만들어준다는 코드

라는 코드가 있다. 빈으로 등록하는 코드이다. 파라미터로 들어온 builder에는 웹 컨텍스트의 정보가 들어있다고 알고 있자.

아무튼 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
@Override
@SuppressWarnings("rawtypes")
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);
}

요약하자면 테스트용 서블릿 컨텍스트가 있는데, 이에 저장된 필터, 설정들을 실제 서블릿 컨텍스트에 넣는다.

그리고 createMockMvc()을 리턴하기에 이 코드도 까봐야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected final MockMvc createMockMvc(Filter[] filters, MockServletConfig servletConfig,
        WebApplicationContext webAppContext, @Nullable RequestBuilder defaultRequestBuilder,
        List<ResultMatcher> globalResultMatchers, List<ResultHandler> globalResultHandlers,
        @Nullable List<DispatcherServletCustomizer> dispatcherServletCustomizers) {

    TestDispatcherServlet dispatcherServlet = new TestDispatcherServlet(webAppContext);
    
    ...

    try {
        dispatcherServlet.init(servletConfig);
    }
    catch (ServletException ex) {
        // should never happen..
        throw new MockMvcBuildException("Failed to initialize TestDispatcherServlet", ex);
    }

    MockMvc mockMvc = new MockMvc(dispatcherServlet, filters);
    mockMvc.setDefaultRequest(defaultRequestBuilder);
    mockMvc.setGlobalResultMatchers(globalResultMatchers);
    mockMvc.setGlobalResultHandlers(globalResultHandlers);

    return mockMvc;
}

보아하니, 테스트용 DispatcherServlet을 만들고 MockMvc객체를 생성할 때 넣는다.

new MockMvc(dispatcherServlet, filters)을 보면

1
2
3
4
5
6
7
8
9
MockMvc(TestDispatcherServlet servlet, Filter... filters) {
    Assert.notNull(servlet, "DispatcherServlet is required");
    Assert.notNull(filters, "Filters cannot be null");
    Assert.noNullElements(filters, "Filters cannot contain null values");

    this.servlet = servlet;
    this.filters = filters;
    this.servletContext = servlet.getServletContext();
}

이렇게 되어있다. 즉, MockMvc는 빈에 등록되어있고, 테스트용 DispatcherServlet과 설정값들을 가지고 있다.

그러면 실제 MockMvc인스턴스의 함수를 호출하는 곳을 보자.

1
2
3
4
5
6
mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(userSignupRequest))
                        .with(csrf())
                )
                .andExpect(MockMvcResultMatchers.status().isOk())

이렇게 구현해놨는데, .perform()은 어떻게 수행되는걸까??

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
62
63
public ResultActions perform(RequestBuilder requestBuilder) throws Exception {

        ...

        //1. 테스트용 HttpServletRequest 객체에 값을 넣음.
		MockHttpServletRequest request = requestBuilder.buildRequest(this.servletContext);

        //2. 비동기 서블릿 컨텍스트 호출 => null
		AsyncContext asyncContext = request.getAsyncContext();
		MockHttpServletResponse mockResponse;
		HttpServletResponse servletResponse;
		if (asyncContext != null) {
            ...
		}
        //3. null이므로 새로운 테스트용 HttpServletResponse 객체를 만듦. 이때는 빈 값이다. status도 200이고 response의 내용물은 비어있다.
		else {
			mockResponse = new MockHttpServletResponse();
			servletResponse = mockResponse;
		}

		
        //4. 인풋 헤더를 검증하는 부분같은데 잘 이해 못했습니다.
		if (requestBuilder instanceof SmartRequestBuilder) {
			request = ((SmartRequestBuilder) requestBuilder).postProcessRequest(request);
		}

        //5. mvcResult는 mockRequest,와 mockResponse를 가지고 있다.
		MvcResult mvcResult = new DefaultMvcResult(request, mockResponse);
        //6. 리퀘스트 속성에 이값을 키-값 형태로 넣는다.
		request.setAttribute(MVC_RESULT_ATTRIBUTE, mvcResult);

        ...

		
        //7. 테스트용 필터를 생성하는데, 이 값에는 기본 필터들과 사용자가 정의한 필터(Jwt 관련)가 들어있다. 또한 인자로 들어간 테스트용 서블릿객체도 들어가 있다.
		MockFilterChain filterChain = new MockFilterChain(this.servlet, this.filters);
        //8. 실질적으로 요청을 보내는 부분이다. 들어온 필터를 도는데 결국 마지막에 들어간 서블릿객체의 service()도 호출하여 실질적으로 가짜HTTP요청이 MockMvc가 만든 서블릿객체에 들어간다.
		filterChain.doFilter(request, servletResponse);

        ...

        //9. 결국 요청에 대한 응답을 받는다.
		applyDefaultResultActions(mvcResult);
		RequestContextHolder.setRequestAttributes(previousAttributes);

        //10. andExpect()처럼 후처리.
		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;
			}
		};
	}

참고로

파라미터로 들어온 requestBuilder는 이런식으로 저장되어있다.

image


쓰다말다 쓰다말다해서 내용이 좀 난잡하다. 참고만 해야할것 같다.

This post is licensed under CC BY 4.0 by the author.