쇼핑몰 만들기 프로젝트 - Spring에서 API를 자동화해보자

yeom yaloo·2023년 4월 2일
0

쇼핑몰

목록 보기
7/19


Maven + Spring REST Docs + Asciidoc + MockMvc Test

  • maven
  • Spring REST Docs : 테스트 작업에서만 문서화 작업 가능
  • MockMvc - @WevMvcTest를 사용한 단위 테스트로 테스트 작업 진행 Mockito도 함께 사용
  • 문서 작업은 asciidocs + asciidoctor으로 진행

API 문서 자동화

Spring REST Docs

Spring REST Docs를 사용한 API 문서화 자동화 작업 선택 이유

백엔드 개발자와 프론트엔드 개발자 사이의 원활한 협업을 위해서는 API 명세에 대한 문서화가 필수이다. 이때 API 명세를 문서화할 수 있는 많은 애플리케이션들이 있지만 본인은 스프링을 사용해서 작업을 진행하고 있고 API 작업을 자동화할 수 있는 기능이 Spring REST Docs를 통해서 진행할 수 있기 때문에 이를 사용해서 진행하려고 한다.

Swagger VS Spring REST Docs

  • 스웨거 역시 API문서 자동화를 진행하는 도구로 생성된 문서에서는 API를 직접 실행도 가능하다고 한다. 그러나 본인은 테스트 작업을 계속해서 모든 로직에서 진행하고 있고 이 상태라면 Spring REST Docs에서 제공하는 자동화 작업을 사용해도 될거란 생각에 이를(=Spring REST Docs) 선택하게 됐다.
  • Spring REST Docs를 선택한 또 다른 이유는 테스트 중심의 개발을 진행하기로 했고 테스트 중심의 작업을 진행하고 있기 때문에 이를 선택했고 결과적으로 테스트가 올바른 프로덕션 코드에만 작동을 하기 때문에 문서 작성이 보다 더 정확할 것이라고 생각했기 때문이다.

RestAssured VS MockMvc

RestAssured

별도의 구성 없이 @SpringBootTest와 같이 사용해야하고 이는 스프링의 전체 빈을 컨텍스트에 모두 띄워서 테스트 환경을 구동하는 방식으로 진행된다. 이는 애플리케이션을 실제 동작할 때와 같은 환경을 만들어서 테스트 작업을 진행하기 때문에 비용이 많이 들고 느리다는 단점이 있다.

MockMvc

MockMvc는 SpringBootTest @WebMvcTest 둘중 하나를 선택해서 사용할 수 있다. @WebMvcTest의 경우엔 필요한 빈들만 로드해서 사용하는 단위 테스트로 비용과 속도 문제가 개선된다. 이때 일반적으로 API의 경우엔 컨트롤러 테스트에서만 사용해도 무관하기 때문에 이를 사용하는것이 일반적이라고 한다.

문서작성

asciidoc

  • asciidoc 사용할 예정으로 이는 github의 readme.md 파일처럼 마크다운언어를 생각하면 된다. 문법적 차이가 있지만 이가 더 편리하게 사용할 수 있다고 해서 이를 사용할 것이다.

사용 방법(Maven)

들어가기에 앞서서 나의 문서 자동화 작업에 사용될 것들은 아래와 같다.

  • 본인은 메이븐을 사용하고 있다. 그래서 메이븐 사용방식을 넣는다.
  • Spring REST Docs 사용
    • 해당 작업은 테스트를 통해서만 API 문서가 생성되기 때문에 테스트 작업이 필수(컨트롤러 테스트)
    • 컨트롤러 테스트에서 단위 테스트를 진행하기 때문에 @MockMvc 사용
    • Asciidoc을 사용해 문서 작성을 진행

1. pom.xml 작성

<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>
  • Spring REST Docs의 WebMvcTest에서 사용할 의존성 추가
  • asciidoc 사용을 위한 플러그인 추가(rest doc 빌드)
<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>
  • static/docs 하위 디렉토리에 해당 rest doc이 작성될 것이다.
  • 파일이 생성되는 주소를 잡아주는 작업과 함께 jar 생성시 우리가 만들 rest api doc도 포함해주는 작업을 위해서 위의 설정을 추가해주는 것이다.

2. test 작성


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()
                    )
                ));


    }
}

2-1. @AutoConfigureRestDocs

  • 해당 애노테이션에 @AutoConfigureRestDocs(uriPort = ,uriScheme = , uriHost = )을 넣어주면 1순위로 작동한다.

api 명세에 선행으로 처리될 작업을 미리 정의해놓은 클래스

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());
    }

}
  • 2순위로 해당 uri가 탐색된다. (1순위 @AutoConfigureRestDocs에 적어둔 정보)
  • 3순위의 경우엔 그냥 defalut 값인 localhost:8080이라고 한다.
  • 이 클래스의 경우엔 @BeforeEach로 테스트가 실행되기 전에 작업할 수 있지만 모든 테스트마다 이를 넣기보단 한 번 정의하고 가져와 사용하는것이 나을 것으로 판단된다.

테스트 성공

테스트 성공 후 스니펫 생성

3.스니펫 생성 후 asciidoc 생성

  • 테스트가 성공적으로 완료 됐다면 스니펫(.adoc)이 생성될 것이고 우리는 이를 이용해서 asciidoc을 생성해야 한다.
  • asciidoc을 작성을 위해서는 asciidoctor를 사용해야 한다.

3-1. asciidoctor

  • 마크업 언어로 문서를 예쁘게 꾸며줄 때 사용하는 .md 파일을 생각하면 된다.
  • /src/main/asciidoc/*.adoc 위치에 adoc 파일을 생성해서 작업해보자
  • 생성된 스니펫을 아래와 같은 문법을 사용해서 가져올 수 있다.
    include::{snippets}/find-detail-product/response-body.adoc[]
    find-detail-product : 테스트 코드에서 정해준 document 이름
    response-body.adoc[] : 사용하고자하는 스니펫

3-2. 작성 완료된 모습

  • 인텔리제이가 추천한 툴을 사용하니 작성한 api 명세를 한번에 미리볼 수 있었다.

4. 배포

4-1. mvn install

  • 간단하게 인텔리제이에서 제공하는 버튼을 눌러서 install 작업을 해주면 2번과 같이 html 파일이 생긴다.

5.배포까지 완료해서 생성한 API를 확인하려면?


간단 정리

  1. 각 API서비스 컨트롤러에 대한 TestCase를 수행합니다. 모든 TestCase가 성공하면 다음으로 넘어가고 실패하면 종료됩니다. 즉 빌드에 실패합니다.

  2. 애플리케이션을 빌드하면 /build/generated-snipet/에 TestCase단위 별 API스펙 명세서가 adoc파일로 자동 생성됩니다. 우리는 이것을 스니펫(Snippet)이라 부릅니다.

  3. 그 스니펫은 /src/docs/asciidoc/에 사용자가 정의한 aodc파일에 include 하여 하나의 문서형태로 편집할 수 있습니다.

  4. 3번 에서 정의한 adoc파일은 빌드 시점에 Asciidoctor라는 Task과정을 거쳐 /build/asciidoc/html5에 html문서 형태로 생성됩니다.

  5. /build/asciidoc/html5 경로에 html형식의 API스펙문서로 저장됩니다.

📌출처: https://ahndy84.tistory.com/27


오류 관련 대처

https://yeomylaoo.tistory.com/863
이곳에 내가 Spring REST Docs를 적용하며 발생한 오류에 대해서 작성해두었다.

profile
즐겁고 괴로운 개발😎

0개의 댓글