Kotlin/Kotlin | Spring 학습기록

[Kotlin] Kotlin DSL + Spring REST Docs + MockMvc 적용기 (2)

kth990303 2022. 7. 17. 15:23
반응형

kotlin DSL과 Spring REST Docs 세팅을 MockMvc 방법으로 설정하는 포스팅이다.

환경세팅 및 adoc 형식 설정은 지난 포스팅에서 확인할 수 있다.

https://kth990303.tistory.com/347

 

[Kotlin] Kotlin DSL + Spring REST Docs + MockMvc 적용기 (1)

현재(22.07.17.) 는 아직 Spring REST Docs에서 kotlin DSL 공식지원을 하지 않고 있는 상황이다. https://github.com/spring-projects/spring-restdocs/issues/677 Document how to use Spring REST Docs with th..

kth990303.tistory.com

 

 

이번 포스팅에선 kotlin DSL이 적용돼있는 api 테스트 코드에 REST Docs 기능을 추가하는 방법을 알아볼 것이다.

코드 내용은 공개하지 않는 선에서, 코드의 틀이랑 설정 방법만 포스팅할 것이다.


구조 및 설정

  • 각 api 테스트 클래스들은 RestControllerTest를 상속받는다.
  • Gradle 7, asciidoctorExt 3.3.2 버전
  • 22.07.17. 기준 (Kotlin DSL + Spring REST Docs 공식 지원이 되지 않는 경우)

RestControllerTest

기본 환경세팅하는 방법은 크게 두가지가 있다.

 

1. @AutoConfigureRestDocs 로 초기세팅을 하고, @Import(RestDocsConfiguration::class) 을 적용해서 커스텀하는 방식

2. @BeforeEach로 초기세팅과 커스텀을 동시에 하는 방식

1번 방법

간단하다. RestControllerTest 클래스에 @AutoConfigureRestDocs, @AutoConfigureMockMvc 를 달아준다. 그러면 자동으로 Spring Rest Docs를 적용할 수 있게 된다.

@AutoConfigureMockMvc 
@AutoConfigureRestDocs 
@ExtendWith(RestDocumentationExtension::class)
@TestEnvironment
abstract class RestControllerTest {

    @Autowired
    lateinit var mockMvc: MockMvc   
}

하지만, 위 방법은 Http Request, Http Response로 json 객체를 이쁘게 출력해주는 prettyprint()를 커스텀하지 못한다. 따라서, @AutoConfigureMockMvc 대신, RestDocsConfiguration 클래스를 만들어서 따로 커스텀해주는 방법을 추천한다. 이렇게 할 경우, prettyprint() 외에 별도로 커스텀하고 싶을 경우에 추가로 설정해줄 수 있다.

 

RestDocsConfiguration

@TestConfiguration
class RestDocsConfiguration {
    @Bean
    fun restDocsMockMvcConfigurationCustomizer(): RestDocsMockMvcConfigurationCustomizer {
        return RestDocsMockMvcConfigurationCustomizer {
            it.operationPreprocessors()
                .withRequestDefaults(Preprocessors.prettyPrint())
                .withResponseDefaults(Preprocessors.prettyPrint())  // prettyprint 커스텀
        }
    }
}

RestControllerTest

@Import(RestDocsConfiguration::class)  // @AutoConfigureMockMvc 대신 RestDocsConfiguration import
@ExtendWith(RestDocumentationExtension::class)
@AutoConfigureRestDocs 
@TestEnvironment
abstract class RestControllerTest {

    @Autowired
    lateinit var mockMvc: MockMvc   
}

위와 같이 prettyprint() 기능을 추가해주면 아래와 같이 json 객체가 문서에서 예쁘게 줄간격을 맞춰 출력된다.


2번 방법

RestControllerTest 의 @BeforeEach 로 이미 별도의 다른 setUp이 진행되고 있을 경우에, 해당 방법을 사용해주면 좋다. 프로젝트 규모가 커지면 REST Docs 설정 이외의 다른 것들도 세팅을 미리 해주는 경우가 많기 때문에, 아마 2번 방법이 더 유용하지 않을까 싶다.

RestControllerTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@ExtendWith(RestDocumentationExtension::class)
@TestEnvironment
abstract class RestControllerTest {
    
    @Autowired
    lateinit var objectMapper: ObjectMapper
    
    lateinit var mockMvc: MockMvc
 
    @BeforeEach
    internal fun setUp(
        webApplicationContext: WebApplicationContext,
        restDocumentationContextProvider: RestDocumentationContextProvider
    ) {
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
            .addFilter<DefaultMockMvcBuilder>(CharacterEncodingFilter("UTF-8"true))
            .alwaysDo<DefaultMockMvcBuilder>(MockMvcResultHandlers.print())
            .apply<DefaultMockMvcBuilder>(
                MockMvcRestDocumentation.documentationConfiguration(
                    restDocumentationContextProvider
                ).operationPreprocessors()
                    .withRequestDefaults(prettyPrint())
                    .withResponseDefaults(prettyPrint())  // custom config 필요없이 여기서 설정
            )
            .build()
    }
}
cs

UserRestControllerTest : RestControllerTest

이제 RestControllerTest를 상속받아서 본격적으로 테스트를 진행해보자. 아래와 같이 테스트 클래스를 세팅해주자.

UserRestControllerTest

@WebMvcTest(
    controllers = [UserRestController::class]
)
@TestEnvironment
internal class UserRestControllerTest : RestControllerTest() {

}

RestControllerTest를 상속하며, 이미 RestControllerTest에서 ObjectMapper를 주입받기 때문에, 해당 클래스에는 협력 객체들만 넣어주면 된다.

mockMvc는 처음에 mockMvc.get(), mockMvc.post() 등과 같이 Http 요청을 보낸다. 이후에 기대하는 응답을 andExpect 로 적어준다. 그리고 이번 포스팅에서 제일 핵심이 되는, REST Docs api 자동 문서화를 진행하라고 andDo를 진행한다.


andDo()에는 어떠한 내용을 적어주어야 할까?

REST Docs 문서화를 해주기 위해 Request에 body에 어떠한 필드가 있는지, 어떠한 쿼리 파라미터가 전달되는지 알려주어야 할 것이다. 이는 Response에도 마찬가지이다. 그리고 이것이 어떠한 종류의 api인지 알려주면 더더욱 좋을 것이다.

 

따라서 어떠한 종류의 api인지 알려주는 identifier (String)을 먼저 적어주자.

그 밑에 request 정보를 적어주기 위해, PayloadDocumentation.requestFields 를 이용하여 request body에 어떠한 필드가 있는지, RequestDocumentation.requestParameters 를 이용하여 request query param에 어떠한 값이 있는지 적어주자.

그 밑에 response 정보를 적어주기 위해, PayloadDocumentation.responseFields를 이용해주자. 204 No Content 응답일 경우 응답값이 없을테니 아예 적지 않으면 된다.

 

필드의 정보를 담기 위해서는 fieldWithPath로 변수명을, description으로 변수에 대한 설명을 적어주면 된다. 타입을 지정해주고 싶다면 type(JsonFieldType.STRING) 등과 같이 지정해주면 된다. 필드의 정보에 대한 더 자세한 내용은 아래 우아한형제들 기술블로그 포스팅을 참고하자.

https://techblog.woowahan.com/2597/

 

Spring Rest Docs 적용 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요? 우아한형제들에서 정산시스템을 개발하고 있는 이호진입니다. 지금부터 정산시스템 API 문서를 wiki 에서 Spring Rest Docs 로 전환한 이야기를 해보려고 합니다. 1. 전환하는

techblog.woowahan.com

 

요약

  • identifier
  • requestFields
  • requestParameters
  • responseFields

을 이용하여 값을 지정해주자.


java REST Docs라면 andDocument() 라는 api가 있지만, 불행하게도 kotlin DSL에는 현재 존재하지 않는다. 빨리 공식지원이 되길...따라서 우리는 MockMvcHandlerDsl 에 존재하는 handle() 과 함께 작성할 것이다. andDo 안에 handle 안에 document 안에 위 정보들을 저장해줄 것이다. andDo(handle(document( ... ))) 가 반복돼서 불편하다면 조금만 참자. 아래에 확장함수를 통해 위 반복을 줄이는 코드를 보여줄 것이다.

1. Request Body 전달하는 테스트 케이스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Test
fun `어떠한 테스트 코드`() {
 
    mockMvc.post("/api/something") {
        content = objectMapper.writeValueAsBytes(somethingRequest) // body값에 넘겨줄 값
        contentType = MediaType.APPLICATION_JSON  // 한글, 특수문자 깨짐 방지
    }.andExpect {
        status { isOk }
        content { json(objectMapper.writeValueAsString(somethingResponse) }  // Response Body
    }.andDo {
        handle(
            document(
                "something",
                PayloadDocumentation.requestFields(
                    PayloadDocumentation.fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
                    PayloadDocumentation.fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호")
                ),
                PayloadDocumentation.responseFields(
                    PayloadDocumentation.fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"),
                    PayloadDocumentation.fieldWithPath("body").type(JsonFieldType.STRING).description("토큰")
                )
            )
        )
    }
}
cs

perform과 andExpect 부분은 java REST Docs와 동일하니까 패스하겠다.

content Type을 perform에서 지정해주지 않으면 스니펫 문서에서 @가 %40으로 보이는 등, 특수문자와 한글이 깨지니까 조심하자.

 

andDo 부분을 유심히 살펴보자.

andDo -> handle -> document를 통해 접근하는 것을 확인할 수 있다.

그 밑에 "something" 부분이 해당 api를 설명해주는 identifier이다. 이 이름으로 .build 디렉토리의 snippet이 생성되게 된다. 여기서는 something.adoc 이 되겠다.

 

다음으로, requestFields 로 RequestBody 정보를 담아준 것을 확인할 수 있다.

위와 같이 fieldWithPath, type, description을 적어주면 .adoc 스니펫이 아래와 같이 생성된다.

참고로, 위처럼 스니펫 미리보기를 보려면 인텔리제이에서 ASCIIDoc 플러그인을 설치해주면 된다.

 

물론 이걸 보여줄지 말지는, src/docs/asciidoc 디렉토리의 .adoc 형식으로 설정해줌으로써 결정된다. .build/generated-snippets 디렉토리에서 위 스니펫 미리보기가 생성된다고 무조건 문서에서 위 그림이 보여지는 것이 아님에 주의하라.


2. Request Param 전달하는 테스트 케이스

위에서 언급했듯이 requestParameters를 보내주면 된다.

.andDo {
    handle(
        document(
            "something",
            RequestDocumentation.requestParameters(
                parameterWithName("email").description("이메일")
            )
        )
    )
}

3. 예외 테스트 케이스

예외 케이스 문서를 작성하고 싶다면 예외를 던져주는 mock을 보내주면 된다.

every { userService.something(somethingRequest) } throws
                UnidentifiedUserException("사용자 정보가 일치하지 않습니다.") // 예외 상황 mock

andExpect는 api 설계한 대로 잘 바꿔주면 되고, andDo는 올바른 경우처럼 그대로 작성해주면 된다.


adoc을 작성해주기

이제 아래와 같이 src/docs/asciidoc 폴더의 adoc들을 작성해주어 문서를 지정해주자.

index.adoc

:doctype: book
:source-highlighter: highlightjs
:toc: left
:toclevels: 1
:sectlinks:

== Something Project
include::someTest.adoc[]  // 우리가 만들어준 테스트 코드 문서

SomeTest.adoc

= SomeTest API
== Something function
operation::something[snippets='http-request,http-response']

== Something other function
operation::somethingOther[snippets='http-request,http-response']

위와 같이 operation::{identifier} 로 특정 메서드 api를 명시해주고, 스니펫으로 어떠한 것들을 보여줄지 적어준다. 이 포스팅에서는 http-request, http-response 만 보여주게 할 것이다.

 

이제 위와 같이 작업을 끝내고 ./gradlew clean 으로 .build 디렉토리를 비워준 다음에, 테스트를 돌리면 index.adoc에 스니펫 미리보기 화면이 예쁘게 보일 것이다~

 

의도한대로 제대로 보인다면 ./gradlew copyDocument 명령어를 터미널에 입력해주면 index.html이 생겨날 것이고, api 문서를 확인할 수 있게 된다! 끝!


확장함수를 이용한 불필요한 반복 줄이기

위와 같이 REST Docs를 적용하고 PR을 날렸는데, 아래와 같이 코드리뷰가 도착했다.

 

그동안 andDo(handle(document(...))) 의 불필요한 반복이 짜증났다면, 코틀린 확장함수 기능을 이용해서 반복을 줄일 수 있다!

fun ResultActionsDsl.andDocument(identifier: String, vararg snippets: Snippet): ResultActionsDsl {
    return andDo {
        handle(
            document(
                identifier,
                *snippets
            )
        )
    }
}

 

spread Operator, vararg를 이용하여 여러 스니펫들을 가변인자로 받고, identifier을 인자로 넘겨주어 ResultActionsDsl을 반환해주는 확장함수를 작성하였다.

 

위 확장함수를 이용하면 andDo(handle(document(...))) 를 쓰지 않고 andDocument로 한번에 이용 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
fun `something 테스트`() {
 
    mockMvc.post("api/something") {
        content = objectMapper.writeValueAsBytes(someRequest)
        contentType = MediaType.APPLICATION_JSON
    }.andExpect {
        status { isOk }
        content { json(objectMapper.writeValueAsString(someResponse) }
    }.andDocument(
        "something",
        PayloadDocumentation.requestFields(
            PayloadDocumentation.fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
            PayloadDocumentation.fieldWithPath("password").type(JsonFieldType.STRING).description("비밀번호")
        ),
        PayloadDocumentation.responseFields(
            PayloadDocumentation.fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"),
            PayloadDocumentation.fieldWithPath("body").type(JsonFieldType.STRING).description("토큰")
        )
    )
}
cs

마치며

코틀린 확장함수, DSL, REST Docs에 대해 공부해볼 수 있는 좋은 경험이었다. 확장함수, DSL 쪽 개념이 많이 약한데, 좀 더 공부해야겠다는 생각도 들었다. 무엇보다, 아직 공식지원되지 않는 kotlin DSL + Spring REST Docs를 다뤄봤다는 점이 인상깊었다.

반응형