filter를 단위테스트하려고 했는데 문제가 생겼다. Spring security를 적용했던 나는 filter가 filterchain으로 계속해서 연결되기 때문에 중간에 연결을 끊고 로직만을 어떻게 테스트 해야 할 지 몰랐다.
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
위 코드 메서드를 보면 받는 인자로 HttpServletRequest request, HttpServletResponse, FilterChain을 입력 받는다. 단위 테스트 이전에 이 3가지가 무슨 의미를 가지는지 먼저 분석해보자
Extends the ServletRequest interface to provide request information for HTTP servlets.
HttpServletRequest는 interface로 정의된 타입이고 Http servlet 요청 정보를 제공한다고 되어 있다. 이를 통해 요청한 URL, HTTP 메소드(GET, POST 등), 쿠키, 헤더 정보, 요청 파라미터 등의 정보를 받아 올 수 있다.
HttpServletResponse는 요청에 대한 응답을 생성하는 역할을 맡은 타입이다. 이 역시 interface로 정의되어 있다. 이를 활용하면 Http 프로토콜을 활용해 header, 쿠키 그 외에 응답들을 정의할 수 있다.
Filterchain은 연결된 다음 filter를 호출한다. 이 때 필터에서 적용된 HttpServletRequest와 HttpServletResponse을 그대로 전달한다.
내겐 servlet 요청, 응답 동작 과정에는 관심이 없다. 그 이유는 이는 원래 spring framework 에서 제공하는 기술이고 내가 테스트할 필요가 없기 떄문이다. 그러나 오버라이드하면서 정의한 doFilterInternal 메서드에서 내가 짠 커스텀 로직은 내 의도대로 동작하는 지 테스트할 필요가 있었다.
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String accessToken = parseJwtAccessToken(request);
if (accessToken != null && !jwtTokenService.isValidToken(accessToken)) {
SecurityContextHolder.clearContext();
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.sendError(HttpStatus.UNAUTHORIZED.value(), "invalid access token");
return;
}
if (accessToken != null && jwtTokenService.isValidToken(accessToken)) {
String memberId = jwtTokenService.getIdFromToken(accessToken);
Member member = memberService.findMember(new Id(memberId));
SecurityContextHolder.getContext().setAuthentication(makeToken(member));
}
filterChain.doFilter(request, response);
}
내가 정의한 Jwt 인증 필터는 3가지 조건을 만족해야 한다.
1번의 경우 인증 정보가 없다면 굳이 막을 필요가 없다. 그래서 dofilter를 호출할 수 있어야한다.
3번의 경우 Spring context에 도달하기 위해서는 인증 정보만 SecurityContextHolder에 담고 doFilter를 호출한다.
2번의 경우 더 이상 진행할 필요가 없다. 잘못된 입력이므로 응답 코드와 메시지를 작성하고 dofilter를 호출하면 안된다.
여기서 테스트시 반드시 확인해야 할 것이 있다. 실제 로직만을 분리하기 위해HttpServletRequest와 HttpServletResponse, FilterChain은 실제로 동작할 필요가 없다.
doFilter가 요구사항에 따라 다르게 동작하므로 doFilter가 실제로 동작했는지 확인이 필요하다.
doFilterInterval 메서드는 이 3개의 타입에 의존하고 있고 HttpServletRequest, HttpServletResponse, FliterChain 모두 인터페이스이다. 그 의미는 해당 타입의 인스턴스를 Fake 구현체로 생성하면 쉽게 해결할 수 있다는 것이다.
앞서 말한 3개의 타입은 우리가 구현해서 테스트할 필요가 없는 클래스이다. 그렇다면 이런 경우는 Fake 객체를 이용하는 방법보다는 Mockito 라이브러리를 이용해 가짜 객체를 생성하는 것이 편할 것이다. 내부적으로 프록시 패턴을 활용해서 프록시 객체로 테스트 하는 형식이기 때문에 interface나 클래스로 정의된 타입은 편하게 테스트 가능하다.(final class는 불가능)
@BeforeEach
void initialize() {
request = Mockito.mock(HttpServletRequest.class);
response = Mockito.mock(HttpServletResponse.class);
filterChain = Mockito.mock(FilterChain.class);
}
앞서 말한 3개의 의존 타입을 매 테스트 시작전 모두 mock으로 대체한다.
@Test
@DisplayName("해당 필터는 access token이 입력되지 않았다면 그냥 통과한다")
void shouldPassWhenNotInputAccessTokenTest() throws ServletException, IOException {
jwtAuthenticationFilter.doFilter(request, response, filterChain);
Mockito.verify(filterChain, Mockito.atLeastOnce()).doFilter(request, response);
}
첫번 째 테스트 코드이다. Mockito의 장점은 mocking 한 객체가 몇번 호출됬는지 검증이 가능하다. 위 같은 경우 목표는 Authorization header에 아무것도 없다면 그냥 통과되어야 한다.
@Test
@DisplayName("access token이 존재할 떄 옳바르지 않다면 response에 에러를 넣고 401상태를 응답하며 더 이상 필터를 통과시키지 않는다")
void shouldBlockAndErrorResponseWhenNotInputAccessTokenTest() throws ServletException, IOException {
String invalidAccessToken = "asa";
Mockito.doReturn("Bearer " + invalidAccessToken).when(request).getHeader("Authorization");
jwtAuthenticationFilter.doFilter(request, response, filterChain);
Mockito.verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Mockito.verify(filterChain, Mockito.never()).doFilter(request, response);
}
@Test
@DisplayName("access token이 정상적이라면 securityContextHolder에 authentication을 등록하고 필터를 통과시킨다")
void shouldPassBlockAndAddAuthenticationIntoSecurityContextWhenAccessTokenIsValidTest() throws
ServletException,
IOException {
String validToken = "asda1231aad";
Mockito.doReturn(true).when(jwtTokenService).isValidToken(validToken);
Mockito.doReturn(Id.generateNextId().toString()).when(jwtTokenService).getIdFromToken(validToken);
Mockito.doReturn("Bearer " + validToken).when(request).getHeader("Authorization");
jwtAuthenticationFilter.doFilter(request, response, filterChain);
Mockito.verify(filterChain, Mockito.atLeastOnce()).doFilter(request, response);
}