이번 포스팅에서는 Spring Rest Docs
의 사용법에 대해 알아보도록 하겠습니다.
DUKCODE님의 블로그 작성글을 보고 Spring REST Docs
를 적용해보도록 해보겠습니다.
Spring 진영에서의 대표적인 API 문서화 자동화 도구에는 Spring REST Docs
와 Swagger
가 있습니다.
Swagger
는 API의 스펙을 정의하는 것이 목적이지만 Spring REST Docs
는 API의 스펙을 정의하고 테스트하는 것이 목적이라고 합니다.
이전에는 Swagger
를 많이 사용했다고 하나 Spring MVC
의 테스트를 실행하지 않고 문서를 생성하기 때문에 실제로 API가 동작하는지 확인할 수 없다는 단점이 있습니다.
또한 Swagger
는 @ApiOperation
등 문서화에 필요한 여러 어노테이션을 비즈니스 로직 코드에 작성하기 때문에 비지니스 로직 코드와 문서를 분리하기 어렵습니다.
Swagger
와 달리Spring REST Docs
는 코드에 비침투적이며, 테스트를 강제한다는 장점이 있습니다.
테스트에서 spring-rest-docs-mockmvc
라이브러리를 사용하면 테스트 실행 시, 문서 조각인 스니펫을 얻을 수 있습니다. 개발자는 문서의 뼈대가 될 adoc
파일을 따로 작성하고, 여기서 테스트 결과로 나온 스니펫들을 포함시키면 됩니다.
이후 asciidoctor
태스크를 실행시킵니다. 해당 태스크는 adoc
파일과 스니펫을 조합해 html
형식으로 만들어 개발 문서를 완성시켜 주는 역할을 합니다.
테스트 후 asciidoctor
플러그인을 실행시키는 대신 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'
}
}
Asciidoctor
플로그인을 추가해줍니다. Asciidoctor
플러그인에 담긴 asciidoctor
태스크가 adoc
파일과 스니펫을 조합해 html
로 변경해주는 역할을 합니다.
dependencies
블럭에 asciidoctorExt
로 라이브러리를 불러올 수 있도록 선언합니다. 해당 라이브러리는 개발자가 작성하는 adoc
파일에서 ``을 통해 스니펫 경로에 있는 스니펫들을 쉽게 불러올 수 있도록 합니다. 해당 라이브러리가 있다면 직접 경로를 입력하지 않아도 되어 편리하게 문서를 작성할 수 있습니다. 그리고 이 라이브러리를 asciidoctor
태스크에 적용합니다.
스니펫들이 저장될 snippetDir
변수를 설정합니다. snippetDir
을 test의 outputs.dir
로 설정해 스니펫들이 해당 경로에 저장되도록 설정합니다.
MockMvc
에 기반해서 스니펫을 뽑아낼 수 있도록 하는 라이브러리를 dependencies
에 추가합니다. 만약 테스트 방식으로 MockMvc
를 사용하지 않고 WebTestClient
나 REST Assured
를 사용하는 환경이라면 spring-restdocs-webtestclient
나 spring-restdocs-restassured
을 사용할 수 있습니다.
adoc
파일을 html
파일로 변환시켜주는 asciidoctor
설정을 합니다. inputs.dir
을 이전에 설정해둔 스니펫 경로인 snippetDir
로 설정합니다. test 태스크에 의존하도록 depensOn
설정을 하여 asciidoctor
태스크를 실행하면 동작 수행 전에 test 태스크를 수행해서 스니펫을 새로 만들고 새로운 html
파일을 만들도록 설정합니다.
bootJar
태스크를 실행하면 asciidoctor
태스크가 실행되게 합니다. 또한 asciidoctor
태스크는 test 태스크에 의존하고 있기 때문에 bootJar
를 실행하게 되면 test
- asciidoctor
- bootJar
순서로 실행됩니다. 또한 html
로 만들어진 문서를 기본 outputDir
인 build/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'
}
}
html
파일을 삭제하도록 합니다. 만약 파일명을 바꾸거나 위치를 옮길 때, 바꾸기 전의 html
파일이 원래 위치에 그대로 존재하는 것을 방지하기 위해 설정합니다.2.test
태스크가 완료되면 asciidoctor
태스크가 실행되도록 finalizedBy
를 통해 설정합니다.
asciidoctor
태스크가 끝나면 새로 작성한 태스크인 copyDocument
가 실행되도록 설정합니다.
asciidoctor
태스크가 시작하기 전 static/docs
에 있는 이전 문서들을 삭제합니다.
asciidoctor
태스크의 결과물을 static/docs
로 옮깁니다. 따라서 intelliJ run 시에 /docs/**
URL로 API 문서에 접근할 수 있습니다.
기존 bootJar
태스크 설정에 기존 html
파일을 삭제하는 로직을 추가합니다. build/resource/main/static/docs
하위에 존재하는 이전 빌드의 파일을 삭제합니다. 만약 파일의 위치를 변경하거나 이름을 변경했을 때 삭제 설정을 하지 않으면 기존의 파일이 그대로 남아있습니다.
테스트 코드 기본 작성법은 MockMvc
의 andDo()
메서드 안에 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에서 공통적으로 사용되어야 될 내용을 분리해서 코드를 간결하게 리팩토링해보도록 하겠습니다.
공통으로 처리해주어야할 내용은 다음과 같습니다
pretty format
을 적용하는 부분ObjectMapper
, MockMvc
등 API 문서 작성 필수 클래스 선언 부분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에 대한 설정을 세팅해줍니다.
지우고 싶은 헤더도 설정하실 수 있습니다.
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
는 테스트 할 클래스에서 해당 클래스를 제외시켜주는 역할을 합니다.
@Import
는 Config
파일에 등록된 스프링 빈을 사용할 수 있도록 해주는 역할을 합니다. 위에서 작성한 RestDocsConfiguration
을 추가해줍니다.
@ExtendWith
에 RestDocumentationExtension
을 설정하여 context
를 제공해서 Spring REST Docs
가 잘 동작하도록 해줍니다.
이 클래스를 상속한 클래스가 MockMvc
와 ObjectMapper
를 선언할 필요 없이 사용이 가능하도록 설정해줬습니다.
또한 Attribute
를 간단하게 추가할 수 있도록 메서드를 추가해줍니다.
@BeforeEach
에서 MockMvc
의 커스터마이즈를 진행하고 있습니다.RestDocsConfiguration
에서 설정했던 RestDocumentationResultHandler
를 적용하고, 테스트 시 요청과 응답을 출력할 수 있도록 print()
메서드도 적용하게 됩니다.
그리고 mockMvc
설정에 스프링 시큐리티 설정을 추가해줌으로써 사용자를 등록해서 Principal 객체에 사용자 정보를 빼낼 수 있게 해주었습니다.
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 가이드 공식 문서를 참고하시면 됩니다.
= 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[]
정상적으로 잘 나오는 것을 확인하실 수 있습니다.