[Spring] Spring REST Docs 사용하기

김강욱·2024년 5월 22일
0

Spring

목록 보기
14/17
post-thumbnail

이번 포스팅에서는 Spring Rest Docs의 사용법에 대해 알아보도록 하겠습니다.

DUKCODE님의 블로그 작성글을 보고 Spring REST Docs를 적용해보도록 해보겠습니다.

✏️ Spring REST Docs란?

Spring 진영에서의 대표적인 API 문서화 자동화 도구에는 Spring REST DocsSwagger가 있습니다.

Swagger는 API의 스펙을 정의하는 것이 목적이지만 Spring REST Docs는 API의 스펙을 정의하고 테스트하는 것이 목적이라고 합니다.

이전에는 Swagger를 많이 사용했다고 하나 Spring MVC의 테스트를 실행하지 않고 문서를 생성하기 때문에 실제로 API가 동작하는지 확인할 수 없다는 단점이 있습니다.

또한 Swagger@ApiOperation 등 문서화에 필요한 여러 어노테이션을 비즈니스 로직 코드에 작성하기 때문에 비지니스 로직 코드와 문서를 분리하기 어렵습니다.

Swagger와 달리Spring REST Docs는 코드에 비침투적이며, 테스트를 강제한다는 장점이 있습니다.

✏️ Spring REST Docs 사용해보기

Spring REST Docs의 작동 과정

테스트에서 spring-rest-docs-mockmvc 라이브러리를 사용하면 테스트 실행 시, 문서 조각인 스니펫을 얻을 수 있습니다. 개발자는 문서의 뼈대가 될 adoc 파일을 따로 작성하고, 여기서 테스트 결과로 나온 스니펫들을 포함시키면 됩니다.

이후 asciidoctor 태스크를 실행시킵니다. 해당 태스크는 adoc 파일과 스니펫을 조합해 html 형식으로 만들어 개발 문서를 완성시켜 주는 역할을 합니다.

테스트 후 asciidoctor 플러그인을 실행시키는 대신 Spring REST Docs 설정을 통해 해당 과정을 자동화해보겠습니다.


Spring REST Docs 설정

plugins {  
    // 생략
    id 'org.asciidoctor.jvm.convert' version '3.3.2' // (1)
}  
  
// 생략
  
configurations {  
    asciidoctorExt // (2)
    // 생략
}  
  
// 생략
  
ext {  
    set('snippetsDir', file("build/generated-snippets")) // (3)
    // 생략
}  
  
dependencies {  

    // 생략

    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' // (2)
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // (4)
}  

task.named('test') {
    outputs.dir snippetsDir // (3)
    // 생략
}

asciidoctor { // (5)
    inputs.dir snippetsDir
    configurations 'asciidoctorExt' // (2)
    depensOn test
}

bootJar { // (6)
    dependsOn asciidoctor

    from( "${asciidoctor.outputDir}") {
        into 'static/docs'
    }
}

  1. Asciidoctor 플로그인을 추가해줍니다. Asciidoctor 플러그인에 담긴 asciidoctor 태스크가 adoc 파일과 스니펫을 조합해 html로 변경해주는 역할을 합니다.

  2. dependencies 블럭에 asciidoctorExt로 라이브러리를 불러올 수 있도록 선언합니다. 해당 라이브러리는 개발자가 작성하는 adoc 파일에서 ``을 통해 스니펫 경로에 있는 스니펫들을 쉽게 불러올 수 있도록 합니다. 해당 라이브러리가 있다면 직접 경로를 입력하지 않아도 되어 편리하게 문서를 작성할 수 있습니다. 그리고 이 라이브러리를 asciidoctor 태스크에 적용합니다.

  3. 스니펫들이 저장될 snippetDir 변수를 설정합니다. snippetDir을 test의 outputs.dir로 설정해 스니펫들이 해당 경로에 저장되도록 설정합니다.

  4. MockMvc에 기반해서 스니펫을 뽑아낼 수 있도록 하는 라이브러리dependencies에 추가합니다. 만약 테스트 방식으로 MockMvc를 사용하지 않고 WebTestClientREST Assured를 사용하는 환경이라면 spring-restdocs-webtestclientspring-restdocs-restassured을 사용할 수 있습니다.

  5. adoc 파일을 html 파일로 변환시켜주는 asciidoctor 설정을 합니다. inputs.dir을 이전에 설정해둔 스니펫 경로인 snippetDir로 설정합니다. test 태스크에 의존하도록 depensOn 설정을 하여 asciidoctor 태스크를 실행하면 동작 수행 전에 test 태스크를 수행해서 스니펫을 새로 만들고 새로운 html 파일을 만들도록 설정합니다.

  6. bootJar 태스크를 실행하면 asciidoctor 태스크가 실행되게 합니다. 또한 asciidoctor 태스크는 test 태스크에 의존하고 있기 때문에 bootJar를 실행하게 되면 test - asciidoctor - bootJar 순서로 실행됩니다. 또한 html로 만들어진 문서를 기본 outputDirbuild/docs/asciidoc에서 static/docs로 복사해 배포 시 /docs/** URL로 접속해 문서를 확인할 수 있게 합니다.

추가 설정

기존 방식대로라면 문서를 작성하고 local에서 Intellij run을 돌려봐도 작성한 문서가 반영되지 않습니다. test 태스크가 진행될 때 asccidoctor 태스크가 실행되고 결과물을 /static/docs로 복사해오도록 변경해보겠습니다. 그러면 테스트를 돌리고 intellij run을 하면 우리가 작성한 문서가 반영됩니다.

plugins {  
    // 생략
    id 'org.asciidoctor.jvm.convert' version '3.3.2'
}  
  
// 생략
  
configurations {  
    asciidoctorExt
    // 생략
}  
  
// 생략
  
ext {  
    set('snippetsDir', file("build/generated-snippets"))
    // 생략
}  
  
dependencies {  

    // 생략

    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}  
  
// 생략

  tasks.named('testClasses') { // (1)
    doFirst {
        delete file('build/docs/asciidoc')
    }
}

tasks.named('test') {  
    outputs.dir snippetsDir
    // 생략
    finalizedBy asciidoctor // (2)
}  
  
tasks.named('asciidoctor') {
    dependsOn test  
    configurations 'asciidoctorExt'  
    inputs.dir snippetsDir  
    finalizedBy copyDocument // (3)
    doFirst { // (4)
        delete file('src/main/resources/static/docs')  
    }  
}  
  
task copyDocument(type: Copy) { // (5)
    dependsOn asciidoctor  
    from file('build/docs/asciidoc')  
    into file('src/main/resources/static/docs')  
}

bootJar {  // (6)
    dependsOn asciidoctor  
    doFirst {  
        delete file('static/docs')  
    }  
    from("${asciidoctor.outputDir}") {  
        into 'static/docs'  
    }  
}

  1. 개별 클래스 테스트가 진행되기 전에 이전에 생성되었던 html 파일을 삭제하도록 합니다. 만약 파일명을 바꾸거나 위치를 옮길 때, 바꾸기 전의 html파일이 원래 위치에 그대로 존재하는 것을 방지하기 위해 설정합니다.

2.test 태스크가 완료되면 asciidoctor 태스크가 실행되도록 finalizedBy를 통해 설정합니다.

  1. asciidoctor 태스크가 끝나면 새로 작성한 태스크인 copyDocument가 실행되도록 설정합니다.

  2. asciidoctor 태스크가 시작하기 전 static/docs에 있는 이전 문서들을 삭제합니다.

  3. asciidoctor 태스크의 결과물을 static/docs로 옮깁니다. 따라서 intelliJ run 시에 /docs/** URL로 API 문서에 접근할 수 있습니다.

  4. 기존 bootJar 태스크 설정에 기존 html 파일을 삭제하는 로직을 추가합니다. build/resource/main/static/docs 하위에 존재하는 이전 빌드의 파일을 삭제합니다. 만약 파일의 위치를 변경하거나 이름을 변경했을 때 삭제 설정을 하지 않으면 기존의 파일이 그대로 남아있습니다.


테스트 코드 작성

테스트 코드 기본 작성법은 MockMvcandDo() 메서드 안에 RestDocumentRequestHandler를 생성하고 작성하고 싶은 내용을 넣으면 됩니다.

공식 문서의 Document your API 섹션에 RestDocumentRequestHandler에 내용을 채우는 방법에 대해 나와있으니 참고하시면 됩니다.

먼저 @AutoConfigureRestDocs를 설정해서 MockMvc에서 Spring REST Docs를 사용할 수 있도록 설정해줍니다. 그러면 andDo() 메서드 안에 문서의 내용을 작성할 수 있습니다.

예시 코드

package com.doggyWalky.doggyWalky.member.controller;

import com.doggyWalky.doggyWalky.security.redis.RedisService;
import com.fasterxml.jackson.databind.ObjectMapper;
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.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.transaction.annotation.Transactional;


import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureRestDocs
@AutoConfigureMockMvc
@Transactional
class MemberControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @MockBean
    protected RedisService redisService;

    @Autowired
    private UserDetailsService userDetailsService;

    private String email = "BLv8Uug7klqfirsmyHa/q1mzxA8nma90rdYVZdc60fY=";

    @Test
    public void removeToken_200() throws Exception {
        // given
        UserDetails userDetails = userDetailsService.loadUserByUsername(email);
        // Mock 객체 설정 - redisService 목 객체에 getRefreshToken() 메소드를 호출할 시 Null 값을 반환하도록 설정
        Mockito.when(redisService.getRefreshToken(Mockito.anyString())).thenReturn(null);

        // when
        mockMvc.perform(get("/removeToken")
                        .with(SecurityMockMvcRequestPostProcessors.user(userDetails)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.memberId").value(24L))
                .andDo(MockMvcResultHandlers.print()) // 요청, 응답 출력
                .andDo(MockMvcRestDocumentation.document("{class-name}/{method-name}", // 문서 이름 설정
                        preprocessRequest(
                                modifyHeaders() // 헤더 내용 수정
                                        .remove("Content-Length")
                                        .remove("Host"),
                                prettyPrint()), // 한 줄로 출력되는 json에 pretty 포멧 적용
                        preprocessResponse(
                                modifyHeaders()
                                        .remove("Content-Length")
                                        .remove("X-Content-Type-Options")
                                        .remove("X-XSS-Protection")
                                        .remove("Cache-Control")
                                        .remove("Pragma")
                                        .remove("Expires")
                                        .remove("X-Frame-Options"),
                                prettyPrint()),
                        responseFields( // 응답 필드 추가
                                fieldWithPath("memberId")
                                        .type(JsonFieldType.NUMBER)
                                        .description("멤버 고유 번호")
                        )
                ));

        // then
        // Mock 객체 호출 확인 - verify를 이용하여 redisService의 removeRefreshToken(refreshTokenKey)가 실제로 호출되었는지 확인합니다.
        // 실제로는 IP 주소가 계속 바뀌므로 IP 주소는 동일하다는 가정하에 removeRefreshToken이 호출되었는지에 대한 테스트를 진행하였습니다.
        Mockito.verify(redisService).removeRefreshToken(anyString());
        // Mock 객체 설정에 의해 Null 값 반환 확인하는 작업입니다.
        assertNull(redisService.getRefreshToken("refreshTokenKey"));
    }
}

테스트 코드를 작성하고 실행하면 build/generated-snipets 아래 adoc 형식의 스니펫이 생성됩니다.

위의 스니펫들을 작성할 뼈대 adoc에서 include해서 사용하면 됩니다.

테스트 코드 리팩토링

전체 API에서 공통적으로 사용되어야 될 내용을 분리해서 코드를 간결하게 리팩토링해보도록 하겠습니다.

공통으로 처리해주어야할 내용은 다음과 같습니다

  • 요청과 응답 JSON에 pretty format을 적용하는 부분
  • 문서 이름을 처리해주는 부분
  • header를 숨기는 부분
  • ObjectMapper, MockMvc 등 API 문서 작성 필수 클래스 선언 부분
  • 요청, 응답 print 적용
  • 추가 속성 지정의 객체 생성 반복 코드

RestDocumentationRequestHandler

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler;

import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;

@TestConfiguration
public class RestDocsConfiguration {

    @Bean
    public RestDocumentationResultHandler restDocumentationResultHandler() {
        return MockMvcRestDocumentation.document(
                "{class-name}/{method-name}",  // 문서 이름 설정
                preprocessRequest(  // 공통 헤더 설정
                        modifyHeaders()
                                .remove("Content-Length")
                                .remove("Host"),
                        prettyPrint()),  // pretty json 적용
                preprocessResponse(  // 공통 헤더 설정
                        modifyHeaders()
                                .remove("Content-Length")
                                .remove("X-Content-Type-Options")
                                .remove("X-XSS-Protection")
                                .remove("Cache-Control")
                                .remove("Pragma")
                                .remove("Expires")
                                .remove("X-Frame-Options"),
                        prettyPrint())    // pretty json 적용
        );
    }
}

test 디렉토리 아래에 테스트 전용 설정 파일을 구성합니다. 문서 이름, 공통 헤더 설정, json pretty print에 대한 설정을 세팅해줍니다.

지우고 싶은 헤더도 설정하실 수 있습니다.


RestDocsTestSupport

import com.doggyWalky.doggyWalky.config.SecurityConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler;
import org.springframework.restdocs.snippet.Attributes;
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@Disabled
@Import({RestDocsConfiguration.class, SecurityConfig.class})
@ExtendWith({RestDocumentationExtension.class})
@AutoConfigureMockMvc
public class RestDocsTestSupport {

    @Autowired
    protected RestDocumentationResultHandler restDocs;
    @Autowired
    protected MockMvc mockMvc;
    @Autowired
    protected ObjectMapper objectMapper;

    protected static Attributes.Attribute constraints( // contraints Attribute 간단하게 추가
                                            final String value) {
        return new Attributes.Attribute("constraints", value);
    }


    @BeforeEach
    void setUp(final WebApplicationContext context,
               final RestDocumentationContextProvider provider) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(MockMvcRestDocumentation.documentationConfiguration(provider))
                .apply(SecurityMockMvcConfigurers.springSecurity()) // Security 설정 추가
                .alwaysDo(MockMvcResultHandlers.print()) // print 적용
                .alwaysDo(restDocs) // RestDocsConfiguration 클래스의 bean 적용
                .build();
    }
}

어노테이션을 한번 살펴보도록 하겠습니다.

@Disabled는 테스트 할 클래스에서 해당 클래스를 제외시켜주는 역할을 합니다.

@ImportConfig 파일에 등록된 스프링 빈을 사용할 수 있도록 해주는 역할을 합니다. 위에서 작성한 RestDocsConfiguration을 추가해줍니다.

@ExtendWithRestDocumentationExtension을 설정하여 context를 제공해서 Spring REST Docs가 잘 동작하도록 해줍니다.

이 클래스를 상속한 클래스가 MockMvcObjectMapper를 선언할 필요 없이 사용이 가능하도록 설정해줬습니다.

또한 Attribute를 간단하게 추가할 수 있도록 메서드를 추가해줍니다.

@BeforeEach에서 MockMvc의 커스터마이즈를 진행하고 있습니다.RestDocsConfiguration에서 설정했던 RestDocumentationResultHandler를 적용하고, 테스트 시 요청과 응답을 출력할 수 있도록 print()메서드도 적용하게 됩니다.

그리고 mockMvc 설정에 스프링 시큐리티 설정을 추가해줌으로써 사용자를 등록해서 Principal 객체에 사용자 정보를 빼낼 수 있게 해주었습니다.

RestDocsTestSupport 상속받기

package com.doggyWalky.doggyWalky.member.controller;

import com.doggyWalky.doggyWalky.common.RestDocsTestSupport;
import com.doggyWalky.doggyWalky.security.redis.RedisService;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.transaction.annotation.Transactional;


import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@Transactional
class MemberControllerTest extends RestDocsTestSupport {


    @MockBean
    protected RedisService redisService;

    @Autowired
    private UserDetailsService userDetailsService;

    private String email = "BLv8Uug7klqfirsmyHa/q1mzxA8nma90rdYVZdc60fY=";

    @Test
    public void removetoken_200() throws Exception {
        // given
        UserDetails userDetails = userDetailsService.loadUserByUsername(email);

        System.out.println("getUserName() :" + userDetails.getUsername());
        // Mock 객체 설정 - redisService 목 객체에 getRefreshToken() 메소드를 호출할 시 Null 값을 반환하도록 설정
        Mockito.when(redisService.getRefreshToken(Mockito.anyString())).thenReturn(null);

        // when
        mockMvc.perform(get("/removeToken")
                        .with(SecurityMockMvcRequestPostProcessors.user(userDetails)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.memberId").value(24L))
                .andDo(restDocs.document(
                        responseFields( // 응답 필드 추가
                                fieldWithPath("memberId")
                                        .type(JsonFieldType.NUMBER)
                                        .description("멤버 고유 번호")
                                        .attributes()
                        )
                ));

        // then
        // Mock 객체 호출 확인 - verify를 이용하여 redisService의 removeRefreshToken(refreshTokenKey)가 실제로 호출되었는지 확인합니다.
        // 실제로는 IP 주소가 계속 바뀌므로 IP 주소는 동일하다는 가정하에 removeRefreshToken이 호출되었는지에 대한 테스트를 진행하였습니다.
        Mockito.verify(redisService).removeRefreshToken(anyString());
        // Mock 객체 설정에 의해 Null 값 반환 확인하는 작업입니다.
        assertNull(redisService.getRefreshToken("refreshTokenKey"));
    }
}

다음과 같이 코드가 간단해졌습니다.

커스텀 스니펫 만들기

스니펫은 기본 템플릿을 통해 생성됩니다. 커스텀 스니펫 템플릿을 만들어 보도록 하겠습니다.

커스텀 스니펫은 src/test/resources/org/springframework/restdocs/templates 디렉토리 하위에 작성하면 됩니다.

request-fields.snippet파일을 위의 경로에 만들어봅시다. 그러면 디폴트 스니펫 템플릿 대신 작동하게 됩니다. 다음과 같이 어트리뷰트를 추가해봅시다.

|===  
|필드명|타입|필수여부|제약조건|설명  
{{#fields}}  
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}  
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}  
|{{#tableCellContent}}{{^optional}}O{{/optional}}{{#optional}}X{{/optional}}{{/tableCellContent}}  
|{{#tableCellContent}}{{#constraints}}{{.}}{{/constraints}}{{/tableCellContent}}  
|{{#tableCellContent}}{{description}}{{/tableCellContent}}  
{{/fields}}  
|===

테스트를 실행해보면 다음과 같이 스니펫 템플릿이 제대로 적용된 것을 확인할 수 있습니다.

문서 작성

이제 스니펫들을 include하여 뼈대 문서를 작성해봅시다. 문서의 경로는 src/docs/asciidoc 디렉토리를 생성해 작성하면 됩니다.

AsciiDoctor 가이드 공식 문서를 참고하시면 됩니다.

member.adoc 예시

= Connect API Document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2

== Member 관련 API

=== 로그아웃

==== 요청

include::{snippets}/member-controller-test/removeToken_200/http-request.adoc[]

==== 요청 필드

include::{snippets}/member-controller-test/removetoken_200/request-fields.adoc[]

==== 응답

include::{snippets}/member-controller-test/removetoken_200/http-response.adoc[]

==== 응답 필드

include::{snippets}/member-controller-test/removetoken_200/response-fields.adoc[]

위와 같이 작성하고 gradle 테스트를 돌려 html 문서를 업데이트하고 서버를 실행해 /docs/member.html로 접속해보겠습니다.

정상적으로 잘 나오는 것을 확인하실 수 있습니다.


참고자료
DUKCODE님의 블로그
gudcks0305님의 블로그

profile
TO BE DEVELOPER

0개의 댓글

관련 채용 정보