Controller Test와 적절한 Mocking 처리

mujik-tigers·2023년 12월 4일

on-and-off

목록 보기
1/2

REST Docs를 사용하여 API 문서를 생성하기로 결정하고, 다양한 구조의 컨트롤러 테스트를 시도했던 과정을 기록한 글입니다.

코드를 작성하기 시작하면서, 구조나 우선 순위에 대한 토의와 변경이 많았던 한 주였습니다.
그 중에서 컨트롤러 테스트 구조가 변화했던 과정을 소개합니다. 😊


(1) Mocking 없이 실제 모듈들이 협력하도록 하자!

첫 번째 시도는 모든 객체들이 잘 협력하는지 확인하는 통합 테스트였습니다.

클라이언트의 요청부터 인터셉터를 제대로 통과하는지, 개별 객체가 우리가 기대하는 응답을 생성하여 서로 잘 주고 받는지 등...
하나의 애플리케이션으로서 문제없이 동작하는지 확인하는 것에 목적을 두었습니다.

따라서 mocking 없이 Spring Boot Context를 바탕으로 실제 객체를 사용하여 테스트를 수행했습니다.


높은 커버리지 점수, 그러나

결과적으로 비교적 적은 양의 코드를 작성하고도 높은 커버리지 점수를 받을 수 있었습니다.
그러나 광범위한 범위를 포함하고 있다보니 테스트가 실패했을 때 어디에서 어떤 오류가 발생했는지, 문제를 직관적으로 파악하기 어렵다는 단점을 갖게 됩니다.

이를 보완하기 위해서 어떤 하나의 부품이 문제없이 동작함을 보장하는 단위 테스트가 필요함을 생각했고, 컨트롤러 라는 관심사를 큰 덩어리에서 분리하게 됩니다.


(2) 컨트롤러 단위 테스트는 어때? Service는 Mocking!

이어진 두 번째 시도는 컨트롤러 가 제대로 동작하는지 확인하는 단위 테스트입니다.

예상한대로 컨트롤러가 서비스에서 전달받은 결과를 갖는 응답을 반환하는지, 요청에 따른 컨트롤러 매핑이 잘 되는지 등
우리가 결정한 관심사인 컨트롤러 라는 하나의 부품이 문제없이 동작하는지 확인하는 것에 목적을 두었습니다.

따라서 컨트롤러를 제외한 인터셉터나 서비스와 같은 객체들은 모두 mocking 처리를 해주었습니다.

실제 코드는 다음과 같이 작성했습니다.

@ExtendWith(RestDocumentationExtension.class)
public abstract class RestDocsSupport {

	protected MockMvc mockMvc;
	protected ObjectMapper objectMapper = new ObjectMapper();
	private final AccessTokenInterceptor accessTokenInterceptor = mock(AccessTokenInterceptor.class);
	private final RefreshTokenInterceptor refreshTokenInterceptor = mock(RefreshTokenInterceptor.class);

	@BeforeEach
	void setUp(RestDocumentationContextProvider provider) throws Exception {
		this.mockMvc = MockMvcBuilders.standaloneSetup(initController())
			.setControllerAdvice(GlobalExceptionHandler.class)
			.addInterceptors(accessTokenInterceptor, refreshTokenInterceptor)
			.apply(MockMvcRestDocumentation.documentationConfiguration(provider))
			.build();

		given(accessTokenInterceptor.preHandle(any(), any(), any()))
			.willReturn(true);
		given(refreshTokenInterceptor.preHandle(any(), any(), any()))
			.willReturn(true);
	}

	protected abstract Object initController();

}
class AuthControllerTest extends RestDocsSupport {

	private final AuthService authService = mock(AuthService.class);

	@Override
	protected Object initController() {
		return new AuthController(authService);
	}

	@Test
	@DisplayName("일반 로그인 : 성공")
	void loginSuccess() throws Exception {
		// given
		LoginForm loginForm = new LoginForm("yeon@email.com", "test!1234");

		given(authService.login(any(LoginForm.class)))
			.willReturn(new AuthenticationTokenPair("accessToken", "refreshToken"));
        
        // when & then
        .
        .
        .

보다 더 의미있는 테스트를 위해

결과적으로 덜어낸 만큼 빠르고 직관적인 컨트롤러 단위 테스트가 되었고,
자연스럽게 컨트롤러 외의 개별 객체들도 자신의 역할을 제대로 수행하는지 검증하는 작은 단위의 테스트를 갖게 되었습니다.

그러나 주변 환경을 전부 mocking 처리함으로써, 인터셉터에서 Authorization Header 를 확인하던 과정이 생략되는 문제점을 발견하게 됩니다.

개발자가 실수로 Authorization HeaderBearer [token] 을 설정하지 않고 REST Docs 문서를 생성해도 오류가 발생하지 않으므로 스스로 주의해야 할 포인트가 하나 늘어나게 되었습니다.

	// header 설정 예시
	...
    
	// when & then
	mockMvc.perform(post("/reissue")
		.header(HttpHeaders.AUTHORIZATION, "Bearer refreshToken"))
    .
    .
    .

이를 해결하기 위해 mocking 처리한 인터셉터에 .will() 을 사용하여 임의로 헤더를 검사하는 코드를 추가할 수 있지만,
개발자가 놓친 부분이 더 있을 가능성을 고려하여 필요한 부분만 mocking 처리하는 것을 선택했습니다.

또한 일반적으로 앞단까지 포함하여 컨트롤러의 동작을 예상하기 때문에, 실제 애플리케이션에 가까운 컨트롤러 테스트로 개선이 필요함을 고려했습니다.

이어진 마지막 시도에서는 요청이 컨트롤러에 오기까지의 과정을 하나의 단위로 설정하게 됩니다.


(3) 컨트롤러와 밀접한 환경도 고려하는게 좋겠어!

마지막 시도는, 관련된 context를 바탕으로 컨트롤러 테스트를 수행하는 보다 실용적인 단위 테스트입니다.

@WebMvcTest 의 설명을 보면, 다음의 내용을 확인할 수 있습니다.

... 'focuses only on Spring MVC componets.'
... (i.e. @Controller, @ControllerAdvice, @JsonComponent, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans not @Component, @Service or @Repository beans.).

  • Spring MVC componets 에 집중한 테스트이다.
  • 테스트 관심사와 관련된 빈들만 설정할 것이다.
  • 따라서 @Controller , @ControllerAdvice , @JsonComponent , Filter , WebMvcConfigurer , HandlerMethodArgumentResolver 와 같은 빈들은 등록되지만 @Component , @Service 또는 @Repository 는 등록되지 않을 것이다.

즉, 저희가 원했던 것처럼 컨트롤러와 그 앞단까지의 환경을 바탕으로 테스트를 수행할 수 있게 도와주는 설정입니다.


@WebMvcTest 를 사용하여 수정한 실제 코드는 다음과 같습니다.

@WebMvcTest(controllers = {AuthController.class, MemberController.class})
@AutoConfigureRestDocs
public abstract class RestDocsSupport {

	@Autowired
	protected MockMvc mockMvc;

	@Autowired
	protected ObjectMapper objectMapper;
    
    @MockBean
	protected AuthService authService;
    
    @MockBean
	protected AuthManager authManager;
    
    @MockBean
	protected TokenManager tokenManager;
    
    ...
    
    @BeforeEach
	void setUp() {
		given(tokenManager.validateRefreshToken(any()))
			.willReturn(Jwts.claims().add("id", 1L).build());
		given(tokenManager.validateAccessToken(any()))
			.willReturn(Jwts.claims().add("id", 1L).build());
            
        given(authManager.validateAuthorizationHeader(any()))
        	.willCallRealMethod();
	}

}

참고 : 인터셉터 코드

@Component
@RequiredArgsConstructor
public class AccessTokenInterceptor implements HandlerInterceptor {

	private static final String AUTHORIZATION_HEADER = "Authorization";
	public static final String ACCOUNT_ID = "id";

	private final AuthManager authManager;
	private final TokenManager tokenManager;

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
		throws Exception {
		if (CorsUtils.isPreFlightRequest(request))
			return true;

		String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
		String token = authManager.validateAuthorizationHeader(authorizationHeader);

		Claims claims = tokenManager.validateAccessToken(token);

		request.setAttribute(ACCOUNT_ID, claims.get(ACCOUNT_ID, Long.class));
		return true;
	}

}

인터셉터에서 tokenManager 를 사용하여 토큰을 검증하는 부분만 mocking 처리하고,
헤더를 검증하거나 request.setAttribute() 하여 resolver에게 넘겨주는 부분은 정상적으로 동작하도록 했습니다.

위와 같은 구조는 어떤 부분이 mocking 되었는지 직관적으로 알 수 있으며, 다른 동작은 모두 정상적으로 수행될 것임을 예상할 수 있다는 장점을 갖습니다.

이전에 Authorization Header 에 값을 설정하지 않거나 Bearer 에 오타가 생겨도 오류를 발생하지 않았던 문제점 또한 해결되었습니다.

최종적으로, 현재 프로젝트에서는 위와 같은 구조를 유지하며 컨트롤러 테스트를 작성하고 있습니다.


Interceptor에서 false를 반환하면?

덧붙여, 두 번째 시도에서 Authorization Header 를 검증하기 위해 임의로 will() 메서드를 사용했던 경험을 공유하고 포스팅을 마무리하려고 합니다.

해당 시점에서 모든 인터셉터를 mocking 처리하여 사용하고 있었고,

	...
    
    // 수정 전 코드
	given(accessTokenInterceptor.preHandle(any(), any(), any()))
			.willReturn(true);
    given(refreshTokenInterceptor.preHandle(any(), any(), any()))
			.willReturn(true);

Authorization Header 를 검증하기 위해 코드를 수정했습니다.

	...
    
    // 수정 후 코드
	given(accessTokenInterceptor.preHandle(any(HttpServletRequest.class), any(), any()))
    	.will((invocation) -> {
    		HttpServletRequest request = invocation.getArgument(0);
			return authManager.validateAuthorizationHeader(
        	request.getHeader(HttpHeaders.AUTHORIZATION)) != null;
		});
	given(refreshTokenInterceptor.preHandle(any(HttpServletRequest.class), any(), any()))
    	.will((invocation) -> {
			HttpServletRequest request = invocation.getArgument(0);
			return authManager.validateAuthorizationHeader(
        	request.getHeader(HttpHeaders.AUTHORIZATION)) != null;
		});

수정한 테스트 코드를 실행하니, 해당 인터셉터를 거치는 모든 요청은 다음과 같이 응답하고 테스트가 통과되지 않았습니다.

200 OK 라고 하지만 아무 데이터가 없는 빈 성공 응답

잘못되었다는 응답이 아니므로 어디서 무엇이 제대로 동작하지 않았는지 파악하기 어려웠으나,
디버깅 결과 mocking 처리한 authManager 를 간과하고 사용한 실수가 원인이었고 authManager 는 항상 null 을 반환했던 것이었습니다.


따라서 인터셉터는 항상 false 를 반환했을텐데 우리는 왜 200 OK 라는 답변을 받았던 걸까요?

Spring 공식 문서에 따르면, preHandle() 메서드가 false 를 반환하면 DispatcherServlet인터셉터가 요청을 알아서 처리한 것으로 간주한다고 합니다.

즉, DispatcherServlet 은 인터셉터에서 false 를 반환해도 요청을 알아서 처리했다고 생각했고 response 에 상태 코드를 지정해주지 않았기 때문에 기본값인 200 OK 를 응답했던 것입니다.


마무리

이로써 @SpringBootTest 를 사용한 통합 테스트에서, 서버를 올리지 않는 빠른 단위 테스트를 거쳐, @WebMvcTest 를 사용하여 관련된 context를 바탕으로 컨트롤러를 테스트하는 구조까지 오게 된 과정을 모두 소개했습니다.

더 나은 구조를 고민하면서 낯선 mocking 기술도 시도해보고, 제대로 이해하지 못했던 동작 과정을 더 살펴보게 됐던 의미있는 시간이었습니다.

긴 글 읽어주셔서 감사합니다 🙂


작성자 : 김서연

profile
mujik-tigers 프로젝트 블로그

0개의 댓글