Spring REST Docs
REST Docs vs Swagger
REST Docs
장점단점
- 테스트를 통과해야 문서가 만들어진다. -> 즉, 신뢰도가 높다
- 프로덕션 코드에 비침투적이다. -> 문서 내부에서 프로덕션 코드를 실행시킬 수 없다
- 코드 양이 많다.
- 설정이 까다롭다.
Swagger
장점단점
- 적용이 쉽다.
- 문서에서 바로 API 호출을 수행 해볼 수 있다.
- UI 가 상대적으로 깔끔하다.
- 프로덕션 코드에 침투적이다.
- 테스트와 무관하기 때문에 실제로 동작하는 것과 코드의 신뢰도가 떨어질 수 있다.
문서 선택 가이드
- 문서를 보며 바로바로 API 를 호출해야 하는 상황이다 -> Swagger
- 테스트가 더 중요하며 프로덕션 코드를 건드리지 않는 선에서 문서를 작성하고 싶다 -> REST Docs
REST Docs 를 만들기 위한 테스트 코드 작성 전 알아보기
REST Docs 를 사용하기 위해서 테스트 코드를 작성해야 한다.
하지만, Controller 테스트를 구성 했던 코드에 Docs 를 위한 내용을 추가하여 공통으로 관리하게 되면 프로덕트가 커졌을 때 관리하기 힘들다는 단점이 명백하다.
이러한 이유로 기능 테스트 코드는 기능 테스트끼리 관리하고, 문서를 위한 코드는 따로 작성하여 서로 분리하는 것을 지향한다.
컨트롤러 계층에서 단위 테스트를 돕는 도구 중 자주 선택되는 것은 MockMvc 와 RestAssured 이다.
MockMvc 는 springboot-starter-test 의존성에 포함되어 있어 별도의 의존성을 추가하지 않고 바로 사용할 수 있으며 SpringBootTest 어노테이션 없이 WebMvcTest 어노테이션으로 컨트롤러 계층만 테스트가 가능하다.
RestAssured 는 rest-assured:rest-assured 의존성을 별도로 추가해야 하며 컨트롤러 테스트를 하기 위해 SpringBootTest 어노테이션을 사용해야한다.
어떤 도구가 내게 더 어울릴까?
RestAssured
- 가독성이 중요한가?
- 컨트롤러를 테스트 하는 과정에서 Bean 을 많이 사용하여 영향을 받는가?
MockMvc
- 전체 테스트를 자주 실행하여 빌드 속도에 영향을 받는가?
- 의존성을 별도로 추가하지 않고 바로 테스트가 가능해야 하는가?
- 컨트롤러 계층만 단위 테스트가 필요한가?
설정
.png)
문서화를 위한 테스트 코드 작성
package sample.cafekiosk.spring.api.controller.product;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import sample.cafekiosk.spring.ControllerTestSupport;
import sample.cafekiosk.spring.api.controller.product.dto.request.ProductCreateRequest;
import sample.cafekiosk.spring.api.service.product.response.ProductResponse;
import sample.cafekiosk.spring.domain.product.ProductSellingStatus;
import sample.cafekiosk.spring.domain.product.ProductType;
class ProductControllerTest extends ControllerTestSupport {
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() throws Exception {
// given
ProductCreateRequest request = ProductCreateRequest.builder()
.type(ProductType.HANDMADE)
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(4000)
.build();
// when // then
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk());
}
...
} package sample.cafekiosk.spring.docs.product;
import static org.mockito.BDDMockito.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import sample.cafekiosk.spring.api.controller.product.ProductController;
import sample.cafekiosk.spring.api.controller.product.dto.request.ProductCreateRequest;
import sample.cafekiosk.spring.api.service.product.ProductService;
import sample.cafekiosk.spring.api.service.product.request.ProductCreateServiceRequest;
import sample.cafekiosk.spring.api.service.product.response.ProductResponse;
import sample.cafekiosk.spring.docs.RestDocsSupport;
import sample.cafekiosk.spring.domain.product.ProductSellingStatus;
import sample.cafekiosk.spring.domain.product.ProductType;
class ProductControllerDocsTest extends RestDocsSupport {
private final ProductService productService = mock(ProductService.class);
@Override
protected Object initController() {
return new ProductController(productService);
}
@DisplayName("신규 상품 등록 API")
@Test
void createProduct() throws Exception {
ProductCreateRequest request = ProductCreateRequest.builder()
.type(ProductType.HANDMADE)
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(4000)
.build();
given(productService.createProduct(any(ProductCreateServiceRequest.class)))
.willReturn(ProductResponse.builder()
.id(1L)
.productNumber("001")
.type(ProductType.HANDMADE)
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(4000)
.build()
);
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andDo(document(
"product-create",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestFields(
fieldWithPath("type").type(JsonFieldType.STRING)
.description("상품 타입"),
fieldWithPath("sellingStatus").type(JsonFieldType.STRING)
.optional()
.description("상품 판매 상태"),
fieldWithPath("name").type(JsonFieldType.STRING)
.description("상품 이름"),
fieldWithPath("price").type(JsonFieldType.NUMBER)
.description("상품 가격")
),
responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER)
.description("HTTP 상태 코드"),
fieldWithPath("status").type(JsonFieldType.STRING)
.description("상태"),
fieldWithPath("message").type(JsonFieldType.STRING)
.description("메시지"),
fieldWithPath("data").type(JsonFieldType.OBJECT)
.description("응답 데이터"),
fieldWithPath("data.id").type(JsonFieldType.NUMBER)
.description("상품 ID"),
fieldWithPath("data.productNumber").type(JsonFieldType.STRING)
.description("상품 번호"),
fieldWithPath("data.type").type(JsonFieldType.STRING)
.description("상품 타입"),
fieldWithPath("data.sellingStatus").type(JsonFieldType.STRING)
.description("상품 판매 상태"),
fieldWithPath("data.name").type(JsonFieldType.STRING)
.description("상품 이름"),
fieldWithPath("data.price").type(JsonFieldType.NUMBER)
.description("상품 가격")
)
));
}
} ProductControllerTest 의 createProduct 메서드를 API 문서화를 하는 과정을 진행하며 어떻게 적용할지 mockMvc 를 기준으로 정리하였다.
문서 내 API 항목 URL Slug 설정
document("product-create", ...
이렇게 작성하면 실제 렌더링된 화면에서 API 항목을 클릭 했을 때 docs/../product-create 로 설정된다.
요청 및 응답 JSON 가독성
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
이 코드를 덧붙이지 않으면 긴 JSON 형식이 한 줄로 표기되어 읽기 힘든 문서가 된다.
요청 값 정의
requestFields(
fieldWithPath("type").type(JsonFieldType.STRING)
.description("상품 타입"),
fieldWithPath("sellingStatus").type(JsonFieldType.STRING)
.optional()
.description("상품 판매 상태"),
fieldWithPath("name").type(JsonFieldType.STRING)
.description("상품 이름"),
fieldWithPath("price").type(JsonFieldType.NUMBER)
.description("상품 가격")
), queryParameters(
parameterWithName("page").description("The page to retrieve"),
parameterWithName("per_page").description("Entries per page")
) 응답 값 정의
responseFields(
fieldWithPath("code").type(JsonFieldType.NUMBER)
.description("HTTP 상태 코드"),
fieldWithPath("status").type(JsonFieldType.STRING)
.description("상태"),
fieldWithPath("message").type(JsonFieldType.STRING)
.description("메시지"),
fieldWithPath("data").type(JsonFieldType.OBJECT)
.description("응답 데이터"),
fieldWithPath("data.id").type(JsonFieldType.NUMBER)
.description("상품 ID"),
fieldWithPath("data.productNumber").type(JsonFieldType.STRING)
.description("상품 번호"),
...
)
응답 값을 정의할 때 JSON 의 하위 계층을 표시하기 위해서 fieldWithPath 메서드 시그니처를 “a.b” 로 명시해서 하위 계층을 표시할 수 있다.