Rest Docs를 처음 접했기 때문에 무한 구글링과 rest docs 원서를 읽으면서 열심히(?) 테스트 코드를 바탕으로 API 명세서를 만들고 있었다.
잘 만들던 중 모든 파라미터와 요청응답을 맞게 설정했음에도 불구하고 응답 data 부분이 null로 뜨는 에러를 만났다.!
그럼 이제 어떻게 오류를 접했고, 어떻게 해결했는지에 대해 말해보겠다.
우선 나는 회원이 좋아요 한 상품을 조회하는 로직을 작성중이었다. 그에따라 컨트롤러를 아래와 같이 작성했다.
//좋아요한 데이터 상품 목록
@GetMapping("/v1/users/hearts")
public CustomResponseEntity<List<DataProductResponse.findHeartDataProducts>> findHeartDataProducts(@AuthenticationPrincipal Long userId){
return CustomResponseEntity.success(userService.findHeartDataProducts(userId));
}
@AuthenticationPrincipal을 이용하여 userId를 받아와서 서비스 로직으로 전달했다.
그리고 test code를 작성했다.
@Test
@DisplayName("좋아요한 데이터 상품 목록")
public void findHeartDataProducts() throws Exception {
Category category1 = Category.builder()
.title("패션").categoryCode(101).build();
Category category2 = Category.builder()
.title("사람").categoryCode(102).build();
User user = new User(1l,"test","test","test","test",null);
DataProduct dataProduct1 = new DataProduct(1l,1l,"test1",100l,"testing1",1l);
DataProduct dataProduct2 = new DataProduct(2l,2l,"test2",200l,"testing2",2l);
//given
DataProductResponse.findHeartDataProducts test1 = DataProductResponse.findHeartDataProducts.builder()
.productId(1l)
.title("test1")
.price(1000l)
.description("test1 설명입니다.")
.imageUrl("이미지 url")
.createdAt(LocalDateTime.now())
.categoriesName(List.of(category1.getTitle(),category2.getTitle()))
.build();
DataProductResponse.findHeartDataProducts test2 = DataProductResponse.findHeartDataProducts.builder()
.productId(2l)
.title("test2")
.price(2000l)
.description("test2 설명입니다.")
.imageUrl("이미지 url")
.createdAt(LocalDateTime.now())
.categoriesName(List.of(category1.getTitle()))
.build();
List<DataProductResponse.findHeartDataProducts> response = List.of(test1, test2);
Heart heart1 = Heart.builder().user(user).dataProduct(dataProduct1).build();
Heart heart2 = Heart.builder().user(user).dataProduct(dataProduct2).build();
given(userService.findHeartDataProducts(user.getId())).willReturn(response);
ResultActions result = this.mockMvc.perform(
get("/v1/users/hearts")
.header("Authorization", "Basic dXNlcjpzZWNyZXQ=")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
);
// when then
result.andExpect(status().isOk())
.andDo(
restDocs.document(
requestHeaders(
headerWithName("Authorization").description("accessToken")),
responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER).description("결과코드"),
fieldWithPath("message").type(JsonFieldType.STRING).description("결과메시지"),
fieldWithPath("data[].productId").type(JsonFieldType.NUMBER).description("상품 아이디"),
fieldWithPath("data[].title").type(JsonFieldType.STRING).description("상품 명"),
fieldWithPath("data[].price").type(JsonFieldType.NUMBER).description("상품 가격"),
fieldWithPath("data[].description").type(JsonFieldType.STRING).description("상품 설명"),
fieldWithPath("data[].imageUrl").type(JsonFieldType.STRING).description("상품 이미지 url"),
fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("상품 생성일자"),
fieldWithPath("data[].categoriesName").type(JsonFieldType.ARRAY).description("상품 아이디")
)
)
)
;
}
위의 코드를 보면, 응답도 알맞게 설정하고, 요청 헤더 형식 등 모두 알맞게 구성했다. 하지만 이 코드를 run하게 되면, 아래와 같이 data:[] 과 같은 아름다운(?) 데이터 증발 현상을 만나게 된다. ㅎㅎㅎ,,,,,
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", Content-Type:"application/json;charset=UTF-8"]
Content type = application/json;charset=UTF-8
Body = {"code":0,"message":"성공","data":[]}
Forwarded URL = null
Redirected URL = null
Cookies = []
org.springframework.restdocs.snippet.SnippetException: The following parts of the payload were not documented:
{
"data" : [ ]
}
Fields with the following paths were not found in the payload: [data[].productId, data[].title, data[].price, data[].description, data[].imageUrl, data[].createdAt, data[].categoriesName]
삽질을 며칠동안 하면서, 롤모델(대학 동기)에게 자문을 구한 결과.. 해당 Mock 테스트는 mvc 테스트이므로 실제 컨트롤러에서 Security를 이용해서 @AuthenticationPrincipal를 통해 userId를 받아오는 것이 불가능하다는 해답을 얻었다.
또한, 매번 컨트롤러에 @AuthenticationPrincipal를 선언하는 것도 매우 매우 귀찮기 때문에, 이참에 별도로 context에서 user를 받아오는 로직을 작성하기로 했다.
public class SecurityUtils {
private static List<SimpleGrantedAuthority> notUserAuthority = new ArrayList<>();
public static Long getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
throw new CustomException(Result.FAIL);
}
if (authentication.isAuthenticated()
&& !CollectionUtils.containsAny(
authentication.getAuthorities(), notUserAuthority)) {
return Long.valueOf(authentication.getName());
}
return 0L;
}
}
위의 코드를 통해 context 안에 들어있는 userId를 받아올 수 있고, 해당 id로 repository를 조회하여 user를 얻을 수 있었다.!!
자 그럼 이제 코드를 수정해보겠다.
컨트롤러
//좋아요한 데이터 상품 목록
@GetMapping("/v1/users/hearts")
public CustomResponseEntity<List<DataProductResponse.findHeartDataProducts>> findHeartDataProducts(){
return CustomResponseEntity.success(userService.findHeartDataProducts());
}
@AuthenticationPrincipal를 제외하고 위에서 설정한 securityUtils를 통해 service로직에서 회원을 받아온다.
테스트
@Test
@DisplayName("좋아요한 데이터 상품 목록")
public void findHeartDataProducts() throws Exception {
Category category1 = Category.builder()
.title("패션").categoryCode(101).build();
Category category2 = Category.builder()
.title("사람").categoryCode(102).build();
User user = new User(1l,"test","test","test","test",null);
DataProduct dataProduct1 = new DataProduct(1l,1l,"test1",100l,"testing1",1l);
DataProduct dataProduct2 = new DataProduct(2l,2l,"test2",200l,"testing2",2l);
//given
DataProductResponse.findHeartDataProducts test1 = DataProductResponse.findHeartDataProducts.builder()
.productId(1l)
.title("test1")
.price(1000l)
.description("test1 설명입니다.")
.imageUrl("이미지 url")
.createdAt(LocalDateTime.now())
.categoriesName(List.of(category1.getTitle(),category2.getTitle()))
.build();
DataProductResponse.findHeartDataProducts test2 = DataProductResponse.findHeartDataProducts.builder()
.productId(2l)
.title("test2")
.price(2000l)
.description("test2 설명입니다.")
.imageUrl("이미지 url")
.createdAt(LocalDateTime.now())
.categoriesName(List.of(category1.getTitle()))
.build();
List<DataProductResponse.findHeartDataProducts> response = List.of(test1, test2);
Heart heart1 = Heart.builder().user(user).dataProduct(dataProduct1).build();
Heart heart2 = Heart.builder().user(user).dataProduct(dataProduct2).build();
given(userService.findHeartDataProducts()).willReturn(response);
ResultActions result = this.mockMvc.perform(
get("/v1/users/hearts")
.header("Authorization", "Basic dXNlcjpzZWNyZXQ=")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
);
// when then
result.andExpect(status().isOk())
.andDo(
restDocs.document(
requestHeaders(
headerWithName("Authorization").description("accessToken")),
responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER).description("결과코드"),
fieldWithPath("message").type(JsonFieldType.STRING).description("결과메시지"),
fieldWithPath("data[].productId").type(JsonFieldType.NUMBER).description("상품 아이디"),
fieldWithPath("data[].title").type(JsonFieldType.STRING).description("상품 명"),
fieldWithPath("data[].price").type(JsonFieldType.NUMBER).description("상품 가격"),
fieldWithPath("data[].description").type(JsonFieldType.STRING).description("상품 설명"),
fieldWithPath("data[].imageUrl").type(JsonFieldType.STRING).description("상품 이미지 url"),
fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("상품 생성일자"),
fieldWithPath("data[].categoriesName").type(JsonFieldType.ARRAY).description("상품 아이디")
)
)
)
;
}
given절에 기존에 파라미터였던 user.getId를 빼줬다.
위와 같이 하면 data가 null뜰일은 없다!!
번외
MockMVC 테스트를 작성하다가 알게된 사실인데,
given 절이 잘못되는 경우 즉, 응답이 올바르지 않는 경우는 통신을 잘 되어서 200으로 응답이 오지만 data가 제대로 전달되지 않는다.
그와 다르게, given은 알맞는데, 단순히 파라미터등의 전달이 잘 안되는 경우에는 400대 에러가 발생하게 된다.!!
뭔가를 또 깨달았다~💪🏻
정보 감사합니다.