테스트 코드 기반으로 Restful API 문서를 돕는 도구입니다.
ResctDocs의 가장 큰 장점은 테스트 코드 기반으로 문서를 작성한다는 점입니다.
API Spec과 문서화를 위한 테스트 코드가 일치하지 않으면 테스트 빌드가 실패하게 되어 테스트 코드로 검증된 문서를 보장할 수 있습니다.
큰 동작을 달성하기 위해 여러 모듈들을 모아 이들이 의도대로 협력하는지 확인하는 테스트
Spring Boot에서는 SQL을 활용하여 테스트 데이터를 넣어두고, API를 호출하여 원하는 응답을 받는지 테스트, Controller - Service - Repository 등의 모든 Class에 실제 객체를 사용하여 테스트
응용 프로그램에서 테스트 가능한 가장 작은 소프트웨어를 실행하여 예상대로 동작하는지 확인하는 테스트
Spring Boot에서는 Controller, Service, Repository 등 각각의 Class들에 한정되어 테스트, 해당 Class에 필요한 다른 객체들은 Mock 등을 활용하여 가짜 객체를 사용하여 테스트
MockitoExtension만 활용하는 단위테스트 사용
테스트를 진행하는 Controller에 @InjectMock 로 Mock들을 주입받을 변수임을 지정하고, 필요한 나머지 객체들에 @Mock 을 선언하여 가짜 객체를 주입
MockMvc 설정을 standaloneSetup() 으로 진행하여 해당 Controller에 대한 테스트만 진행하도록 설정
테스트 상에서는 해당 API에서 실행되는 Service 등 객체들의 모든 메소드의 Return값을 설정해야 합니다. - when().thenReturn()
Fixture Class에 많은 생성자 케이스를 만들어 별도로 테스트에서 사용하는 객체의 생성을 관리
객체가 생성될 경우를 미리 정의해두어서 세팅에 관련된 부분은 한 곳에서만 관리하고, 테스트에서는 가져다 쓰기만 합니다.
public static TokenOutDto createTokenOutDto() {
return TokenOutDto.builder()
.accessToken(ACCESS_TOKEN)
.refreshToken(REFRESH_TOKEN)
.isPasswordDueForChange(Boolean.FALSE)
.build();
}
@Test
void mobileSignUpByEmail() throws Exception {
// given
TokenOutDto tokenOutDto = UserFixtures.createTokenOutDto();
...
}
asciidoctor.jvm.conver 플러그인은 adoc 파일 변환, build 디렉토리에 복사하기 위한 플러그인입니다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.14'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
// restdocs
id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
gradle은 build/generated-snippets 에 스니펫이 생성되므로, 스니펫 생성 디렉토리를 변수에 담아줍니다.
ext {
// restdocs
snippetsDir = file('build/generated-snippets')
}
asciidoctor {
dependsOn test // (1)
inputs.dir snippetsDir // (2)
baseDirFollowsSourceFile() // (3)
}
asciidoctor.doFirst {
delete file('src/main/resources/static/docs') // (4)
}
(1) gradle build 시 test → asciidoctor 순으로 수행됩니다.
(2) snippetsDir 를 입력으로 구성합니다.
(3) 특정 adoc에 다른 adoc 파일을 가져와서(include) 사용하고 싶을 경우 경로를 baseDir로 맞춰주는 설정입니다.
(4) 기존에 존재하는 static/docs 폴더를 삭제해줍니다.
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("${asciidoctor.outputDir}")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument
}
copyDocument : asciidoctor 작업 이후 생성된 html 파일을 static/docs 로 copy 해줍니다.
build 전 copyDocument를 실행합니다.
test {
include 'com/restdocs/example/docs/**/*.class'
outputs.dir snippetsDir
useJUnitPlatform()
}
include : 빌드시 include에 설정한 test만 수행하게 설정합니다.
outputs.dir snippetsDir : 위에서 작성한 snippetsDir 디렉토리를 test의 output으로 구성하는 설정
→ 스니펫 조각들이 build/generated-snippets로 출력
테스트 실행
테스트 결과를 build/generated-snippets 에 저장
이전 static/docs/ 비우기
src/docs/asciidoc/*.adoc 을 통해 build/docs/asciidocs/ 에 html 파일들 생성
생성된 html 파일을 build 에서 src/main/resources/static/docs/ 로 이동
@AutoConfigureRestDocs
@ExtendWith({RestDocumentationExtension.class, ObjectMapperResolver.class})
public abstract class RestDocsSpecification {
protected ObjectMapper objectMapper;
private RestDocumentationContextProvider contextProvider;
protected MockMvc mockMvc;
protected String apiVersion = "v1";
protected String apiRootPath = "app";
protected MessageSource messageSource;
protected String baseUrl;
@BeforeEach
private void setUp(ObjectMapper objectMapper, RestDocumentationContextProvider contextProvider) {
this.objectMapper = objectMapper;
this.contextProvider = contextProvider;
this.messageSource = messageSource();
OntactApiResult.EnumValuesInjectionService enumValuesInjectionService = new OntactApiResult.EnumValuesInjectionService(messageSource);
enumValuesInjectionService.postConstruct();
}
protected void baseUrl(String url) {
baseUrl = "/" + apiVersion + "/" + apiRootPath + url;
}
protected void mockMvc(Object controller) {
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.addPlaceholderValue("api.version.latest", apiVersion)
.addPlaceholderValue("api.root-path.app", apiRootPath)
.setMessageConverters(jackson2HttpMessageConverter())
.setCustomArgumentResolvers(new PageableHandlerMethodArgumentResolver())
.apply(documentationConfiguration(contextProvider))
.addFilters(new CharacterEncodingFilter("UTF-8", true))
.alwaysDo(print())
.build();
}
...
}
해당 클래스는 RestDocs를 만들기 위해 필요한 설정들을 해놓은 추상 클래스입니다.
protected void mockMvc(Object controller) 에서 컨트롤러 인스턴스를 받아 MockMvc에 셋업합니다.
@RequestMapping("${api.version.latest}/${api.root-path.app}/user")
RequestMapping에 있는 프로퍼티에 값을 넣어주기 위해 addPlaceholderValue("api.version.latest", apiVersion) 을 사용했습니다.
@ExtendWith(MockitoExtension.class)
public class UserControllerTests extends RestDocsSpecification {
@InjectMocks
private UserController userController;
@Mock
private UserService userService;
@Mock
private UserSnsLinkService userSnsLinkService;
@Mock
private NotificationService notificationService;
@Mock
private JwtProvider jwtProvider;
@Mock
private AuthenticationFacade authenticationFacade;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(userController, "cookieSameSite", "None");
ReflectionTestUtils.setField(userController, "cookieSecure", true);
ReflectionTestUtils.setField(userController, "cookieHttpOnly", true);
baseUrl("/user");
mockMvc(userController);
}
@Test
@DisplayName("일반 사용자 이메일 로그인")
void mobileSignInByEmail() throws Exception {
// given
SignInEmailInDto signInEmailInDto = createSignInEmailInDto();
TokenOutDto tokenOutDto = createTokenOutDto();
when(userService.signInByEmail(any())).thenReturn(tokenOutDto);
// when
ResultActions result = mockMvc.perform(
post(baseUrl + "/email/mobile/_signin")
.content(objectMapper.writeValueAsString(signInEmailInDto))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
);
// then
result.andExpect(status().isOk())
.andDo(documentation(
requestFields(
fieldWithPath("username").type(JsonFieldType.STRING).description("유저 식별 값"),
fieldWithPath("password").type(JsonFieldType.STRING).description("유저 비밀번호"),
fieldWithPath("fcmToken").type(JsonFieldType.STRING).optional().description("fcm토큰")
),
responseFields(
beneathPath("data").withSubsectionId("data"),
fieldWithPath("accessToken").type(JsonFieldType.STRING).description("jwt 액세스토큰"),
fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("jwt 리프레시토큰"),
fieldWithPath("isPasswordDueForChange").type(JsonFieldType.BOOLEAN).description("비밀번호 변경 권고여부(최근 비밀번호 변경 90일 이후)")
)
));
}
}
@InjectMocks 을 이용해 컨트롤러를 Mock 객체로 생성하고 mockMvc(userController) 슈퍼 클래스에 넘겨줍니다.
baseUrl("/user") 을 통해 api uri 중복을 줄이고 ReflectionTestUtils.setField() 를 이용해 @Value 로 선언된 프로퍼티 변수에 값을 넣어줍니다.
요청 api에 서비스가 호출될경우 @Mock 으로 해당 서비스를 Mock객체로 생성한 후 when().thenReturn() 을 이용해 리턴값을 미리 정의해줍니다.
protected OperationRequestPreprocessor getDocumentRequest() {
return preprocessRequest(
modifyUris()
.scheme("https")
.host("your-api.endpoint.net")
.removePort(),
prettyPrint());
}
protected OperationResponsePreprocessor getDocumentResponse() {
return preprocessResponse(prettyPrint());
}
protected RestDocumentationResultHandler documentation(Snippet... snippets) {
return document(
"{class-name}/{method-name}",
getDocumentRequest(),
getDocumentResponse(),
snippets
);
}
슈퍼 클래스의 documentation 메소드를 이용해 RestDocs 문서를 생성할 수 있으며 해당 테스트 클래스명 폴더의 테스트 이름 경로에 snippet들이 생성됩니다.
preprocessRequest 의 modifyUris 메소드를 통해 RestDocs를 보여줄 때 uri를 변경 할 수 있습니다.
테스트에 성공하면 build/generated-snippets 폴더의 (테스트클래스명)/(테스트명) 폴더에 adoc 조각들이 생성되어 있습니다.
스니펫 조각들이 생성되었으면 src/docs/asciidocs/ 폴더에 adoc 파일을 만들고 문서를 작성합니다.
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
= API Document
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
:docinfo: shared-head
operation::user-controller-tests/mobile-sign-in-by-email[snippets='http-request,http-response,request-fields,response-fields-data']
operation::디렉토리명[snippets='원하는 조각들'] : 문서로 사용할 조각들을 명시해 자동으로 가져옵니다.
이제 빌드하면 위에서 작성한 index.adoc 파일이 index.html 형태로 export 됩니다.
index.html 파일은 build/docs/asciidoc/, src/main/resources/static/docs/ 두 디렉토리 안에 있습니다. (build.gradle 의 copyDocument 명령어로 복사했습니다.)
커스텀 스니펫을 적용하려면
src/test/resources/org/springframework/restdocs/templates 하위에 .snippet 파일을 만들면 됩니다.