Rest Docs 적용

조제·2024년 4월 26일
0

Rest Docs 란?

테스트 코드 기반으로 Restful API 문서를 돕는 도구입니다.

ResctDocs의 가장 큰 장점은 테스트 코드 기반으로 문서를 작성한다는 점입니다.

API Spec과 문서화를 위한 테스트 코드가 일치하지 않으면 테스트 빌드가 실패하게 되어 테스트 코드로 검증된 문서를 보장할 수 있습니다.

통합테스트 vs 단위테스트

통합테스트

큰 동작을 달성하기 위해 여러 모듈들을 모아 이들이 의도대로 협력하는지 확인하는 테스트

Spring Boot에서는 SQL을 활용하여 테스트 데이터를 넣어두고, API를 호출하여 원하는 응답을 받는지 테스트, Controller - Service - Repository 등의 모든 Class에 실제 객체를 사용하여 테스트

단위테스트

응용 프로그램에서 테스트 가능한 가장 작은 소프트웨어를 실행하여 예상대로 동작하는지 확인하는 테스트

Spring Boot에서는 Controller, Service, Repository 등 각각의 Class들에 한정되어 테스트, 해당 Class에 필요한 다른 객체들은 Mock 등을 활용하여 가짜 객체를 사용하여 테스트

Rest Docs를 위한 단위테스트

MockitoExtension만 활용하는 단위테스트 사용

테스트를 진행하는 Controller에 @InjectMock 로 Mock들을 주입받을 변수임을 지정하고, 필요한 나머지 객체들에 @Mock 을 선언하여 가짜 객체를 주입

MockMvc 설정을 standaloneSetup() 으로 진행하여 해당 Controller에 대한 테스트만 진행하도록 설정

테스트 상에서는 해당 API에서 실행되는 Service 등 객체들의 모든 메소드의 Return값을 설정해야 합니다. - when().thenReturn()

Test Fixtures 구성

Fixture Class에 많은 생성자 케이스를 만들어 별도로 테스트에서 사용하는 객체의 생성을 관리

객체가 생성될 경우를 미리 정의해두어서 세팅에 관련된 부분은 한 곳에서만 관리하고, 테스트에서는 가져다 쓰기만 합니다.

Fixture

public static TokenOutDto createTokenOutDto() {
    return TokenOutDto.builder()
            .accessToken(ACCESS_TOKEN)
            .refreshToken(REFRESH_TOKEN)
            .isPasswordDueForChange(Boolean.FALSE)
            .build();
}

Test

@Test
void mobileSignUpByEmail() throws Exception {
    // given
    TokenOutDto tokenOutDto = UserFixtures.createTokenOutDto();
    ...
}

⚙️build.gradle

plugin 추가

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 추가

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 폴더를 삭제해줍니다.

build 설정

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 설정

test {
    include 'com/restdocs/example/docs/**/*.class'
    outputs.dir snippetsDir
    useJUnitPlatform()
}

include : 빌드시 include에 설정한 test만 수행하게 설정합니다.

outputs.dir snippetsDir : 위에서 작성한 snippetsDir 디렉토리를 test의 output으로 구성하는 설정

→ 스니펫 조각들이 build/generated-snippets로 출력

빌드 과정

  1. 테스트 실행

  2. 테스트 결과를 build/generated-snippets 에 저장

  3. 이전 static/docs/ 비우기

  4. src/docs/asciidoc/*.adoc 을 통해 build/docs/asciidocs/ 에 html 파일들 생성

  5. 생성된 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를 변경 할 수 있습니다.

AsciiDocs 작성

테스트에 성공하면 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 파일을 만들면 됩니다.

profile
조제

0개의 댓글