백엔드 개발자와 프론트엔드 개발자 사이의 원활한 협업을 위해서는 API 명세에 대한 문서화가 필수이다. 이때 API 명세를 문서화할 수 있는 많은 애플리케이션들이 있지만 본인은 스프링을 사용해서 작업을 진행하고 있고 API 작업을 자동화할 수 있는 기능이 Spring REST Docs
를 통해서 진행할 수 있기 때문에 이를 사용해서 진행하려고 한다.
별도의 구성 없이 @SpringBootTest
와 같이 사용해야하고 이는 스프링의 전체 빈을 컨텍스트에 모두 띄워서 테스트 환경을 구동하는 방식으로 진행된다. 이는 애플리케이션을 실제 동작할 때와 같은 환경을 만들어서 테스트 작업을 진행하기 때문에 비용이 많이 들고 느리다는 단점이 있다.
MockMvc는 SpringBootTest
@WebMvcTest
둘중 하나를 선택해서 사용할 수 있다. @WebMvcTest
의 경우엔 필요한 빈들만 로드해서 사용하는 단위 테스트로 비용과 속도 문제가 개선된다. 이때 일반적으로 API의 경우엔 컨트롤러 테스트에서만 사용해도 무관하기 때문에 이를 사용하는것이 일반적이라고 한다.
Spring REST Docs
사용@MockMvc
사용Asciidoc
을 사용해 문서 작성을 진행<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-mockmvc</artifactId>
<version>{project-version}</version>
<scope>test</scope>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.asciidoctor</groupId>
<artifactId>asciidoctor-maven-plugin</artifactId>
<version>1.5.8</version>
<executions>
<execution>
<id>generate-docs</id>
<phase>prepare-package</phase>
<goals>
<goal>process-asciidoc</goal>
</goals>
<configuration>
<backend>html</backend>
<doctype>book</doctype>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-asciidoctor</artifactId>
<version>{project-version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>2.7</version>
<executions>
<execution>
<id>copy-resources</id>
<phase>prepare-package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>
${project.build.outputDirectory}/static/docs
</outputDirectory>
<resources>
<resource>
<directory>
${project.build.directory}/generated-docs
</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaloostore.shop.product.dto.response.ProductDetailViewResponse;
import com.yaloostore.shop.product.repository.dummy.ProductDetailViewResponseDummy;
import com.yaloostore.shop.product.service.inter.QueryProductService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static com.yaloostore.shop.docs.RestApiDocumentation.getDocumentRequest;
import static com.yaloostore.shop.docs.RestApiDocumentation.getDocumentsResponse;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureRestDocs(uriPort = 8081)
@WebMvcTest(QueryProductRestController.class)
class QueryProductRestControllerTest {
private final Long ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private QueryProductService service;
@WithMockUser
@DisplayName("상품 상세 조회 - 성공")
@Test
void getProductByProductId_success() throws Exception {
//given
ProductDetailViewResponse response = ProductDetailViewResponseDummy.dummy();
Mockito.when(service.getProductByProductId(anyLong())).thenReturn(response);
//when
ResultActions result = mockMvc.perform(get("/api/service/products/{productId}", ID)
.with(csrf())
.contentType(MediaType.APPLICATION_JSON));
result.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
verify(service, times(1)).getProductByProductId(ID);
// Spring REST Docs
result.andDo(document("find-detail-product",
getDocumentRequest(),
getDocumentsResponse(),
pathParameters(parameterWithName("productId").description("조회할 상품의 아이디")),
responseFields(
fieldWithPath("success")
.type(JsonFieldType.BOOLEAN)
.description("동작 성공 여부"),
fieldWithPath("status")
.type(JsonFieldType.NUMBER)
.description("상태"),
fieldWithPath("data.productId")
.type(JsonFieldType.NUMBER)
.description("상품 아이디"),
fieldWithPath("data.productName")
.type(JsonFieldType.STRING)
.description("상품명"),
fieldWithPath("data.thumbnail")
.type(JsonFieldType.STRING)
.description("상품 이미지"),
fieldWithPath("data.rawPrice")
.type(JsonFieldType.NUMBER)
.description("상품 가격"),
fieldWithPath("data.discountPrice")
.type(JsonFieldType.NUMBER)
.description("상품 할인 가격"),
fieldWithPath("data.discountPercent")
.type(JsonFieldType.NUMBER)
.description("상품 할인율"),
fieldWithPath("data.isSold")
.type(JsonFieldType.BOOLEAN)
.description("상품 품절 여부"),
fieldWithPath("data.quantity")
.type(JsonFieldType.NUMBER)
.description("상품 개수"),
fieldWithPath("data.description")
.type(JsonFieldType.STRING)
.description("상품 상세 정보"),
fieldWithPath("data.isbn")
.type(JsonFieldType.STRING)
.description("도서 ISBN"),
fieldWithPath("data.pageCnt")
.type(JsonFieldType.NUMBER)
.description("전체 페이지"),
fieldWithPath("data.publisherName")
.type(JsonFieldType.STRING)
.description("출판사"),
fieldWithPath("data.authorName")
.type(JsonFieldType.STRING)
.description("저자"),
fieldWithPath("errorMessages").type(JsonFieldType.ARRAY)
.description("에러 메세지")
.optional()
)
));
}
}
package com.yaloostore.shop.docs;
import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor;
import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
/**
* 명세 문서가 생성되기 직전에 일괄적으로 해당 명세에 대한 일종의 선행 처리과정을 정의한 클래스입니다.
* (선행 처리작업을 여기서 설정한 대로 진행 가능)
* */
public class RestApiDocumentation {
/**
* 요청 API에 대한 기본 도메인, 포트를 정의해준다.
* 처리된 결과를 PrettyPrint()를 통해서 깔끔하게 보여질 수 있게 했다.
* */
public static OperationRequestPreprocessor getDocumentRequest(){
return preprocessRequest(modifyUris().scheme("https")
.host("localhost")
.port(8081)
.removePort(), prettyPrint());
}
/**
* 처리된 결과에 동일하게 PrettyPrint()를 적용해서 결과를 깔금하게 보여줄 수 있게 했다.
* */
public static OperationResponsePreprocessor getDocumentsResponse(){
return preprocessResponse(prettyPrint());
}
}
asciidoctor
를 사용해야 한다..md
파일을 생각하면 된다./src/main/asciidoc/*.adoc
위치에 adoc 파일을 생성해서 작업해보자include::{snippets}/find-detail-product/response-body.adoc[]
각 API서비스 컨트롤러에 대한 TestCase를 수행합니다. 모든 TestCase가 성공하면 다음으로 넘어가고 실패하면 종료됩니다. 즉 빌드에 실패합니다.
애플리케이션을 빌드하면 /build/generated-snipet/에 TestCase단위 별 API스펙 명세서가 adoc파일로 자동 생성됩니다. 우리는 이것을 스니펫(Snippet)이라 부릅니다.
그 스니펫은 /src/docs/asciidoc/에 사용자가 정의한 aodc파일에 include 하여 하나의 문서형태로 편집할 수 있습니다.
3번 에서 정의한 adoc파일은 빌드 시점에 Asciidoctor라는 Task과정을 거쳐 /build/asciidoc/html5에 html문서 형태로 생성됩니다.
/build/asciidoc/html5 경로에 html형식의 API스펙문서로 저장됩니다.
📌출처: https://ahndy84.tistory.com/27
https://yeomylaoo.tistory.com/863
이곳에 내가 Spring REST Docs를 적용하며 발생한 오류에 대해서 작성해두었다.