파란하늘의 지식창고
article thumbnail
Published 2019. 6. 14. 14:23
Spring Rest Docs 사용해보기 Study/Java
반응형

Spring Rest Docs 2.0.5.RELEASE 기준으로 작성된 글

maven 빌드 기준 설명임


Spring REST Docs 소개

Sprig Rest Docs 문서

 

Spring REST Docs

Document RESTful services by combining hand-written documentation with auto-generated snippets produced with Spring MVC Test.

docs.spring.io

Spring Rest Docs는 asciidoc 개발 문서 작성에 spring web 요청/응답 결과에 대한 문서 자동화를 지원해주는 라이브러리이다.

예전에 spring boot + swagger기반의 라이브러리인 springfox swagger를 사용해서 api 문서 자동화를 해본 적이 있었다.

문제는 해당 라이브러리의 릴리즈 속도가 너무 느려서 spring boot의 새 릴리즈를 적용하면 추가되거나 변경된 사항에 대해 springfox swagger 라이브러리의 지원이 늦어 프로젝트 버전 변경에 어려움이 있었다. (+ 성능 문제도 있긴 했음)

자바 개발 코드에 얹어서 문서를 작성해주는 방식의 라이브러리들은 늘 이런 문제들이 존재한다.

그리고 실제 개발 코드 대비 문서화 작업을 위한 주석/어노테이션 코드가 도배가 되면서 개발 코드 / 문서 코드를 구분해서 읽는 비용이 늘어나는 문제가 있었다.

문서 관련 코드와 개발 코드를 같은 파일에 관리하는 것은 (문서를 작성하는데 들어가는 영역 비중이 확연하게 적지 않은 한) 좋지 않은 방식인 것 같다.

wiki에 따로 개발 문서를 작성해두어도 시간이 지나면 결국 개발이 바뀌면 문서의 내용과 일치되지 않아 어려움이 있었고 이를 극복하기 위한 방법으로 해당 git 프로젝트의 readme문서에 markdown 문법으로 개발 문서를 작성해보기도 했지만 결국 관리하는 저장소만 같을 뿐 동일한 문제가 발생했다.

Spring Rest Docs는 현재로선 그나마 가장 나은 방법이 아닌가 싶어 선택해보았다.

Spring Rest Docs를 사용하려면 Asciidoc을 작성하는 방법부터 알아야 한다.

Asciidoc은 markdown과 매우 흡사한 문법을 가지고 있지만 가장 큰 차이점은 source를 include 할 수 있다는 점이다.

문서 결과물을 compile 해서 만들기 때문에 가지게 되는 강점이고 이로 인해 문서상 개발 코드를 작성하지 않고 실제 java, xml, properties 등등 여러 자원에 있는 내용을 가져와 보여줄 수 있어 실제 코드의 변경이 문서에 바로 반영할 수 있게 된다.

Spring Rest Docs는 이러한 Asciidoc의 장점을 활용한 라이브러리이다.

spring의 mvc/webflux test 코드를 통해 요청과 응답에 대한 asciidoc 문서들을 생성해주고 해당 문서들 개발 문서에서 include 하여 사용한다.

요약하면

  1. mvn package 실행 시
  2. test 코드가 수행되며 spring rest docs가 test 요청/응답에 대한 snippet을 /target/generated-snippets에 생성
  3. 미리 작성한 asciidoc 개발 문서에 2에서 생성된 문서에 대한 include가 선언되어 있으면 문서가 병합되어 만들어짐
    개발 문서는 /src/main/asciidoc에 작성
    결과물은 /target/generated-docs에 생성

개발 코드에 얹어서 문서 자동화해주는 것만큼 자동화가 되는 과정은 아니지만 자유롭게 개발 문서를 작성하고 그 문서에 include 되는 개발 코드는 실제 결과물이기 때문에 개발 코드와 문서의 불일치 문제가 어느 정도 해소된다.

maven 설정하기

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.springframework.restdocs</groupId>
    <artifactId>spring-restdocs-mockmvc</artifactId>
    <scope>test</scope>
</dependency>

test code 실행 시 asciidoc snippet을 생성하기 위한 dependency 설정은 위와 같다.

junit5를 사용하는 경우의 예시이며 만약 현재 spring이 기본 참조하는 junit4를 사용하려면 jupiter 관련 설정 부분은 생략하면 된다.

해당 빌드를 수행할 plugin 설정은 다음과 같다.

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
        <plugin>
            <groupId>org.asciidoctor</groupId>
            <artifactId>asciidoctor-maven-plugin</artifactId>
            <version>${asciidoctor.maven.plugin.version}</version>
            <dependencies>
                <dependency>
                    <groupId>org.springframework.restdocs</groupId>
                    <artifactId>spring-restdocs-asciidoctor</artifactId>
                    <version>${spring-restdocs.version}</version>
                </dependency>
            </dependencies>
            <executions>
                <execution>
                    <id>asciidoc-to-html</id>
                    <phase>prepare-package</phase>
                    <goals>
                        <goal>process-asciidoc</goal>
                    </goals>
                    <configuration>
                        <backend>html5</backend>
                        <sourceHighlighter>coderay</sourceHighlighter>
                        <preserveDirectories>true</preserveDirectories>
                        <outputDirectory>src/main/resources/static</outputDirectory>
                        <attributes>
                            <java-version>${java.version}</java-version>
                            <spring-restdocs-version>${spring-restdocs.version}</spring-restdocs-version>
                            <basedir>${project.basedir}</basedir>
                        </attributes>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

attributes의 값은 asciidoc에서 쓰기 위해 설정한 내용으로 생략해도 무방하다.

기본 build시 /target/generated-docs에 생성되던 걸 html로 빌드하고 web으로 바로 띄우려고 src/main/resource/static으로 변경하였다.

이렇게 설정을 하였으면 /src/main/asciidoc 에 adoc 확장자의 asciidoc 문서를 만들고 mvn package를 실행하면 해당 output directory에 결과물이 생성되는 것을 확인할 수 있다.

문서 작성 시 쓰이는 asciidoc 문법은 이 곳을 참고하면 된다.

test 코드 작성하기

웹 요청에 대한 테스트 코드에 대해 spring rest docs는 3가지 방식 중 선택하면 된다.

  • MockMvc (servlet 기반 테스트 코드 작성)
  • WebTestClient (reactive 기반 테스트 코드 작성)
  • REST Assured (좀 더 편한 rest 테스트 코드 작성 라이브러리)

각자의 환경에 맞게 해당 테스트 코드 작성 방식을 선택하면 된다.

테스트 코드를 작성하는 예제는 유형별로 Spring Rest Docs 문서에 자세하게 나와있다.

해당 설명을 따라 하면 된다.

테스트 코드가 작성되고 정상적으로 동작하게 되면 target/generated-snippets에 빌드된 결과물이 생성된다.

아래의 스샷은 이렇게 생성된 하나의 예이다.

이 결과물을 작성할 문서에 추가하면 된다.

문서의 default 위치는 /src/main/asciidoc이다.

= blogArticle API

== create

=== curl-request

include::{snippets}/blogArticle/create/curl-request.adoc[]

=== httpie-request

include::{snippets}/blogArticle/create/httpie-request.adoc[]

=== http-request

include::{snippets}/blogArticle/create/http-request.adoc[]

=== path-parameters

include::{snippets}/blogArticle/create/path-parameters.adoc[]

=== request-body

include::{snippets}/blogArticle/create/request-body.adoc[]

=== http-response

include::{snippets}/blogArticle/create/http-response.adoc[]

=== response-body

include::{snippets}/blogArticle/create/response-body.adoc[]

=== response-fields

include::{snippets}/blogArticle/create/response-fields.adoc[]

매번 include가 각 api 별로 7~8개씩 생기기 때문에 이를 좀 더 편리하게 include 할 수 있는 operations macro를 spring restdocs가 제공한다.

docs.spring.io/spring-restdocs/docs/current/reference/html5/#working-with-asciidoctor-including-snippets-operation

이를 사용하면 위의 예제는 아래와 같이 사용할 수 있다.

= blogArticle API

== create

operation::/blogArticle/create[snippets='curl-request,httpie-request,http-request,path-parameters,request-body,http-response,response-body,response-fields']

custom snippet template 사용하기

docs.spring.io/spring-restdocs/docs/current/reference/html5/#documenting-your-api-customizing-snippets

spring이 제공하는 default snippet을 참고하여 수정하면 된다.

spring restdocs의 default snippet

default-response-field.snippet은 아래와 같다.

|===
|Path|Type|Description

{{#fields}}
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}

{{/fields}}
|===

src/test/resources/org/springframework/restdocs/templates/asciidoctor 위치에 response-field.snippet 파일을 만들어 Nullable 칼럼을 추가한 예이다.

|===
|Path|Type|Nullable|Description

{{#fields}}
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{optional}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}

{{/fields}}
|===

이렇게 custom snippet template을 만들면 원하는 형태로 snippet을 바꿀 수 있다.

인증 처리 테스트 코드 작성하기

Spring Rest Docs의 문서대로 잘 따라 해서 snippet 문서가 잘 생성되는 것까지는 되었지만 해당 문서에는 로그인한 경우에 대한 설명이 없다.

로그인한 유저의 경우를 테스트하고 싶은 경우 위 dependency 참조 설명에 있는 spring-security-test를 추가한 후 다음과 같이 SecurityMockMvcConfigurer를 추가해주어야 한다.

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;

// import 생략

@ExtendWith({ RestDocumentationExtension.class, SpringExtension.class })
@SpringBootTest(classes = TestApplication.class)
public abstract class GeneralTest {
	
    protected ManualRestDocumentation restDocumentation = new ManualRestDocumentation();

    protected MockMvc mockMvc;
	
    @BeforeEach
    public void setUp(WebApplicationContext webApplicationContext,
            RestDocumentationContextProvider restDocumentation) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .apply(springSecurity())
                .apply(
                    documentationConfiguration(restDocumentation)
                        .operationPreprocessors()
                        .withRequestDefaults(modifyUris().host("test.com").removePort(), prettyPrint())
                        .withResponseDefaults(prettyPrint())
                        )
                .alwaysDo(MockMvcResultHandlers.print())
                .build();
	}
}

위 설정에서 .apply(springSecurity()) 부분을 추가해야 로그인에 대한 인증처리 테스트가 가능하다.

인증을 처리하는 경우 request header의 cookie에 로그인된 인증 쿠키를 추가한다.

쿠키를 획득하는 부분은 인증을 어떻게 사용하는가에 따라 개별 구현을 해야 하여 여기서는 별도의 설명을 하지는 않는다.

this.mockMvc.perform(
    get("/api/blogArticle/{id}", 1)
        .contentType(MediaType.APPLICATION_JSON)
        .cookie(getLoginCookie())	// 인증을 처리하는 부분을 개별 구현하여 cookie를 설정
)
    .andExpect(status().isOk())
    .andDo(document("blogArticle/findById", 
        pathParameters(parameterWithName("id")
            .description("blogArticle Id")), responseFields(blogArticleFields)
            .andWithPrefix("blog.", blogFields)));

@MockBean의 사용

기본적인 테스트 코드는 문제없이 작성이 가능하지만 1회성 테스트의 경우 작성된 코드가 2회 이상 실행될 때 에러가 발생하게 된다.

예를 들어 저장하기 요청을 테스트하는 경우 중복된 저장을 허용하지 않는 경우 매번 기존 테스트 코드를 수행하면서 저장된 데이터를 삭제 처리를 하는 식으로 만드는 것도 복잡하다.

@MockBean은 선언된 호출에 대해 설정된 값을 반환하게 해 주어 이런 문제를 방지한다.

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
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.payload.PayloadDocumentation.subsectionWithPath;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// import 생략

// 아래는 테스트 class의 하위 내용임

@MockBean
private SomeService someService;

@Test
@SneakyThrows
public void save() {
    ResultSomeObject result = new ResultSomeObject();
    //...  예상 반환 결과 설정
	
    // mock 설정
    given(someService.save(any())).willReturn(result);

    MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.add("paramA", "paramAValue");
    params.add("paramB", "paramBValue");
    params.add("paramC", "paramCValue");
	
		
    this.mockMvc.perform(
            post("/someObject")
                .accept(MediaType.APPLICATION_JSON)
                .params(params)
        )
        .andExpect(status().isOk())
        .andDo(
            document("someObject",
                pathParameters(),
                requestParameters(
                    parameterWithName("paramA").description("param A 값"),
                    parameterWithName("paramA").description("param B 값"),
                    parameterWithName("paramA").description("param C 값")
                ),
                responseFields(
                    fieldWithPath("idx").type(JsonFieldType.NUMBER).description("idx"),
                    fieldWithPath("name").type(JsonFieldType.STRING).description("이름"),
                    fieldWithPath("savedata").type(JsonFieldType.STRING).description("저장 데이터")
                );
            )
        );	
}

response field 중첩 관리하기

예를 들어 응답이 다음과 같은 결과라고 가정하자.

{
    "id": 1,
    "blog": {
        "id": "97052572-3309-4fee-921f-473c59aa922e",
        "userId": "974c8f3a-cab9-4150-824a-fb6e4ae12405",
        "createdDate": "2020-09-29T10:32:29"
    },
    "title": "tewste",
    "content": "asdfasdfasdfasdfasdf",
    "createdDate": "2021-01-06T14:35:51",
    "lastModifiedDate": "2021-03-29T15:39:58",
    "userId": "974c8f3a-cab9-4150-824a-fb6e4ae12405",
    "viewCount": 56,
    "blogCommentCount": 0,
    "blogArticleCategory": null
}

이 응답의 경우 response field를 작성하면 대략 다음과 같을 것이다.

    private static FieldDescriptor[] blogArticleFields = new FieldDescriptor[] {
            fieldWithPath("id").type(JsonFieldType.NUMBER).description("blogArticle Id"),
            fieldWithPath("blog.id").type(JsonFieldType.STRING).description("blog Id"),
            fieldWithPath("blog.userId").type(JsonFieldType.STRING).description("유저 Id"),
            fieldWithPath("blog.createdDate").type(JsonFieldType.STRING).description("유저 가입일"),
            fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
            fieldWithPath("content").type(JsonFieldType.STRING).description("내용"),
            fieldWithPath("createdDate").type(JsonFieldType.STRING).description("생성일"),
            fieldWithPath("lastModifiedDate").type(JsonFieldType.STRING).description("수정일"),
            fieldWithPath("userId").type(JsonFieldType.STRING).description("유저 Id"),
            fieldWithPath("viewCount").type(JsonFieldType.NUMBER).description("조회수"),
            fieldWithPath("blogCommentCount").type(JsonFieldType.NUMBER).description("댓글수"),
            subsectionWithPath("blogArticleCategory").description("blogArticle category 정보 참조") };

위 코드는 blog 객체에 대해 재사용성이 떨어진다.

각 객체별로 field 정의를 한 후 객체가 다른 객체를 가지고 있는 경우 response에 .andWithPrefix 선언을 사용하여 재사용성을 높인다.

    private static FieldDescriptor[] blogFields = new FieldDescriptor[] {
            fieldWithPath("id").type(JsonFieldType.STRING).description("blog Id"),
            fieldWithPath("userId").type(JsonFieldType.STRING).description("유저 Id"),
            fieldWithPath("createdDate").type(JsonFieldType.STRING).description("유저 가입일") };

    private static FieldDescriptor[] blogArticleFields = new FieldDescriptor[] {
            fieldWithPath("id").type(JsonFieldType.NUMBER).description("blogArticle Id"),
            fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
            fieldWithPath("content").type(JsonFieldType.STRING).description("내용"),
            fieldWithPath("createdDate").type(JsonFieldType.STRING).description("생성일"),
            fieldWithPath("lastModifiedDate").type(JsonFieldType.STRING).description("수정일"),
            fieldWithPath("userId").type(JsonFieldType.STRING).description("유저 Id"),
            fieldWithPath("viewCount").type(JsonFieldType.NUMBER).description("조회수"),
            fieldWithPath("blogCommentCount").type(JsonFieldType.NUMBER).description("댓글수"),
            subsectionWithPath("blogArticleCategory").description("blogArticle category 정보 참조") };

    @Test
    @SneakyThrows
    public void findById() {
        given(blogArticleController.findById(any())).willReturn(result);
        
        this.mockMvc.perform(get("/api/blogArticle/{id}", 1).contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(document("blogArticle/findById",
                        pathParameters(parameterWithName("id").description("blogArticle Id")),
                        responseFields(blogArticleFields).andWithPrefix("blog.", blogFields)));
    }

 

생각해야 할 부분

처음으로 만들어보는 개발 문서 작업이라 rest docs 문서대로 따라 하였다.

그러다 보니 전문화된 코드 개발을 하진 못했다.

좀 더 찾아보고 진행했으면 더 나은 방식을 사용하지 않았을까 싶긴 하다.

@MockBean을 좀 더 편하게 쓰는 방법은 없을까?

@MockBean을 사용하면 test에 사용된 해당 객체의 모든 메서드를 mock처리를 해야 하는 불편함이 있다.

@SpyBean을 쓰면 대상 메서드만 처리하고 나머진 실제 호출을 쓰게 된다는데 안되더라..

mock 리턴 값 설정은 위 예제 코드엔 설명하지 않았지만 jacksonMapper로 json 파일을 직접 읽어서 처리하는 게 관리하기 편했다.

Spring Test를 더 빠르게 만드는 방법은 없을까?

실제 spring test code로 snippet 문서를 만드는 방식인데 test class마다 매번 spring life cycle을 생성해서 빌드에 시간이 오래 걸렸다.

어차피 전체 검증이 주목적이 아니고 코드의 동작을 통한 문서 생성이 주라면 차라리 맨 마지막 Controller 요청 처리에 대해서만 호출하여 응답을 아예 모두 mock으로 사용하는 게 낫지 않았을까 싶기도 하다.

로그인 처리를 편하게 테스트 코드화 하는 방법은 없을까?

이 부분도 생각해야 할 부분이다.

현재는 매번 로그인을 따로 하고 해당 세션 쿠키를 가져와 테스트하는 형태로 작성했는데 자동화하는 걸 고민해봐야겠다.

security의 @MockUser로도 해결을 할 수 없는 부분이었다.

아마 개별 인증 구현을 한경우 MockUser 사용하기 같은 게 있지 않을까 싶긴 하다.

또는 restTemplate으로 로그인을 처리하고 응답 값에서 인증 쿠키를 획득하는 식으로 할 수도 있지 않을까 싶다.

문서 버전 관리를 체계적으로 하는 방법은?

물론 기존에 생성된 버전의 문서를 따로 관리하는 방법으로 관리할 수 있지만 현재까지 사용한 방법은 asciidoc 간 include처리를 하는 간단한 방법만 사용했다.

그리고 adoc 확장자를 link 한 경우 자동으로 html로 변환해준다거나 하는 식의 처리는 아직 모르겠다. (왠지 그런 것도 가능할 거 같다.)

문서를 좀 더 효율적으로 사용하는 방법도 있을 거 같은데 이 부분도 찾아봐야겠다.

반응형
profile

파란하늘의 지식창고

@Bluesky_

내용이 유익했다면 광고 배너를 클릭 해주세요