Controller에 HTTP문서로 요청할 때, body에 넣을 값이 없다면, 해당 content-type의 기본값을 넣어주자. 해당 body가 null이면 안된다.
@RestController
@RefreshScope
@RequestMapping(path = "/sns/post", consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
@RequiredArgsConstructor
@Slf4j
public class PostController {
.
.
.
/*
즐겨찾기 게시판 불러오기
*/
@GetMapping(path = "/favorite")
public ResponseEntity<ResponseResource> getFavoritePosts(
HttpServletRequest request,
@RequestParam("lastId") int lastId,
@RequestParam("limit") int limit
) {
Long memberId = util.getTokenMemberId(request);
Response response = Response.builder()
.code(10)
.message("SUCCESS")
.body(postService.getFavoritePosts((long) lastId, limit, memberId))
.build();
ResponseResource resource = new ResponseResource(response, PostController.class);
resource.add(linkTo(PostController.class).slash("favorite").withRel("get-favorite-posts"));
return ResponseEntity.ok().body(resource);
}
}
@RequestMapping(path = "/sns/post", consumes = MediaType.APPLICATION_JSON_VALUE)
- 프론트에서 해당 API를 요청할 때 body에 빈값을 넣었는데 415 unsupported media type 상태코드가 반환된다고 확인 부탁해달라는 요청이 들어왔다.
- 처음에는 나도 왜 문제가 생겼는지 전혀 알 수가 없었다. 테스트코드는 무사히 통과가 되었기 때문이다.
@Test @Order(11)
@DisplayName("즐겨찾기 게시판 목록 읽기 성공")
void getFavoritePosts() throws Exception {
//given
Long lastId = 0L;
int limit = 10;
//when
MockHttpServletRequestBuilder builder = get("/sns/post/favorite",lastId)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + token)
.accept(MediaTypes.HAL_JSON_VALUE)
.param("lastId", String.valueOf(lastId))
.param("limit", String.valueOf(limit));
//then
mockMvc.perform(builder)
.andDo(print())
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaTypes.HAL_JSON_VALUE + ";charset=UTF-8"))
.andExpect(jsonPath("code").exists())
.andExpect(jsonPath("message").exists())
.andExpect(jsonPath("body",hasSize(7)))
.andExpect(jsonPath("_links.self").exists())
.andExpect(jsonPath("_links.get-favorite-posts").exists())
.andDo(document("get-favorite-posts",
links(
linkWithRel("self").description("link to self"),
linkWithRel("get-favorite-posts").description("link to favorite posts")
),
requestHeaders(
headerWithName(HttpHeaders.ACCEPT).description("accept header"),
headerWithName(HttpHeaders.CONTENT_TYPE).description("contentType header")
),
responseHeaders(
headerWithName(HttpHeaders.CONTENT_TYPE).description("contentType header")
),
responseFields(
fieldWithPath("code").description("label code number"),
fieldWithPath("message").description("message"),
fieldWithPath("body").description("body of the response"),
fieldWithPath("body[].id").description("id"),
fieldWithPath("body[].title").description("title"),
fieldWithPath("body[].content").description("content"),
fieldWithPath("body[].createdAt").description("createdAt"),
fieldWithPath("body[].updatedAt").description("updatedAt"),
fieldWithPath("body[].user").description("user"),
fieldWithPath("body[].user.id").description("user id"),
fieldWithPath("body[].user.nickname").description("user nickname"),
fieldWithPath("body[].user.image").description("user image"),
fieldWithPath("body[].images").description("images").optional(),
fieldWithPath("body[].images[].src").description("image path").optional(),
fieldWithPath("body[].likers").description("likers").optional(),
fieldWithPath("body[].likers[].id").description("id of likers").optional(),
fieldWithPath("body[].likers[].nickname").description("nickname of likers").optional(),
fieldWithPath("body[].likers[].image").description("profile image of likers").optional(),
fieldWithPath("body[].likers[].image.src").type(JsonFieldType.STRING).description("profile image path of likers").optional(),
fieldWithPath("body[].commentNum").description("commentNum"),
fieldWithPath("body[].retweet").type(JsonFieldType.OBJECT).description("post that sharing other post").optional(),
fieldWithPath("body[].retweet.id").description("id of retweet"),
fieldWithPath("body[].retweet.title").description("title of retweet"),
fieldWithPath("body[].retweet.content").description("content of retweet"),
fieldWithPath("body[].retweet.createdAt").description("created time of retweet"),
fieldWithPath("body[].retweet.updatedAt").description("updated time of retweet"),
fieldWithPath("body[].retweet.user").description("user who retweeted"),
fieldWithPath("body[].retweet.user.id").description("user id who retweeted"),
fieldWithPath("body[].retweet.user.nickname").description("user nickname who retweeted"),
fieldWithPath("body[].retweet.user.image").description("user profile image who retweeted"),
fieldWithPath("body[].retweet.images").description("images of retweet"),
fieldWithPath("body[].retweet.images[].src").description("images path of retweet"),
fieldWithPath("body[].retweet.likers").description("user who liked retweet"),
fieldWithPath("body[].retweet.commentNum").description("commentNum of retweet"),
fieldWithPath("body[].retweet.category").description("category of retweeted post"),
fieldWithPath("body[].retweet.hits").description("views of retweet"),
fieldWithPath("body[].retweet.blocked").description("blocking flag of retweet"),
fieldWithPath("body[].retweet.deleted").description("whether to delete a retweet"),
fieldWithPath("body[].category").description("category"),
fieldWithPath("body[].hits").description("views"),
fieldWithPath("body[].blocked").description("blocking flag of the post"),
fieldWithPath("_links.self.href").description("link to self"),
fieldWithPath("_links.get-favorite-posts.href").description("link to favorite posts")
)
));
}
-
무엇이 문제였을까?
-
컨트롤러의 consumes = MediaType.APPLICATION_JSON_VALUE 부분을 살펴보자
-
위의 방식대로 정의하면 body는 json타입으로만 읽기가 가능하다.
-
그리고json의 기본값은 {} 이다.
-
그래서 body에 {}를 집어넣고 테스트를 돌려보았다. 바로 성공했다.
-
왜 나는 이 문제를 테스트코드로부터 발견할 수 없었을까?
-
MockMvc는 content-type을 json으로 설정하면 기본 값을 {}로 테스트하고 있었기 때문이다.
-
그런데 혹시나 궁금해서 빈 문자열을 body 에 넣어보았는데 테스트가 성공했다.
//when
MockHttpServletRequestBuilder builder = get("/sns/post/favorite",lastId)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + token)
.accept(MediaTypes.HAL_JSON_VALUE)
.content("")
.param("lastId", String.valueOf(lastId))
.param("limit", String.valueOf(limit));
- 다음과 같은 조건으로 content에 빈 문자열을 넣어 test를 진행해도 테스트는 무사히 통과가 되었다. 아무래도 null이 아니기만 하면 굳이{}가 아니라도 무엇이든 되는것 같다.
MockHttpServletRequest:
HTTP Method = GET
Request URI = /sns/post/favorite
Parameters = {lastId=[0], limit=[10]}
Headers = [Content-Type:"application/json;charset=UTF-8", Authorization:"Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJtZjAwMDEiLCJyb2xlIjoiUk9MRV9NQU5BR0VSIiwiaWQiOjEsImV4cCI6MTkxMjA2NDU4NiwiaWF0IjoxNTk2NzA0NTg2fQ.VmpRq6R0NhyteAp2ToaPPbjAANcSfZTMKvrXxCd3iFBcm3gVLn9GYd6lJQ07gRIyk_U38x4t7VEpzA2qcbMAgA", Accept:"application/hal+json"]
Body =
Session Attrs = {}
-
심지어 12345를 테스트에 넣어도 통과했다.
-
그럼 딱히 json이 문제는 아니었다는 소리가 된다. 그러면 대체 무엇이 문제일까?
-
@RequestMapping 어노테이션을 이러저리 둘러보면서 근원을 찾는 도중 문제가 되는 부분을 발견했다.
-
AbstractMessageConverterMethodArgumentResolver.java를 살펴보자
.
.
.
/**
* Create the method argument value of the expected parameter type by reading
* from the given HttpInputMessage.
* @param <T> the expected type of the argument value to be created
* @param inputMessage the HTTP input message representing the current request
* @param parameter the method parameter descriptor
* @param targetType the target type, not necessarily the same as the method
* parameter type, e.g. for {@code HttpEntity<String>}.
* @return the created method argument value
* @throws IOException if the reading from the request fails
* @throws HttpMediaTypeNotSupportedException if no suitable message converter is found
*/
@SuppressWarnings("unchecked")
@Nullable
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
MediaType contentType;
boolean noContentType = false;
try {
contentType = inputMessage.getHeaders().getContentType();
}
catch (InvalidMediaTypeException ex) {
throw new HttpMediaTypeNotSupportedException(ex.getMessage());
}
if (contentType == null) {
noContentType = true;
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
Class<?> contextClass = parameter.getContainingClass();
Class<T> targetClass = (targetType instanceof Class ? (Class<T>) targetType : null);
if (targetClass == null) {
ResolvableType resolvableType = ResolvableType.forMethodParameter(parameter);
targetClass = (Class<T>) resolvableType.resolve();
}
HttpMethod httpMethod = (inputMessage instanceof HttpRequest ? ((HttpRequest) inputMessage).getMethod() : null);
Object body = NO_VALUE;
EmptyBodyCheckingHttpInputMessage message;
try {
message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null && converter.canRead(targetClass, contentType))) {
if (message.hasBody()) {
HttpInputMessage msgToUse =
getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
}
else {
body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
}
break;
}
}
}
catch (IOException ex) {
throw new HttpMessageNotReadableException("I/O error while reading input message", ex, inputMessage);
}
if (body == NO_VALUE) {
if (httpMethod == null || !SUPPORTED_METHODS.contains(httpMethod) ||
(noContentType && !message.hasBody())) {
return null;
}
throw new HttpMediaTypeNotSupportedException(contentType, this.allSupportedMediaTypes);
}
MediaType selectedContentType = contentType;
Object theBody = body;
LogFormatUtils.traceDebug(logger, traceOn -> {
String formatted = LogFormatUtils.formatValue(theBody, !traceOn);
return "Read \"" + selectedContentType + "\" to [" + formatted + "]";
});
return body;
}
.
.
.
-
코드를 읽어보면 바디가 NO_VALUE가 되어선 안된다. 그 후에 body가 return되어 컨트롤러의 @RequestBody 에 해당하는 부분에 매핑이 되는데 내 코드에는 body에 매핑 될 객체의 형태가 없었기 때문에 12345,""를 넣더라도 에러가 딱히 발생하지 않은것이고, 그 이전에 빈값을 넣었을 때는 415에러가 발생한 것이다.
-
그렇다면 이러한 문제를 앞으로 해결하기 위해 어떻게 테스트코드를 변경해야할까?
-
mockMVC 테스트코드에 의도를 드러내는 수밖에 없는 것 같다. requestBuilder의 content에 {}를 사용하여 제이슨의 빈 타입이라는 의도를 드러내는 것 말고는 방도가 없어보인다.
결론
- default 값으로 설정하지 말고 의도를 드러내어 코딩하자.
MockHttpServletRequestBuilder builder = get("/sns/post/favorite",lastId)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + token)
.accept(MediaTypes.HAL_JSON_VALUE)
.content("{}") //비어있는 것을 표시하자
.param("lastId", String.valueOf(lastId))
.param("limit", String.valueOf(limit));
//then
mockMvc.perform(builder)
.
.
.
PS. 테스트 코드가 조금 지저분한것같다. 중복을 제거하는 방향으로 테스트 코드를 정리할 필요가 있겠다.
'실패록 > 오늘의 실패록' 카테고리의 다른 글
[실패록] EC2 카프카 메모리 리소스 부족 (2) | 2021.02.18 |
---|