TEST

[TEST] RestDocs와 Kotlin DSL로 직관적인 테스트 코드를 작성해보자.

EJUN 2025. 5. 16. 23:01

 

저는 최근 Spring RestDocs를 활용하여 REST API를 문서화하는 작업을 했습니다.

하지만 RestDocs를 기본적인 형태로 사용하면 다음과 같은 코드가 만들어지곤 합니다.

requestFields(
    fieldWithPath("username")
        .type(JsonFieldType.STRING)
        .description("회원 이름"),
    fieldWithPath("avatarUrl")
        .type(JsonFieldType.STRING)
        .description("GitHub 프로필 URL"),
    fieldWithPath("gitUrl")
        .type(JsonFieldType.STRING)
        .description("GitHub URL")
)

이 코드는 명확한 장점이 있지만 다음과 같은 문제점이 있습니다.

  • 반복적인 패턴: 매번 .type().description()을 호출해야 하므로 코드가 중복됩니다.
  • 가독성 문제: 빌더 패턴이 여러 줄로 나눠져 있어서 코드가 길어지고 읽기 어렵습니다.
  • 유지보수 어려움: 문서화할 필드가 많아질수록 코드가 복잡해지고 관리가 어려워집니다.

이러한 문제를 해결하기 위해 Kotlin DSL(Domain Specific Language)을 도입하여 더 간결하고 직관적인 코드를 작성하기로 했습니다.

 

DSL을 활용한 코드 개선

먼저, 필드 타입을 더 명확히 관리하기 위해 sealed classRestDocsField를 정의했습니다.

sealed class RestDocsField(val type: JsonFieldType)

object STRING: RestDocsField(JsonFieldType.STRING)
object NUMBER : RestDocsField(JsonFieldType.NUMBER)
object BOOLEAN : RestDocsField(JsonFieldType.BOOLEAN)
object DATE : RestDocsField(JsonFieldType.STRING)
object OBJECT : RestDocsField(JsonFieldType.OBJECT)
object NULL : RestDocsField(JsonFieldType.NULL)
object ANY : RestDocsField(JsonFieldType.VARIES)
object ARRAY : RestDocsField(JsonFieldType.ARRAY)

 

이어서 DSL을 구축하기 위한 필수적인 클래스와 확장 함수를 구현했습니다.

open class Field(val descriptor: FieldDescriptor) {
    open infix fun means(value: String) = apply { descriptor.description(value) }
}

infix fun String.typeOf(restDocsField: RestDocsField) =
    createField(this, restDocsField.type)

fun createField(
    value: String,
    type: JsonFieldType,
    optional: Boolean = false,
): Field {
    val descriptor = PayloadDocumentation
        .fieldWithPath(value)
        .type(type)
        .description("")
    if (optional) descriptor.optional()
    return Field(descriptor)
}
  • means 함수: RestDocs의 FieldDescriptor에서 .description()을 설정하는 역할을 하며, DSL 코드 작성 시 자연스러운 문장 형태로 필드 설명을 추가할 수 있도록 도와줍니다.
  • typeOf 확장함수: 필드명에 대해 직접적으로 JsonFieldType을 지정해주는 확장 함수로, 간결하게 필드 타입을 지정할 수 있게 해줍니다.
  • createField 함수: 실제 FieldDescriptor 객체를 생성하며, 타입과 필드명을 지정하고 선택적으로 optional 속성도 설정할 수 있게 합니다.

여기서 optional을 통해 해당 필드가 필수 유무인지를 문서에 명시할 수 있게 했습니다.

 

문장처럼 읽히도록 하기 위해 코틀린에서 제공해주는 infix(중위 표기 함수)를 활용해 기존에는

"username".typeOf(String).means("깃허브 닉네임")

위처럼 작성했다면 infix를 통해

"username" typeOf STRING means "깃허브 닉네임"

더욱 더 문장스럽게 읽히도록 할 수 있었습니다.

개선된 DSL 코드 예시

 

document(
    "member-info",
    authorizationHeader(),
    responseFields(
        "username" typeOf STRING means "회원 이름",
        "avatarUrl" typeOf STRING means "GitHub 프로필 URL",
        "gitUrl" typeOf STRING means "GitHub URL",
    )
)

위와 같은 DSL을 적용하면, 문서화 코드가 다음과 같이 자연스럽고 직관적으로 개선됩니다.

 

제가 생각한 DSL을 적용한 장점은 

  • 가독성 향상: 문장처럼 자연스럽게 읽히는 코드로 가독성이 크게 향상됩니다.
  • 중복 코드 제거: 반복적이고 중복되는 빌더 코드가 사라져 유지보수가 쉬워집니다.
  • 유지보수 편리성: 필드를 추가하거나 수정할 때도 매우 간단하게 관리할 수 있습니다.

이처럼 Kotlin DSL을 통해 RestDocs 문서화 작업을 더욱 직관적이고 효율적으로 개선할 수 있었습니다.

 

Code Duplication을 줄여보자.

 

기존 테스트 코드 작성 방식에서는 반복적인 인증 정보 설정과 JSON 직렬화/역직렬화 코드가 필수적으로 필요했습니다.

예를 들어, 매 테스트마다 다음과 같은 코드를 반복적으로 작성해야 했습니다.

mockMvc.perform(
    get("/api/member")
        .header("Authorization", "Bearer token")
        .principal(MockPrincipal("username"))
)

 

인증 정보 간편 설정을 위한 확장 함수
class MockkPrincipal(private val username: String) : Principal {
    override fun getName() = username
}

fun MockHttpServletRequestBuilder.setAuthorization(
    userName: String = "username",
): MockHttpServletRequestBuilder {
    return this
        .header("Authorization", "Bearer test-token")
        .principal(MockkPrincipal(userName))
}
  • 인증 정보 설정을 한 줄의 코드로 간편하게 처리합니다.
  • 반복적인 헤더와 Principal 생성을 제거하여 가독성을 높였습니다.

 

JSON 직렬화 및 역직렬화를 위한 확장 함수
inline fun <reified T> MvcResult.toResponse(): T {
    return objectMapper.readValue(this.response.contentAsString)
}

inline fun <reified T> T.toRequest(): String {
    return objectMapper.writeValueAsString(this)
}

object ObjectMappers {
    lateinit var objectMapper: ObjectMapper
}

 

우선 MvcResult의 객체 반환값이 mvcResult.reponse.contentAsString는 JSON 타입의 문자열인데 
저는 controller에서 반환된 값이 제가 예상한 DTO와 같은 타입인지 확인하기 위해 반환 값을 다시 객체로 변환해줘야 했습니다.

mvcResult.toResponse<MemberInfoResponse>() shouldBe expectedResponse

그래서 위와 같이 T타입 객체로 변환해서 검증했습니다.

이때 inline을 사용한 이유는 reified를 사용하기 위함입니다.

[ inline ]
1. 함수 내부에서 제네릭 타입 정보를 유지하고자 할 때
2. 함수 호출 오버헤드 제거

inline의 위와 같은 특징 중 저는 첫 번째 특징을 사용하기 위해 사용했습니다.

 

이때 reified는 코틀린은 기본적으로 타입 소거(type erasure) 라는 특징 때문에 JVM에서 제네릭 타입인 T는 런타임에 지워지는 특징이 있습니다.

이렇게 되면 T::class, TypeReference<T>(), readValue<T>() 같은 코드정상적으로 타입을 알 수 없게 된다.

 

그래서 inline + reified를 활용하면 아래와 같은 효과를 볼 수 있다.

 

  • inline 함수로 만들면 컴파일 시점에 코드가 해당 호출 지점에 복사
  • reified 타입 덕분에 컴파일러가 T의 타입 정보를 유지하게 되어 readValue<T>()를 사용할 수 있음
inline fun <reified T> T.toRequest(): String {
    return objectMapper.writeValueAsString(this)
}

 

toRequest또한 content로 넘길 때 JSON타입으로 변환해줘야하기 때문에 

When("POST /api/member/fcm 을 호출하면") {
    Then("FCM 토큰이 정상적으로 재발급되어야한다.") {
        mockMvc.perform(
            post("/api/member/fcm")
                .content(fcmRequest.toRequest())
                .contentType(MediaType.APPLICATION_JSON)
        )

이런식으로 toRequest()확장함수를 통해 객체를 JSON타입으로 쉽게 변환할 수 있다.

 

andDocument() 확장함수

RestDocs를 작성할 때 andDo(document())처럼 불필요한 호출을 하는 부분이 있었다.

물론 이게 크게 거슬리진 않았지만 최대한 간결하게 만들고 싶은 바램이 있었다.

 

그래서 andDo()의 타입인 ResultActions을 활용해 

fun ResultActions.andDocument(
    identifier: String,
    vararg snippets: Snippet
) : ResultActions = andDo(document(identifier, *snippets))

andDocument()라는 확장함수를 생성했다.

 

test("해당 요일에 있는 할 일 목록 조회 API") {
        val today = LocalDate.of(2025, 5, 13)
        val createDateRequest = createDateRequest()
        val todoResponses = listOf(
            createTodoResponse(1L, "", today),
        )
        every { memberTodoService.findTodoLists(any(), any()) } returns todoResponses

        val mvcResult = mockMvc.perform(
            post(TODO_PATH + "list")
                .setAuthorization()
                .contentType(MediaType.APPLICATION_JSON)
                .content(createDateRequest.toRequest())
        ).andExpect(status().isOk)
            .andDocument(
                "find-todoList",
                authorizationHeader(),
                requestFields(
                    "deadline" typeOf STRING means "마감일 (yyyy-MM-dd)"
                ),
                responseFields(
                    "todoListId" arrayTypeOf NUMBER means "할 일 Id",
                    "content" arrayTypeOf STRING means "할 일 내용",
                    "memo" arrayTypeOf STRING means "메모",
                    "tag" arrayTypeOf STRING means "태크",
                    "deadline" arrayTypeOf ARRAY means "마감일 (yyyy-MM-dd)",
                    "todoStatus" arrayTypeOf STRING means "할 일 상태"
                )
            ).andReturn()

        mvcResult.toResponse<List<TodoResponse>>() shouldBe todoResponses
    }

최종적으로 다음과 같은 테스트 코드를 작성할 수 있게 되었다.

 

최종 테스트 코드는 TDD-be 레포에서 확인하실 수 있습니다.