- 테스트에서의 Spring Security
- Controller 테스트 코드 작성
- 트러블슈팅
- @TestConfiguration
UserController에 대한 테스트 코드를 작성했다.
@Test
@DisplayName("일반 사용자 회원가입")
void signup() throws Exception {
// given
SignupRequestDto requestDto = SignupRequestDto.builder()
.username("test")
.password("password")
.email("test@email.com")
.phoneNumber("01012345678")
.role(UserRoleEnum.CUSTOMER)
.imgUrl(null)
.build();
// when
when(userService.signup(any(SignupRequestDto.class), eq(null)))
.thenReturn(new UsernameResponseDto("test"));
// then
mvc.perform(post(BASE_URL + "/users/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(requestDto)))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("test"));
}
일반 사용자로의 회원가입을 진행했고, isCreated(201) 상태가 return 될 것으로 .andExpect를 통해 예측했다. 그런데 메서드를 실행했을 때 401 에러가 발생했다.
처음에는 401이 발생한 이유를 전혀 알지 못했다. /users/signup URI로의 요청은 WebSecurityConfig에서 인증되지 않은 사용자만 접근 가능하도록 허용해놨기 때문에 절대 401이 발생해서는 안 됐다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = MockSecurityContextFactory.class)
public @interface MockUser {
String username() default "user1";
String email() default "email1@email.com";
String password() default "password";
String phoneNumber() default "01011110000";
UserRoleEnum role() default UserRoleEnum.CUSTOMER;
boolean publicProfile() default true;
}
public class MockSecurityContextFactory implements WithSecurityContextFactory<MockUser> {
@Override
public SecurityContext createSecurityContext(MockUser annotation) {
User user = User.builder()
.username(annotation.username())
.email(annotation.email())
.password(annotation.password())
.phoneNumber(annotation.phoneNumber())
.role(annotation.role())
.build();
UserDetails userDetails = new UserDetailsImpl(user);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(userDetails);
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authentication);
return securityContext;
}
}
그래서 @WithMockUser 대신에 이 어노테이션을 사용할 수 있었다. 일단 401이 발생하는 문제 때문에 @MockUser를 추가했다.
@Test
@DisplayName("일반 사용자 회원가입")
@MockUser
void signup() throws Exception { ...
어노테이션을 추가한 결과 201이 나올 것으로 기대했지만, 403이 발생했다.
로그를 확인해 보니, csrf 문제로 403 에러가 발생하는 것을 확인했다. 그런데 분명히 WebSecurityConfig에서 csrf를 disable 시켜 놨는데, 왜 이런 문제가 발생하는지 이해할 수 없었다.
일단 당장 문제 해결을 위해서 with(csrf())를 추가했다.
// then
mvc.perform(post(BASE_URL + "/users/signup")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(requestDto)))
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("test"));
.with(csrf())를 추가한 결과 테스트가 통과하는 것을 확인했다.
@Secured로 설정한 컨트롤러 메서드에 대해 role이 맞지 않는, 즉 권한이 부족해 실패하는 테스트를 작성했다.
@Test
@DisplayName("관리자 회원가입 실패 - 권한 부족")
@MockUser(role = UserRoleEnum.CUSTOMER)
void signupManagerByNotAdmin() throws Exception {
// given
SignupRequestDto requestDto = SignupRequestDto.builder()
.username("test")
.password("password")
.email("test@email.com")
.phoneNumber("01012345678")
.role(UserRoleEnum.MANAGER)
.imgUrl(null)
.build();
// when - then
mvc.perform(post(BASE_URL + "/users/signup/master")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(requestDto)))
.andDo(print())
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.errorMessage").value(ExceptionMessage.NOT_ALLOWED_API.getMessage()));
}
그런데 실패가 아닌 201 Created가 발생하는 것을 확인했다. 분명히 @MockUser로 role까지 지정해 줬는데, 201이 발생한다는 건 이상했다.
메인 메서드를 실행하고 postman으로 테스트 해봤을때는 정상적으로 403이 발생했다. 테스트 환경에서만 오류가 나고 있다.
일단 로그를 찍어 보았다.
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.info("Authentication: " + authentication.getAuthorities());
INFO 18848 --- [ main] c.s.b.controller.UserControllerTest : Authentication: [ROLE_CUSTOMER]
MockUser로 설정된 User의 role은 분명히 CUSTOMER였다. 그럼 도대체 무엇이 문제일까?
GPT를 통해 @Import(WebSecurityConfig.class)를 테스트 클래스에 추가해보라는 답을 받았다. 하지만 WebSecurityCofig는 다른 Component들에 의존성을 가지고 있기 때문에 이렇게 가져오는 것은 불가능하다. 그렇다고 컴포넌트들을 모두 추가하는 것은 테스트 코드를 작성하는 의미가 없다고 생각했다.
결론을 찾아 냈다. 테스트용 SecurityConfig 클래스를 구현하고, @TestConfiguration 어노테이션을 달아 주어야 한다.
현재 테스트가 실행되면 Spring Security가 WebSecurityConfig를 받아올 수는 없다. 그렇기 때문에 기본 SecurityConfig를 생성하게 된다.
기본 설정의 특징
- 모든 요청에 인증 필요 (anyRequest().authenticated())
- form 로그인 활성화
- HTTP Basic 인증 활성화
- CSRF 보호 활성화
모든 요청에 인증이 필요하기 때문에 회원가입 테스트에서 @MockUser가 없으면 401이 발생했던 것이고, csrf가 활성화되어 있기 때문에 csrf 에러가 발생했던 것이었다! @Secured가 동작하지 않았던 이유도, @EnableMethodSecurity(securedEnabled = true)가 SecurityConfig에 지정되어 있어야 하는데 기본 설정에는 이것이 지정되어 있지 않기 때문이었다.
그래서 다음과 같은 TestSecuriyConfig 클래스를 작성했다.
@TestConfiguration
@EnableMethodSecurity(securedEnabled = true)
public class TestSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf((csrf) -> csrf.disable())
.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers("/api/v1/users/signup").anonymous()
.anyRequest().authenticated()
);
return http.build();
}
}
또한 모든 with(csrf())를 제거하고, 일반 사용자 회원가입 테스트에서 @MockUser를 제거했다. 그리고 모든 테스트가 통과하는 것을 확인했다.

이제 테스트에서 Security를 다루는 법을 어느 정도 알게 되었다. 테스트 코드를 작성하는 데 있어 한결 수월해 질 것 같다.