내가 현재까지 정의 내린 Controller 테스트는 요청에 대한 응답이 제대로 반환되는가를 테스트하는 것이다.

조금 더 구체적으로 말하면, @WebMvcTest 어노테이션을 통해 응답과 요청에 필요한 Bean들만 로딩하여 가볍게 테스트하는 것을 말한다.

그런데, Controller 테스트를 작성하면서 Security 가 포함되면 어떻게 처리해야 하는가?’에 대한 고민이 생겼다.

방법을 크게 3가지 정도로 압축해봤다.

크게, Security를 아예 제외하는 방법과 포함시켜서 진행하는 방법이 있고, Security를 포함시키는 방법에서 또 2가지로 나눌 수 있었다.

처음엔, @WithMockUser, @WithAnonymousUser 어노테이션을 이용해서 기본 Security 설정값을 포함시키면 되는 것이 아닌가라는 생각을 했다.

그런데, 아래에서 설명하겠지만 인증에 대한 테스트를 진행할 때, 어노테이션을 쓰게 되면 어노테이션 기능으로 인해 인증에 대한 조건을 이상하게 넣거나 넣지 않아도 테스트가 통과되는 것을 발견했다.

이를 해결하기 위해, 커스텀 어노테이션을 사용해서 직접 인증에 대한 조건을 설정해주는 등의 방법을 시도했으나 근본적으로 인증에 대한 조건이 어떻든 간에 인증을 통과시키는 기능을 하기에 해결할 수 없었다.

이와 관련하여 또, Security를 포함시키는 순간, 내가 작성한 Security 설정들이 테스트에 전혀 반영되지 않았다.

이게 무슨 뜻이나면, 나의 경우 jwt 토큰이 부적절하게 들어올 때의 에러처리 필터와 jwt 토큰이 아예 들어오지 않았을 경우의 에러처리도 해주었는데, 그런 부분들이 제대로 반영되지 않았다는 뜻이다.

이런 문제들의 해결은 @WebMvcTest 어노테이션이 도대체 어떤 Bean들을 로딩하는지를 확인하는 부분부터 시작했다.

@Controller,
@ControllerAdvice,
@JsonComponent,
Converter / GenericConverter,
Filter,
WebSecurityConfigurerAdapter,
WebMvcConfigurer,
HandlerMethodArgumentResolver

@WebMvcTest는 위와 같은 어노테이션이 붙은 Bean을 로딩한다.

여기서 WebSecurityConfigurerAdapter이 중요하다.

Spring Security에 대한 설정 또한 스캔 대상에 들어간다는 의미다.

그렇다면, 스캔 대상인데 왜 내가 만든 필터들이 제대로 반영되지 않았는가에 대한 의문이 생길 수 있다.
이유인 즉슨, Spring Security 관련된 컴포넌트를 기본 설정으로 자동 구성하기 때문이다.

WebSecurityConfigurerAdapter 클래스 코드를 살펴보면 다음과 같은 부분이 있다.

image

이런 기본 설정된 컴포넌트를 스캔하기 때문에 아래의 내 Security 설정정보의

csrf().diable(),
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint()),
.addFilterBefore(new JwtTokenFilter(secretKey), UsernamePasswordAuthenticationFilter.class),
.addFilterBefore(new JwtTokenExceptionFilter(),JwtTokenFilter.class)

등의 내가 구성한 설정들이 반영되지 못했던 것이었다.

또한 이런 상황에서, @WithMockUser, @WithAnonymousUser 어노테이션을 붙여 테스트를 진행하니 내가 원하는 방향으로 제대로 테스트가 되지 않았던 것이었다.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    return httpSecurity
            .httpBasic().disable() //rest api 이므로 기본설정 사용안함. 기본설정은 비인증시 로그인폼 화면으로 리다이렉트
            .csrf().disable()      //rest api 이므로 csrf 보안이 필요없음
            .cors()
            .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) //jwt token으로 인증시 세션 필요없으므로 생성안함
            .and()
                .authorizeRequests()
                .antMatchers("/api/v1/users/login","/api/v1/users/join", "/swagger-ui").permitAll() // join, login 은 언제나 가능
                .antMatchers(HttpMethod.GET,"/api/v1/**").permitAll()   // 모든 get 요청 허용
                .antMatchers(HttpMethod.POST,"/api/v1/**").authenticated()  // 순서대로 적용이 되기 때문에 join, login 다음에 써주기
                .antMatchers(HttpMethod.PUT, "/api/v1/**").authenticated()
                .antMatchers(HttpMethod.DELETE, "/api/v1/**").authenticated()
            .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())// 토큰 없을 시 에러 처리
            .and()
                .addFilterBefore(new JwtTokenFilter(secretKey), UsernamePasswordAuthenticationFilter.class) // UserNamePasswordAuthenticationFilter 적용하기 전에 JwtTokenFilter 적용한다는 의미
                .addFilterBefore(new JwtTokenExceptionFilter(),JwtTokenFilter.class)    // 인증
            .build();
}


해결방법은 무엇일까????

위에서 말한 Security를 포함시키는 방법에서 남은 1가지 방법이 바로 이것과 관련이 있다.

바로, 직접 작성한 WebSecurityConfig까지 포함시키기 위해 커스텀 WebMvcTest 어노테이션을 만들어 Security 설정들을 포함시켜주는 방법이다.

이 과정을 바로 보여주면 아주 좋겠지만, 나는 단계별로 처음 내가 작성했던 이상한 테스트 코드부터 시작해보겠다.

3가지 경우를 발전시키면서 보여줄 것이고,
각각의 상황에서 테스트할 것은 인증된 회원여부와 상관없이 1.게시글을 조회하는 기능의 성공 테스트 와, 인증된 회원만이 가능한 2.게시글 등록 기능의 성공 테스트3.인증되지 않아서 실패하는 테스트 를 보여줄 것이다.

1. @WithMockUser, @WithAnonymousUser 사용


1-1. 게시글 조회 성공 테스트

@WebAppConfiguration
@WebMvcTest(PostApiController.class)
class PostApiControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @MockBean
    PostService postService;

    private final PostDetailResponse postDetailResponse = PostDetailResponse.builder().id(1).title("title").body("body").userName("userName").build();


    @Test
    @DisplayName("포스트 상세 조회 성공")
    @WithMockUser
    public void postdetail_success() throws Exception {
        Integer postsId = 1;
        given(postService.findPost(postsId)).willReturn(postDetailResponse);

        mockMvc.perform(get("/api/v1/posts/" + postsId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postDetailResponse)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result.id").value(1))
                .andExpect(jsonPath("$.result.title").value("title"))
                .andExpect(jsonPath("$.result.body").value("body"))
                .andDo(print());

        verify(postService,times(1)).findPost(postsId);
    }
}

포스트 조회 기능은 인증된 회원의 여부와 관계없이 통과해야 한다.

🚫 그런데, @WithMockUser 어노테이션이 없으면 통과하지 않는다.

어노테이션이 없거나 @WithAnonymousUser 사용 시 다음과 같은 에러가 난다.

분명히 정확히 작동하는 테스트가 아니다…

image

1-2. 게시글 등록 성공 테스트

@WebAppConfiguration
@WebMvcTest(PostApiController.class)
class PostApiControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @MockBean
    PostService postService;

    private final PostCreateRequest postCreateRequest = new PostCreateRequest("제목", "내용");
    private final PostCreateResponse postCreateResponse = new PostCreateResponse(1, "포스트 등록 완료");

    @Test
    @DisplayName("포스트 등록 성공")
    @WithMockUser
    public void post_create_success() throws Exception {

        given(postService.createPost(any(),any())).willReturn(postCreateResponse);

        mockMvc.perform(post("/api/v1/posts")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postCreateRequest)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.resultCode").value("SUCCESS"))
                .andExpect(jsonPath("$.result.postId").value(1))
                .andExpect(jsonPath("$.result.message").value("포스트 등록 완료"))
                .andDo(print());

        verify(postService,times(1)).createPost(any(),any());
    }
}

🚫 나는 SecurityConfig에서 분명히 .csrf().disable()설정을 했는데도 불구하고, with(csrf())가 없으면 다음과 같이 테스트가 통과하지 않는다.

image

🚫.with(csrf())가 있어도, 토큰이 없거나 토큰을 이상하게 넣었는데도 통과한다.

즉, .header(HttpHeaders.AUTHORIZATION,"Bearer " + token)이 없거나 이상하게 넣어도 통과한다.

image

image

이것 또한, 정확하지 않은 테스트이다…

1-3. 게시글 등록 인증 실패 테스트

@WebAppConfiguration
@WebMvcTest(PostApiController.class)
class PostApiControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @MockBean
    PostService postService;

    private final PostCreateRequest postCreateRequest = new PostCreateRequest("제목", "내용");
    private final PostCreateResponse postCreateResponse = new PostCreateResponse(1, "포스트 등록 완료");

    @Test
    @DisplayName("포스트 작성 실패(1) - 인증 실패 (Bearer 토큰으로 보내지 않은 경우)")
    public void post_create_fail1() throws Exception {

        String token = JwtTokenUtil.createToken("user", secretKey, 1000 * 60 * 60L);



        mockMvc.perform(post("/api/v1/posts")
                        .with(csrf())
                        .header(HttpHeaders.AUTHORIZATION,"Basic " + token) 
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postCreateRequest)))
                .andExpect(status().is(INVALID_TOKEN.getStatus().value()))
                .andDo(print());
    }
}

Bearer 토큰으로 보내지 않았을 때 INVALID_TOKEN 에러가 반환되는지를 확인하는 테스트이다.

🚫 이 또한, with(csrf())가 없으면 다음과 같이 테스트가 통과하지 않는다.

image

🚫.with(csrf())가 있어도, Bearer으로 시작하면 테스트가 통과되지 않아야 하는데 통과된다.

.header(HttpHeaders.AUTHORIZATION,"Bearer " + token) 

image

이것 또한, 정확하지 않은 테스트이다…

2. @Import(SecurityConfig.class) 사용


테스트 클래스 맨위에 @Import(SecurityConfig.class)을 붙인다.

2-1. 게시글 조회 성공 테스트

@WebAppConfiguration
@WebMvcTest(PostApiController.class)
@Import(SecurityConfig.class)
class PostApiControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @MockBean
    PostService postService;

    private final PostDetailResponse postDetailResponse = PostDetailResponse.builder().id(1).title("title").body("body").userName("userName").build();

    @Test
    @DisplayName("포스트 상세 조회 성공")
    @WithMockUser
    public void postdetail_success() throws Exception {
        Integer postsId = 1;
        given(postService.findPost(postsId)).willReturn(postDetailResponse);

        mockMvc.perform(get("/api/v1/posts/" + postsId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postDetailResponse)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result.id").value(1))
                .andExpect(jsonPath("$.result.title").value("title"))
                .andExpect(jsonPath("$.result.body").value("body"))
                .andDo(print());

        verify(postService,times(1)).findPost(postsId);
    }
}

✅ 이제, @WithMockUser 어노테이션이 없어도 통과한다. 즉, 인증된 회원의 여부와 관계없이 통과한다.

✅ 또한, .with(csrf())가 없어도 테스트가 통과한다. @Import(SecurityConfig.class)를 통해, 내가 작성한 Security 설정 정보가 테스트에 추가되었기 때문에 가능한 일이다.

2-2. 게시글 등록 성공 테스트

@WebAppConfiguration
@WebMvcTest(PostApiController.class)
@Import(SecurityConfig.class)
class PostApiControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @MockBean
    PostService postService;
    @Autowired
    WebApplicationContext context;
    @Value("${jwt.token.secret}") String secretKey;

    private final PostCreateRequest postCreateRequest = new PostCreateRequest("제목", "내용");
    private final PostCreateResponse postCreateResponse = new PostCreateResponse(1, "포스트 등록 완료");

    @Test
    @DisplayName("포스트 등록 성공")
    public void post_create_success() throws Exception {

        String token = JwtTokenUtil.createToken("userName", secretKey, 1000 * 60 * 60L);

        given(postService.createPost(any(),any())).willReturn(postCreateResponse);

        mockMvc.perform(post("/api/v1/posts")
                        .header(HttpHeaders.AUTHORIZATION,"Bearer " + token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postCreateRequest)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.resultCode").value("SUCCESS"))
                .andExpect(jsonPath("$.result.postId").value(1))
                .andExpect(jsonPath("$.result.message").value("포스트 등록 완료"))
                .andDo(print());

        verify(postService,times(1)).createPost(any(),any());
    }
}

✅ 이제 @WithMockUser 어노테이션이 없어도 통과한다. 그 이유는 직접 토큰을 생성해서 인증 절차를 진행했기 때문이다. 보다 정확한 테스트라고 할 수 있다.

🚫 이제 토큰을 위와 같이 제대로 넣지 않을 경우, 게시글 등록 성공 테스트는 다음과 같이 통과하지 않는다.

image

✅ 또한, .with(csrf())가 없어도 테스트가 통과한다. 이것 또한, @Import(SecurityConfig.class)를 통해, 내가 작성한 Security 설정 정보가 테스트에 추가되었기 때문에 가능한 일이다.

2-3. 게시글 등록 인증 실패 테스트

@WebAppConfiguration
@WebMvcTest(PostApiController.class)
@Import(SecurityConfig.class)
class PostApiControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @MockBean
    PostService postService;
    @Autowired
    WebApplicationContext context;
    @Value("${jwt.token.secret}") String secretKey;

    private final PostCreateRequest postCreateRequest = new PostCreateRequest("제목", "내용");
    private final PostCreateResponse postCreateResponse = new PostCreateResponse(1, "포스트 등록 완료");

    @Test
    @DisplayName("포스트 작성 실패(1) - 인증 실패 (Bearer 토큰으로 보내지 않은 경우)")
    public void post_create_fail1() throws Exception {

        String token = JwtTokenUtil.createToken("user", secretKey, 1000 * 60 * 60L);

        mockMvc.perform(post("/api/v1/posts")
                        .header(HttpHeaders.AUTHORIZATION,"Basic " + token) 
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postCreateRequest)))
                .andExpect(status().is(INVALID_TOKEN.getStatus().value()))
                .andDo(print());
    }
}

Bearer 토큰으로 보내지 않았을 때 INVALID_TOKEN 에러가 반환되는지를 확인하는 테스트이다.

✅ Bearer 토큰으로 보내지 않을 시 내가 작성한 CustomAuthenticationEntryPoint.class가 작동하여 인증 실패 테스트가 성공한다. image

✅ 마찬가지로, .with(csrf())가 없어도 테스트가 통과한다. 이것 또한, @Import(SecurityConfig.class)를 통해, 내가 작성한 Security 설정 정보가 테스트에 추가되었기 때문에 가능한 일이다.

3. 커스텀 WebMvcTest 어노테이션 사용


마지막 과정은 기존의 @WebMvcTest어노테이션과 @Import(SecurityConfig.class)을 합친 커스텀 어노테이션을 만들어서 사용한다.

@WebMvcTestSecurity를 만들어 똑같이 작동하도록 해보겠다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@WebMvcTest
@ImportAutoConfiguration(SecurityConfig.class)
public @interface WebMvcTestSecurity {
    @AliasFor(annotation = WebMvcTest.class, attribute = "value")
    Class<?>[] value() default {};
}

다음과 같이 @WebMvcTest 기본 설정에 내가 작성한 Security 설정을 추가하도록
@ImportAutoConfiguration(SecurityConfig.class)를 붙인다.

앞에서 @Import(SecurityConfig.class)의 기능과 똑같은 역할을 한다.

이제, 테스트 클래스 맨위에 커스텀 어노테이션을 붙인다.

3-1. 게시글 조회 성공 테스트

@WebAppConfiguration
@WebMvcTestSecurity(value = PostApiController.class)
class PostApiControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @MockBean
    PostService postService;

    private final PostDetailResponse postDetailResponse = PostDetailResponse.builder().id(1).title("title").body("body").userName("userName").build();

    @Test
    @DisplayName("포스트 상세 조회 성공")
    public void postdetail_success() throws Exception {
        Integer postsId = 1;
        given(postService.findPost(postsId)).willReturn(postDetailResponse);

        mockMvc.perform(get("/api/v1/posts/" + postsId)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postDetailResponse)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.result.id").value(1))
                .andExpect(jsonPath("$.result.title").value("title"))
                .andExpect(jsonPath("$.result.body").value("body"))
                .andDo(print());

        verify(postService,times(1)).findPost(postsId);
    }
}

✅ 마찬가지로, @WithMockUser 어노테이션이 없어도 통과한다. 즉, 인증된 회원의 여부와 관계없이 통과한다.

.with(csrf())가 없어도 테스트가 통과한다.

✅ 내가 작성한 Security 설정 정보가 커스텀 어노테이션 @WebMvcTestSecurity를 통해 테스트에 추가되었기 때문에 가능한 일이다.

3-2. 게시글 등록 성공 테스트

@WebAppConfiguration
@WebMvcTestSecurity(value = PostApiController.class)
class PostApiControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @MockBean
    PostService postService;
    @Autowired
    WebApplicationContext context;
    @Value("${jwt.token.secret}") String secretKey;

    private final PostCreateRequest postCreateRequest = new PostCreateRequest("제목", "내용");
    private final PostCreateResponse postCreateResponse = new PostCreateResponse(1, "포스트 등록 완료");

    @Test
    @DisplayName("포스트 등록 성공")
    public void post_create_success() throws Exception {

        String token = JwtTokenUtil.createToken("userName", secretKey, 1000 * 60 * 60L);

        given(postService.createPost(any(),any())).willReturn(postCreateResponse);

        mockMvc.perform(post("/api/v1/posts")
                        .header(HttpHeaders.AUTHORIZATION,"Bearer " + token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postCreateRequest)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.resultCode").value("SUCCESS"))
                .andExpect(jsonPath("$.result.postId").value(1))
                .andExpect(jsonPath("$.result.message").value("포스트 등록 완료"))
                .andDo(print());

        verify(postService,times(1)).createPost(any(),any());
    }
}

2번 과정과 똑같은 성공 결과가 나온다.

image

@WithMockUser 어노테이션이 없어도 통과한다. 직접 토큰을 생성해서 인증 절차를 진행했기 때문이다.

🚫 이제 토큰을 제대로 넣지 않을 경우, 게시글 등록 성공 테스트는 다음과 같이 통과하지 않는다.

image

✅ 또한, .with(csrf())가 없어도 테스트가 통과한다. 내가 작성한 Security 설정 정보가 커스텀 어노테이션 @WebMvcTestSecurity를 통해 테스트에 추가되었기 때문에 가능한 일이다.

3-3. 게시글 등록 인증 실패 테스트

@WebAppConfiguration
@WebMvcTestSecurity(value = PostApiController.class)
class PostApiControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @MockBean
    PostService postService;
    @Autowired
    WebApplicationContext context;
    @Value("${jwt.token.secret}") String secretKey;

    private final PostCreateRequest postCreateRequest = new PostCreateRequest("제목", "내용");
    private final PostCreateResponse postCreateResponse = new PostCreateResponse(1, "포스트 등록 완료");

    @Test
    @DisplayName("포스트 작성 실패(1) - 인증 실패 (Bearer 토큰으로 보내지 않은 경우)")
    public void post_create_fail1() throws Exception {

        String token = JwtTokenUtil.createToken("user", secretKey, 1000 * 60 * 60L);

        mockMvc.perform(post("/api/v1/posts")
                        .header(HttpHeaders.AUTHORIZATION,"Basic " + token) 
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postCreateRequest)))
                .andExpect(status().is(INVALID_TOKEN.getStatus().value()))
                .andDo(print());
    }
}

Bearer 토큰으로 보내지 않았을 때 INVALID_TOKEN 에러가 반환되는지를 확인하는 테스트이다.

✅ 2번 과정과 똑같은 테스트 결과를 반환한다.

image

✅ 또한, .with(csrf())가 없어도 테스트가 통과한다. 내가 작성한 Security 설정 정보가 커스텀 어노테이션 @WebMvcTestSecurity를 통해 테스트에 추가되었기 때문에 가능한 일이다.

4. 정리


Security가 포함된 ControllerTest를 진행하는 방법을 3가지로 생각해보았다.

  1. Security를 제외하여 단위테스트 목적에 완벽히 부합하게 하는 방법

  2. 기본 Security 설정 값만 포함 하는 방법(@WithMockUser or @WithAnonymousUser)

  3. 개개인마다 작성한 Security 설정을 포함시켜서 테스트 하는 방법 3.1 @Import(SecurityConfig.class)를 통해 추가 3.2 Security 설정정보를 포함한 커스텀 어노테이션 추가


결국 나는 2 ➡️ 3단계를 거쳐 테스트를 진행했다.

하지만, 완벽한 단위테스트를 하는 것, 즉, 단순히 게시글 조회와 등록 요청/응답이 잘 진행되는지 확인하고 싶다면, Security를 제외하고 진행하는 것도 좋은 방법이다.

대신 이런 경우엔 토큰과 관련한 인증 테스트를 따로 진행해야 할 것이다.

References

Tags:

Categories:

Date:

Leave a comment