3 분 소요

문제 상황

Controller에 정의한 API 중 로그인한 사용자만 사용 가능한 권한이 필요한 API가 있는 상황이었다.
해당 함수에 대한 테스트 코드를 작성하고 실행했는데 API 함수를 호출하기도 전에 계속 빈 생성을 덜했다거나 의존성 문제가
남발했고, 그렇게 생성안된 빈들은 모두 Spring Security에 정의된 것들이었다. 테스트하는데 꼭 필요한 내용인지는 모르겠어서
해당되는 빈들을 모두 생성하지 않도록 걸러주는 방법이 없을까 생각해봤다.

일단 Controller라고 콕찝어 얘기한 이유는 Controller 단위 테스트를 수행하고 싶었기 때문이다! (@WebMvcTest 사용)
즉, Presentation Layer 관련 컴포넌트만 스캔하고 Service, Repository는 @MockBean으로 주입받으려 했다.
Controller에 정의한 메소드에 대해서 적절한 응답이 오는지만 체크하고, Service, Repository는 별도로 검증하고 싶었다.

java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@5df92089 testClass = fittering.mall.controller.CategoryControllerTest Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name ‘jwtAuthenticationFilter’ Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type ‘fittering.mall.config.jwt.JwtTokenProvider’ available: expected at least 1 bean which qualifies as autowire candidate.

인증/인가 방식으로는 프로젝트에서 JWT를 사용하고 있었고 Spring Security 설정 클래스 내에서 JWT 관련 클래스를 주입받는
상황이다보니 테스트 함수를 실행하기 전에 빈 생성이 제대로 되지 않았다는 문제가 발생했었다.

추가로 @AuthenticationPrincipal 주입 문제도 있었는데, 아래 해결 방법 중 @WithCustomMockUser를 참고하자.

해결 방법

@WithMockUser

빈 생성 제외 (Optional)

Optional이라고 적은 이유는 Spring Security 설정 파일 내에 JwtAuthenticationFilter를 주입받아 의존성이 있는 상황
인 경우에 한해서이다. 그래서 문제 상황에 적힌 예외도 발생한거니까 해당되는 경우에만 아래를 따라가면 된다. 방식은 2가지다!

Controller 단위 테스트를 위해 @WebMvcTest를 사용하고 있어서 다음처럼 빈 생성을 제외하는 옵션을 줄 수 있었다.
typeFilterType.ASSIGNABLE_TYPE을, 빈 생성 제외 클래스들은 classes에 적어주면 된다.
Spring Security 설정 클래스인 SecurityConfig와 거기에 등록된 JWT 필터 JwtAuthenticationFilter를 제외했다.
SecurityConfig만 등록해봤는데 잘 해결되지 않아서 JwtAuthenticationFilter도 같이 등록해주니 해결됐다!

@WebMvcTest(
        controllers = CategoryController.class,
        excludeFilters = @ComponentScan.Filter(
                type = FilterType.ASSIGNABLE_TYPE,
                classes = {
                        SecurityConfig.class,
                        JwtAuthenticationFilter.class
                }
        )
)
class CategoryControllerTest { ... }
  • controllers : 단위 테스트 대상 Controller 정보

excludeFilters를 걸어서 처리해주고 싶지 않다면, 아래처럼 @MockBean을 이용해보자.

@WebMvcTest(controllers = MallController.class)
class CategoryControllerTest {

    @MockBean
    JwtAuthenticationFilter jwtAuthenticationFilter;
    ...
}

유저 설정과 CSRF

카테고리 등록 메소드가 유저 권한이 필요했는데 @WithMockUser를 설정해주니 해결할 수 있었다.
그리고 CSRF 설정도 추가해줘야 한다! 그렇지 않으면 403 Forbidden 에러를 뱉는다.
Spring Security 기본 설정에는 CSRF 설정을 해줘야하는 것으로 알고 있는데, 프로젝트에서 적용하던 Security 설정 클래스
SecurityConfig를 위에서 제외하는 바람에 기본 설정으로 들어간 것 같다. 때문에 @WithMockUser도 먹힌듯 하다.

@DisplayName("카테고리를 등록한다.")
@Test
@WithMockUser(username = "test", roles = "USER") //추가
void createCategory() throws Exception {
    //given
    String categoryName = "상의";

    //when //then
    mockMvc.perform(
                post("/api/v1/auth/categories/" + categoryName)
                        .with(csrf()) //추가
            )
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().string("카테고리 등록 완료"));
}

@WithCustomMockUser

@AuthenticationPrincipal Mock 객체 주입하기

@WithMockUser 방식은 매우매우 간단해서 편리하지만 안타깝게도 아래처럼 @AuthenticationPrincipal를 사용해서 현재
사용자 정보를 얻으려 하는 API의 경우 PrincipalDetailsnull로 들어오기 때문에 사용할 수 없다.

@GetMapping("/api/v1/auth/malls/{mallId}")
public ResponseEntity<?> getMall(@PathVariable("mallId") @NotEmpty Long mallId,
                                 @AuthenticationPrincipal User user) { //@WithMockUser를 쓰면 null이 들어온다.
        ...
}

이는 @WithMockUser를 통해 생성되는 User 객체 타입이 주입받고자 하는 타입과 다르거나, 갖고 있는 필드가 부족해
null이 들어오는 것이다. 때문에 직접 사용하고 있는 User 객체를 생성해주도록 설정해줄 필요가 있다.
아래는 그런 역할을 하는 @WithCustomMockUser를 정의하고 적용하는 내용을 다룬다.

@WithCustomMockUser를 사용할 수 있도록 애노테이션을 정의해주자.
그리고 생성하려는 User 객체에 필요한 내용을 설정해주자.

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithCustomMockUserSecurityContextFactory.class)
public @interface WithCustomMockUser {
    String username() default "test";
    String password() default "password";
    String role() default "USER";
}

WithSecurityContextFactory를 구현하는 WithCustomMockUserSecurityContextFactory를 정의해주자.
테스트 환경에서 사용할 SecurityContext를 정의해줄 클래스를 의미한다.
제네릭으로 WithCustomMockUser를 설정해줌으로써 annotation에서 위에서 정의한 값을 참조할 수 있다.

public class WithCustomMockUserSecurityContextFactory implements WithSecurityContextFactory<WithCustomMockUser> {

    @Override
    public SecurityContext createSecurityContext(WithCustomMockUser annotation) {
        User user = User.builder()
                .username(annotation.username())
                .password(annotation.password())
                .build();

        UsernamePasswordAuthenticationToken token =
                new UsernamePasswordAuthenticationToken(user, user.getPassword(), List.of(new SimpleGrantedAuthority(annotation.role())));
        SecurityContext context = SecurityContextHolder.getContext();
        context.setAuthentication(token);
        return context;
}

그리고 테스트 클래스에서 MockMvc를 통해 테스트한다면 다음 설정을 추가해줘야 한다.

@BeforeEach
void setUp() {
    mockMvc = MockMvcBuilders
        .webAppContextSetup(context)
        .apply(springSecurity())
        .build();
}

다음은 적용했을 때 테스트 클래스 코드다.

@WebMvcTest(controllers = MallController.class)
class MallControllerTest {

    @Autowired
    private WebApplicationContext context; //setUp()에서 사용하기 위해 주입

    @MockBean
    JwtAuthenticationFilter jwtAuthenticationFilter;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private MallService mallService;

    //설정 추가
    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .build();
    }

    @DisplayName("쇼핑몰을 조회한다.")
    @Test
    @WithCustomMockUser //추가
    void getMall() throws Exception {
        //given
        Long mallId = 1L;
        RequestMallDto request = RequestMallDto.builder()
                .url("https://www.test.com")
                .description("테스트 쇼핑몰")
                .build();
        ResponseMallDto response = ResponseMallDto.builder()
                .id(1L)
                .url("https://www.test.com")
                .description("테스트 쇼핑몰")
                .build();

        when(mallService.findById(1L, 1L)).thenReturn(response);

        //when //then
        mockMvc.perform(
                        get("/api/v1/auth/malls/" + mallId)
                                .content(objectMapper.writeValueAsString(request))
                                .contentType(MediaType.APPLICATION_JSON)
                )
                .andDo(print())
                .andExpect(status().isOk());
    }
}

References

카테고리:

업데이트:

댓글남기기