👻 Java Restdocs코드

junit5로-계층-구조의-테스트-코드-작성하기 - 기계인간 John Grib글을 보고 난 후 계층형으로 테스트 코드를 작성해 보았는데, 테스트 결과가 한눈에 파악하기 좋게 트리구조로 출력되어서 다음과 같은 DCI패턴의 계층형 테스트를 선호하게 되었다.

@Nested
@DisplayName("POST")
class DescribePOST {

  @Nested
  @DisplayName("유효한 요청이 전달되면")
  class ContextWithValidRequest {
    setAuthenticationHolder(1L, Role.USER);
    final var requestBody = new BoardCreateRequest("Test", "Test");
    final var jsonBody = objectMapper.writeValueAsString(requestBody);

    final var request = RestDocumentationRequestBuilders
          .post(URL)
          .contentType(MediaType.APPLICATION_JSON)
          .content(jsonBody);

    @Test
    @DisplayName("201 응답")
    void ItResponse201() throws Exception {
      final var response = mockMvc.perform(request);

      response.andExpect(status().isCreated())
              .andDo(document("Create Board", requestFields(
                  fieldWithPath("title").type(JsonFieldType.STRING).description("게시판 이름"),
                  fieldWithPath("description").type(JsonFieldType.STRING).description("게시판 설명")
              )));
    }

  }

 

하지만 간단하게 주석으로 다음과 같은 스타일의 테스트 코드를 작성하는 것에 비해 @Nested어노테이션을 매번 붙여주어야 하고, 클래스명들도 정의해 주어야 해서 테스트 작성시간이 오래 걸린다는 생각이 들었다.

@Test
void test() {
  //given
  ...
  //when
  ...
  //then
  ...
}

물론 인텔리제이 Live template을 활용해 모든코드를 일일이 작성하지는 않았지만 테스트 코드 외 불필요한 코드들이 여전히 많은 수의 라인을 차지하고 있어 테스트 코드에 온전히 집중하기 힘들었다.

 

이러한 고민을 하던 찰나에 Kotest에서는 DCI패턴의 테스팅 스타일을 지원한다는 이야기를 듣고 찾아보게 되었다.

👍 왜 Kotest인가

  • Kotlin DSL 을 활용한 다양한 테스팅 스타일을 제공한다
  • Kotest는 Junit과 호환된다

테스팅 스타일
Kotest에는 여러 언어의 테스트프레임워크로부터 영감을 받은 테스팅 스타일을 제공한다.

Java - Junit

@Nested
@DisplayName("...")
class Describe... {

  @Nested
  @DisplayName("...")
  class ContextWith... {

    @Test
    @DisplayName("...")
    void It...("...") {
      ...
    }

  }

}

Kotlin - Kotest

describe("...") {
  context("...") {
    it("...") {
        ...
    }
  }
}

Kotlin DSL 을 활용하니 테스트 코드가 훨씬 눈에 잘 들어온다. 또 Java에서 DCI패턴을 활용할 때는 매번 클래스 명과 메서드 명을 정의해 주어야 해서 불편한 점이 있었는데 Kotest를 활용하니 이러한 점이 사라져서 좋았다.

그렇다면 이를 활용해서 기존 Junit 테스트 코드를 Kotest 테스트 코드로 리펙토링 해보자.

♻️ 리펙토링

테스트 하려는 클래스는 컨트롤러 계층의 BoardRestController클래스로 서비스 계층에 대해 다음과 같은 의존성을 갖고 있었다.

public class BoardRestController {

  private final BoardCommandService boardCommandService;
  private final BoardQueryService boardQueryService;

기존 테스트 코드는 @MockBean을 통해 두 서비스 클래스를 모킹 하고 있었는데, Koltin에서 역시 동일하게 사용가능하므로 다음과 같이 테스트를 작성해 주었다.

class BoardRestControllerTest(
  @MockBean
  private val commandService: BoardCommandService,
  @MockBean
  private val queryService: BoardQueryService,
  ...

이후 DCI 테스팅 스타일을 활용해 Restdocs코드까지 다음과 같이 작성해 주었다. @Nested와 불필요한 클래스 선언 코드들이 사라지니 조금 더 깔끔해진 느낌이다.

describe("POST : /api/v1/boards") {
  val url = "/api/v1/boards"
  context("유효한 요청이 전달 되면") {
    val authentication = Authentication(1L, Role.USER)
    AuthenticationContextHolder.setAuthentication(authentication)

    val requestBody = BoardCreateRequest("Test", "Test")
    val requestJson = mapper.writeValueAsString(requestBody)
    val request = request(HttpMethod.POST, url)
        .contentType(MediaType.APPLICATION_JSON)
        .content(requestJson)

    it("201 응답") {
      mockMvc
          .perform(request)
          .andExpect(status().isCreated)
          .andDo(document("Create Board", requestFields(
                   fieldWithPath("title").type(JsonFieldType.STRING).description("게시판 이름"),
                   fieldWithPath("description").type(JsonFieldType.STRING).description("게시판 설명")
          )))
    }
  }

하지만 restDocs와 관련된 코드들이 거슬렸다. andDo(docuemnt(… 같은 형태의 메서드 호출은 반복되고 fieldWithPath... 같은 형태의 체이닝 메서드는 매번 자동 포매팅을 할 때마다 이상하게 정렬되고 한눈에 잘 들어오지 않는다는 생각이 들었다.

✚ 더 개선하기

JAVA에서는 Standard Library나 외부 라이브러리를 사용하는 경우 새로운 함수를 추가하기 어렵다. 하지만 코틀에서는 Extension functions(확장 함수)을 활용하면 기존에 정의된 클래스에 함수를 손쉽게 추가할 수 있다.

 

우선 andDo() 메서드를 가지고 있는 클래스에 코틀린 확장함수로 andDocument() 함수를 다음과 같이 구현하여 반복되는. andDo(document(... 호출을 개선하였다.

fun ResultActions.andDocument(
  identifier: String,
  vararg snippets: Snippet
): ResultActions {
  return andDo(document(identifier, *snippets))
}
it("201 응답") {
      mockMvc
          .perform(request)
          .andExpect(status().isCreated)
          .andDocument("Create Board", requestFields(
                   fieldWithPath("title").type(JsonFieldType.STRING).description("게시판 이름"),
                   fieldWithPath("description").type(JsonFieldType.STRING).description("게시판 설명")
          )))
    }

다음으로 restdocs코드의 경우 infix function(중위함수)와 문자열 extension functions(확장함수)를 통해 개선할 수 있었다.

우선 description과 optional 과같은 descriptor의 체이닝 메서드들을 중위함수로 처리할 수 있도록 RestDocField를 구현하였다.

class RestDocField(
  val descriptor: FieldDescriptor
) {

  infix fun isOptional(value: Boolean): RestDocField {
    if (value) descriptor.optional()
    return this
  }

  infix fun isIgnored(value: Boolean): RestDocField {
    if (value) descriptor.ignored()
    return this
  }

  infix fun means(value: String): RestDocField {
    descriptor.description(value)
    return this
  }

  infix fun attributes(block: RestDocField.() -> Unit): RestDocField {
    block()
    return this
  }
}

그리고 문자열을 통해 descriptor를 정의할 수 있도록 문자열에 대한 확장함수를 구현하였다.

infix fun String.fieldType(
  docsFieldType: DocsFieldType
): RestDocField {
  return createField(this, docsFieldType.type)
}

private fun createField(
  value: String,
  type: JsonFieldType,
  optional: Boolean = true
): RestDocField {
  val descriptor = PayloadDocumentation
      .fieldWithPath(value)
      .type(type)
      .description("")

  if (optional) descriptor.optional()

  return RestDocField(descriptor)
}

fun requestBody(vararg fields: RestDocField): RequestFieldsSnippet =
  PayloadDocumentation.requestFields(fields.map { it.descriptor })

fun responseBody(vararg fields: RestDocField): ResponseFieldsSnippet =
  PayloadDocumentation.responseFields(fields.map { it.descriptor })

적용 후

describe("POST : /api/v1/boards") {
  val url = "/api/v1/boards"
  context("유효한 요청이 전달 되면") {
    val authentication = Authentication(1L, Role.USER)
    AuthenticationContextHolder.setAuthentication(authentication)

    val requestBody = BoardCreateRequest("Test", "Test")
    val requestJson = mapper.writeValueAsString(requestBody)
    val request = request(HttpMethod.POST, url)
        .contentType(MediaType.APPLICATION_JSON)
        .content(requestJson)

    it("201 응답") {
      mockMvc
          .perform(request)
          .andExpect(status().isCreated)
          .andDocument(
            "Create Board", (
                requestBody(
                  "title" fieldType STRING means "게시판 이름" isOptional false,
                  "description" fieldType STRING means "게시판 설명" isOptional true
                ))
          )
    }
  }
}

좀 더 파라미터가 많은 예시

describe("GET : /api/v1/boards/{id}") {
  val url = "/api/v1/boards/{id}"
  context("유효한 요청이 전달 되면") {
    val id = 1L
    val request = request(HttpMethod.GET, url, id)
    it("200 응답") {
      val response = getTestBoardGetDetailResponse()
      given(queryService.getDetail(anyLong())).willReturn(response)
      mockMvc
          .perform(request)
          .andExpect(status().isOk)
          .andDocument(
            "Lookup Board",
            pathParameters(
              "id" pathMeans "게시판 번호"
            ),
            responseBody(
              "id" fieldType NUMBER means "게시판 번호" isOptional false,
              "title" fieldType STRING means "게시판 제목" isOptional false,
              "description" fieldType STRING means "게시판 제목" isOptional true,
              "creatorInfo.id" fieldType NUMBER means "게시판 생성자 번호" isOptional false,
              "creatorInfo.name" fieldType STRING means "게시판 생성자 이름" isOptional false,
              "creatorInfo.imageUrl" fieldType STRING means "게시판 생성자 이미지 URL" isOptional false,
              "createdAt" fieldType STRING means "게시판 생성일" isOptional false,
            )
          )
    }
  }
}

Kotest와 코틀린의 확장함수, 중위함수를 통해 기존 테스트 코드를 훨씬 가독성 있게 변경하였다. 이러한 리팩터링은 필드와 Path 파라미터들이 많이 필요할수록 눈에 띄었다. 코틀린의 특성을 활용해 DSL을 작성하고 기존 코드를 리펙토링 해보면서 코틀린의 장점을 더 크게 느낄 수 있었다.

Reference

+ Recent posts