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 설정에서 해당 엔드포인트에 대해서 접근을 허용해둔 상황이었다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
...
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests.requestMatchers("/", "/location").permitAll()
.anyRequest().authenticated());
return http.build();
}
설정에 문제가 없지만 여전히 401
응답이 발생하고 있다.
서치를 해보니 @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씨와 친해지기 앞서 Springboot에서 Controller 테스트가 어떻게 진행되는지를 이해해보자.
Springboot에서 컨트롤러 테스트를 진행할 때, Mock
이라는 가상의 객체를 만들어(Mocking) 접속을 테스트하는 방식으로 진행한다.
이러한 이유로 웹 어플리케이션에서 Controller를 테스트할 때, 서블릿 컨테이너를 모킹하기 위하여
@SpringBootTest
와@AutoConfigureMockMvc
를 결합한 방법 또는@WebMvcTest
를 사용하면 된다.(* 여기서 서블린 컨테이너를 모킹한다는 것은 실제 서블릿 컨테이너가 아닌 테스트용 모형 컨테이너를 구동하여 DispatcherServlet 객체를 메모리에 올린다는 것.)
MockMvc
를 제어하는 어노테이션이다.@WebMvcTest
와 다르게 전체 구성을 메모리에 올린다.MockMvc
를 보다 세밀하게 제어하기 위해 사용한다.@AutoConfigureMockMvc
와 @SpringBootTest
를 결합한 방법을 고려하면 된다고👀SpringBootTest(webEnvironment=WebEnvironment.MOCK)
@SpringBootTest
에는 웹 어플리케이션 테스트를 지원하는 webEnvironment 속성이 있다.@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
MockMvc
를 제어하는 어노테이션이다.@AutoConfigureMockMvc
과 다르게 @Controller
, @ControllerAdvice
등 사용 가능하나, @Service
, @Component
, @Repository
등 사용 불가하다.@MockBean
또는 @Import
와 함께 사용되어 컨트롤러 빈에 필요한 협력자를 생성한다.그럼 아까 앞에서 확인했던 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번은 해결이 아닌 회피)
정상적으로 호출할경우 로그인을 한 뒤에 발급받은 AccessToken을 이용하여 Header에 넣고 호출해야 하지만 테스트 단계에서 그렇게 까지 하기에는 정말 중요한 코드보다 그것을 실행하기 위한 사이드 코드가 많아진다.
@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에 따라 단계를 가독성있게 나누어주고 검증을 디벨롭해주었다.
정상적으로 동작하는 것을 확인할 수 있다.
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