[Springboot] @WebMvcTest - Controller Test에서 401과 데이트했다.

유아 Yooa·2023년 7월 31일
4

Spring

목록 보기
16/18
post-thumbnail

Overview

Unauthorized
          Headers = [WWW-Authenticate:"Basic realm="Realm"", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
     Content type = null
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

Junit5를 활용하여 Controller 단위 테스트를 진행하던 도중 401 Unauthorized 에러가 발생했다.

오늘도 해결해보자.


원인

테스트 코드부터 살펴보자.

@WebMvcTest(LocationController.class)
public class LocationControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private LocationService locationService;

    @Test
    @DisplayName("좌표 기준 주소 및 날씨 조회 테스트")
    public void searchPlaceTest() throws Exception {
    	// given
    	when(locationService.searchPlace(searchLocationReq))
         	.thenReturn(MockResponse.testSearchPlaceRes());
         
        mvc.perform(get("/location")
                .param("mapX", String.valueOf(MockRequest.X))
                .param("mapY", String.valueOf(MockRequest.Y)))
                .andExpect(status().isOk())
                .andDo(print());
    }
}

코드는 매우 간단하다.
/location 엔드 포인트로 적절한 파라미터를 넘겨주어 GET 요청을 보내고 200 status를 확인하는 테스트 케이스이다.

삽질💦 : security 설정이 잘못됐나?

분명 security 설정에서 해당 엔드포인트에 대해서 접근을 허용해둔 상황이었다.

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                ...
                .authorizeHttpRequests(authorizeRequests ->
                        authorizeRequests.requestMatchers("/", "/location").permitAll()
                                .anyRequest().authenticated());
        return http.build();
    }

설정에 문제가 없지만 여전히 401 응답이 발생하고 있다.

@WebMvcTest..?

서치를 해보니 @WebMvcTest를 사용하며 마주치는 에러인 것 같았다.
그렇다면 오늘도 공식 문서를 뜯어보자.

Annotation that can be used for a Spring MVC test that focuses only on Spring MVC components.
Using this annotation will disable full auto-configuration and instead apply only configuration relevant to MVC tests

@WebMvcTest는 Spring MVC와 관련된 Annotation만 구성되며, full auto-configuration은 비활성화된다고 한다.

아! 그래서 시큐리티 관련 어쩌구가 안된건가..? (아직 감 안잡힘)

By default, tests annotated with @WebMvcTest will also auto-configure Spring Security and MockMvc (include support for HtmlUnit WebClient and Selenium WebDriver).

그런데 조금 더 살펴보니 Spring Security와 MockMvc에 대한 auto-configure를 해준다고.👀

아직 잘 이해는 안되지만 원인은 @WebMvcTest와 관련있는 듯 하다.
더 알아보자.


@WebMvcTest 친해져요

우리 WebMvcTest씨와 친해지기 앞서 Springboot에서 Controller 테스트가 어떻게 진행되는지를 이해해보자.

Mock

Springboot에서 컨트롤러 테스트를 진행할 때, Mock이라는 가상의 객체를 만들어(Mocking) 접속을 테스트하는 방식으로 진행한다.

  • 객체를 테스트하려면 테스트 대상 객체가 메모리에 있어야 한다.
    • 생성에 복잡한 절차가 필요하거나 많은 시간이 소요되는 객체가 있을 수 있고, 웹 어플레케이션의 컨트롤러처럼 WAS나 다른 소프트웨어의 도움이 반드시 필요한 객체도 있을 수 있다.
  • 이처럼 복잡한 객체를 테스트하기 위해 실제 객체와 비슷한 가짜 객체를 만들어 테스트에 필요한 기능만 가지도록 Mocking하면 테스트가 쉬워진다.
  • 복잡한 의존성을 가지고 있을 때, 모킹한 객체를 이용해 의존성을 단절시킬 수 있어 쉽게 테스트가 가능하다.

이러한 이유로 웹 어플리케이션에서 Controller를 테스트할 때, 서블릿 컨테이너를 모킹하기 위하여 @SpringBootTest@AutoConfigureMockMvc를 결합한 방법 또는 @WebMvcTest를 사용하면 된다.

(* 여기서 서블린 컨테이너를 모킹한다는 것은 실제 서블릿 컨테이너가 아닌 테스트용 모형 컨테이너를 구동하여 DispatcherServlet 객체를 메모리에 올린다는 것.)

@AutoConfigureMockMvc로 단번에 해결?!

  • MockMvc를 제어하는 어노테이션이다.
  • @WebMvcTest와 다르게 전체 구성을 메모리에 올린다.
    • @Controller 뿐만 아니라 @Service, @Repository 모두 테스트 가능하다는 것.
  • MockMvc를 보다 세밀하게 제어하기 위해 사용한다.
    • 전체 어플리케이션 구성을 로드해서 실제 환경과 동일하게 테스트를 진행해야 한다면 @AutoConfigureMockMvc@SpringBootTest를 결합한 방법을 고려하면 된다고👀

@SpringBootTest

  • SpringBootTest(webEnvironment=WebEnvironment.MOCK)
    • @SpringBootTest에는 웹 어플리케이션 테스트를 지원하는 webEnvironment 속성이 있다.
    • 이 속성을 생략하면 default로 WebEnvironment.Mock이 설정되고 이로 인해 서블릿 컨테이너가 모킹된다.
  • 이 설정으로 모킹한 객체를 의존성 주입을 받기 위하여 @AutoConfigureMockMvc를 클래스 위에 추가해준다.
@AutoConfigureMockMvc
@SpringBootTest
public class LocationControllerTest {

    @Autowired
    private MockMvc mvc;

    @Autowired
    private LocationService locationService;

    @Test
    @DisplayName("좌표 기준 주소 및 날씨 조회 테스트")
    public void searchPlaceTest() throws Exception {
        mvc.perform(get("/location")
                .param("mapX", String.valueOf(MockRequest.X))
                .param("mapY", String.valueOf(MockRequest.Y)))
                .andExpect(status().isOk())
                .andDo(print());
    }
}

⭐️ 해당 코드를 사용하면 Security 오류는 발생하지 않는다. 우리가 정의한 Spring Security Configuration이 불러와졌기 때문

그러나 @WebMvcTest를 활용한다면 어플리케이션 규모가 축소되기에 훨씬 빠르고 쉬운 테스트가 가능하다. Controller 단위 테스트에만 집중하기 위해 @WebMvcTest를 사용해 해결하려고 한다.

Unit Test에서 중요한 것은 테스트하려는 대상의 고립이다.
출처 - https://wonit.tistory.com/493


@WebMvcTest

  • MockMvc를 제어하는 어노테이션이다.
  • @AutoConfigureMockMvc과 다르게 @Controller, @ControllerAdvice등 사용 가능하나, @Service, @Component, @Repository 등 사용 불가하다.
  • 일반적으로 @MockBean 또는 @Import와 함께 사용되어 컨트롤러 빈에 필요한 협력자를 생성한다.
  • 웹 상에서 요청과 응답에 대해 테스트할 수 있고 시큐리티 혹은 필터까지 자동으로 테스트하여 수동으로 추가/삭제가 가능하다.

Spring Security auto-configure

그럼 아까 앞에서 확인했던 Spring Security와 MockMvc에 대한 auto-configure 부분도 이해해보자. @WebMvcTest를 활용하면 Spring Security Configuration이 불러와지지 않기에 에러가 발생했던 것이다. 그런데 auto-configure가 나오니 헷갈리기 시작한다.

핵심은 auto에 있다. Spring Security가 자동으로 구성하는 Configuration 파일들을 불러와서 사용한다는 것.👀

내가 설정한 Security Configuration은 아무 상관도 없었던 것!

자동으로 구성되는 많은 클래스 중 SpringBootWebSecurityConfiguration를 살펴보면 아래와 같다.

@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
@ConditionalOnWebApplication(type = Type.SERVLET)
class SpringBootWebSecurityConfiguration {

	@Bean
	@Order(SecurityProperties.BASIC_AUTH_ORDER)
	SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
		http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic();
		return http.build();
	}

}

모든 요청에 대하여 아무 권한을 가지고 있다면 허용되도록 기본적으로 설정되어 있다.
그렇기 때문에 /location 요청에 대하여 특별한 권한 설정을 해주지 않았으므로 401 Unauthorized 에러가 발생할 수 밖에 없었다.

그러므로 요청을 할 때 권한을 같이 넘겨주면 해결 가능이다. 드디어 원인과 해결 방법을 찾았다!


해결 방법

요청 시에 권한을 함께 넘겨주는 방법은 세 가지가 있다.
1) ExcludeFilter를 이용해 Security를 회피하는 방법
2) @WithMockUser, @WithUserDetails 등의 Annotation을 이용해 권한을 함께 요청하는 방법
3) @TestConfiguration 파일을 별도로 설정하는 방법

필자는 이 중 2번의 방법을 활용했다. (사실상 1번은 해결이 아닌 회피)

인증된 Mock 유저를 생성해주기

정상적으로 호출할경우 로그인을 한 뒤에 발급받은 AccessToken을 이용하여 Header에 넣고 호출해야 하지만 테스트 단계에서 그렇게 까지 하기에는 정말 중요한 코드보다 그것을 실행하기 위한 사이드 코드가 많아진다.

출처 - https://lemontia.tistory.com/1088

  • @WithMockUser - 인증된 사용자
  • @WithAnonymousUser - 미인증 사용자 (principal에서 "anonymous"가 들어가있음)
  • @WithUserDetails - 메서드가 principal 내부의 값을 직접 사용하는 경우 (별도의 사전 설정 필요)

따라서 @WithMockUser를 붙여주면 권한이 함께 넘어가기에 401 에러가 사라질 것.

 @Test
@DisplayName("좌표 기준 주소 및 날씨 조회 테스트")
@WithMockUser("user1")
public void searchPlaceTest() throws Exception {
	// given
    when(locationService.searchPlace(searchLocationReq))
         .thenReturn(MockResponse.testSearchPlaceRes());

    // when
    ResultActions result = mvc.perform(get("/location")
                					.param("mapX", String.valueOf(MockRequest.X))
                					.param("mapY", String.valueOf(MockRequest.Y)));

    // then
    result.andExpect(status().isOk())
          .andExpect(jsonPath("$.data.address").value(MockResponse.testSearchPlaceRes().getAddress()))
          .andExpect(jsonPath("$.data.weatherType").value(MockResponse.testSearchPlaceRes().getWeatherType().toString()))
          .andExpect(jsonPath("$.data.temperatures").value(MockResponse.testSearchPlaceRes().getTemperatures()));
    }

BDD에 따라 단계를 가독성있게 나누어주고 검증을 디벨롭해주었다.

🚨 403 에러가 발생한다면?

정상적으로 동작하는 것을 확인할 수 있다.

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
     Content type = application/json
             Body = {"code":"200","message":"요청 성공","data":{"address":"서울특별시 중구 창경궁로 17","weatherType":"SUNNY","temperatures":33.2}}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

ref

profile
기록이 주는 즐거움

0개의 댓글