[Kotlin] Kotest와 MockK를 활용해 Spring Boot에서 테스트 작성하기

Revimal (서현호)·2022년 4월 30일
4
post-thumbnail

개요

프로젝트를 진행함에 있어서, 테스트는 코드의 무결성을 보장해주는 최소한의 안전 장치이다.
특정 기능을 아무리 견고하게 작성했더라도, 코드를 변경하게 되면 개발자는 큰 불안감을 느낀다.
이때 근간이 되는 요구 사항을 테스트 코드로 작성해놓았다면, 변경된 기능의 안정성을 신속하고 신뢰성 있게 검증할 수 있다.

그럼 더 나아가, 구현에 앞서 테스트 케이스를 작성한다면 더 좋지 않을까?
요구 사항에 대한 테스트 케이스를 먼저 작성하고, 그에 맞추어가며 코드를 작성해가는 개념을 TDD1라고 부른다.

자, 그러면 Kotlin + Spring Boot 프로젝트 구성에서 TDD를 하기 위한 방법에는 뭐가 있을까?
뭐... 여러가지 방법이 있겠지만, 나는 KotestMockK을 조합해서 사용하기로 했다.

Kotest와 MockK를 선택한 이유

Kotest를 선택한 이유

Spring Boot를 테스트 할 수 있는 프레임워크에는 여러가지가 있었다.
가장 많이 사용되는 JUnit, BDD2 개념을 받아들인 Spock와 그 Kotlin 파생형인 Spek이 대표적.

하지만 나는 아래와 같은 이유로 Kotest를 선택했다.

  • 처음부터 Kotlin을 위해 개발된 테스트 프레임워크로, Kotlin DSL3을 완벽하게 지원한다.
  • Spock/Spek과 같이 BDD 개념도 쉽게 적용할 수 있고, 그 외에도 Describe 등의 다양한 테스트 스타일을 지원한다.
  • 공식 예제 코드를 봐도, @Annotation을 떡칠하지 않고 예쁘게 테스트 코드를 작성 할 수 있다4.
  • 프레임워크 생태계나 문서화 또한 활발해서, 사후 지원에 대한 걱정을 사실상 하지 않아도 되었다.

'이래서 Kotest를 사용한다' 보다는 '모든게 다 되는 Kotest를 사용하지 않을 이유가 없다'에 가까운 느낌이었다.

MockK을 선택한 이유

모킹 프레임워크를 고를 때에는 큰 고민이 없었다.
이전까지 Java/Kotlin 프로젝트에서 모킹을 할 때에는 Mockito를 사용했었다.
큰 이유는 없었고, 제일 레퍼런스도 많고 주변에서 얘기도 많이 해서 관성에 따라 사용했었다.
그런데 테스트 프레임워크를 Kotest로 바꾸고 나니까, '모킹 프레임워크도 더 좋은게 있는거 아니야?'라고 생각이 들었고.
마침 그때쯤 해서, 같이 사이드 프로젝트를 진행하던 팀원이 '저는 MockK 써서 모킹하는데요!'라는 얘기를 해 주었다.

찾아보니까, 'Mockito에서 지원하는 모든 기능에 몇몇 추가 기능을 더한, Kotlin을 위한 모킹 프레임워크'라고 나오더라.

  • Coroutine 지원도 되고, Object도 모킹할 수 있고, Private 메소드도 모킹할 수 있고, 생성자까지 모킹할 수 있더라.
  • 그냥 쉽게 말하자면, 'Kotlin에서 언어적으로 지원하는 거, 전부 모킹할 수 있다니까!' 비슷한 느낌?
  • 사용자 수도 적지 않고... 깃허브 통계상 최근 사용하는 프로젝트들도 꽤 많이 늘어났더라.

'한번 써볼까?' 싶었다.

테스트 준비

테스트의 큰 틀 잡기

테스트에 필요한 프레임워크들도 전부 모았으니, Spring Boot 프로젝트를 어떻게 테스트할지 고민해보았다.

일단, 내가 생각하는 Clean Architecture 분류에 따라 테스트 대상을 나누어 보았다.

  • Infrastructure: Database와 그 연결부인 Repository 정도를 분류할 수 있을 것이다.
  • Adapters: 아마도, Controller(혹은 Router와 Handler)가 여기에 속할 것이다.
  • Use Cases: Service에 해당하는 부분들이 여기에 들어갈 거고.
  • Entity: 정말 근본적인 Domain Entity들이 포함되겠지.

자, 그러면 각 부분에 맞는 테스트 방법을 고민해보자.

  • Infrastructure: 어플리케이션과 독립적인 계층이니, 굳이 테스트를 수행한다면 Redis등을 사용한 통합 테스트?
  • Adapters: WebTestClient등으로 Request/Response를, Service는 Mockk로 모킹해서 Kotest 테스트 진행.
  • Use Cases: Repository를 MockK로 모킹한 뒤, Kotest 테스트 진행.
  • Entity: 이상적인 상황이라면 Data class 형태일테니 테스트가 필요없겠지만, 그게 아니라면 Kotest로 유닛 테스트.

테스트 스타일 결정

Service 테스트 스타일 결정

Use Cases에 해당하는 Service 클래스들은 'Given-When-Then' 패턴으로 테스트하기로 결정.

  • 주어진 상황이 있고 (Given) / 무엇을 할 때에 (When) / 이런 결과가 나온다 (Then)

특정 행동에 따라, 특정 결과가 나올 것이 명확하게 구분되는 Service 로직의 테스트에 적합하다고 생각됐다.

Given("저장된 작업이 1개 있는 상황에서") {
    every { taskRepository.getAllTasks() } returns singleTaskFlux.log()
    When("모든 작업 리스트를 요청하면") {
        val result = StepVerifier.create(service.getAllTasks())

        Then("결과 리스트를 조회해야 한다") {
            verify(exactly = 1) {
                taskRepository.getAllTasks()
            }
        }
        Then("조회된 결과 리스트에 1개의 작업이 존재해야 한다") {
            result
                .expectSubscription()
                .expectNext(*singleGetAllTasksDataList.toTypedArray())
                .expectComplete()
                .verify()
        }
    }

Controller 테스트 스타일 결정

HTTP Request/Response를 직접 처리하는 Controller 클래스는, 'Describe-Context-It' 패턴으로 테스트하기로 결정.

  • 특정 기능을 (Describe) / 사용자가 어떠한 식으로 사용하면 (Context) / Controller는 이렇게 반응한다 (It)

사용자와 기능의 상호 관계와, 그 동작 결과에 집중하는 특성은 Controller 로직의 테스트에 적합하다고 생각했다.

describe("getAllTasks를") {
    val performRequest = { webTestClient.get().uri("/task").exchange() }
    context("저장된 데이터가 없는 상황에서 요청한 경우") {
        every { taskService.getAllTasks() } returns emptyGetAllTasksDataFlux.log()
        val response = performRequest()

        it("서비스를 통해 데이터를 조회한다") {
            verify(exactly = 1) {
                taskService.getAllTasks()
            }
        }
        it("요청은 성공한다") {
            response.expectStatus().isOk
        }
        it("반환 형식은 JSON이다") {
            response.expectHeader().contentType(MediaType.APPLICATION_JSON)
        }
        it("JSON은 비어 있다") {
            response.expectBody().json(emptyTaskResponseBody)
        }
    }
}

코드로 보는 Kotest/MockK 사용법

class TaskServiceTest : BehaviorSpec() {
    /* ... */
    private val manyTaskList = listOf(
        TaskFactory.create(Task.Status.IN_PROGRESS),
        TaskFactory.create(Task.Status.TODO),
        TaskFactory.create(Task.Status.WONT_DO),
        TaskFactory.create(Task.Status.DONE)
    )
    private val manyTaskFlux = TestUtils.listToFlux(manyTaskList)
    private val manyGetAllTasksDataList = manyTaskList.map { GetAllTasksData.fromTask(it) }

    init {
        val taskRepository = mockk<TaskRepository>()
        val service = TaskService(taskRepository)

        afterContainer {
            clearAllMocks()
        }

        /* ... */

        Given("저장된 작업이 N개 있는 상황에서") {
            every { taskRepository.getAllTasks() } returns manyTaskFlux.log()
            When("모든 작업 리스트를 요청하면") {
                val result = StepVerifier.create(service.getAllTasks())

                Then("결과 리스트를 조회해야 한다") {
                    verify(exactly = 1) {
                        taskRepository.getAllTasks()
                    }
                }
                Then("조회된 결과 리스트에 N개의 작업이 존재해야 한다") {
                    result
                        .expectSubscription()
                        .expectNext(*manyGetAllTasksDataList.toTypedArray())
                        .expectComplete()
                        .verify()
                }
            }
        }
    }
}

위 코드는, TaskService를 테스트 하기 위한, TaskServiceTest 클래스의 구현이다.

'Given-When-Then' 패턴을 사용하기 위해, BehaviorSpecTaskServiceTest 클래스에 적용시켜주자.
그런 뒤에 TaskRepository를 MockK로 모킹해 테스트 대상인 TaskService에 주입해주자.
이러면 테스트 케이스 작성 준비가 전부 끝난다.

'주어진 상황 (Given)'은 '저장된 작업이 N개 있는 상황'이다.
즉, getAllTasks() 메소드를 호출했을 때, 1개 이상의 결과 값이 반환되어야 한다.

TaskRepositorygetAllTasks() 메소드가 manyTaskFlux를 리턴할 수 있게끔 every{}returns 구문을 이용해 동작을 정의해준다.

만약에, TaskRepositorygetAllTasks()가 인자를 받는다면 어떻게 해야 할까.
대부분의 경우에는 TaskRepositorygetAllTasks()에 어떤 인자가 들어가든지 관계없이 특정 값을 리턴하게끔 모킹을 진행할 것이다.
이 경우 every { taskRepository.getAllTasks(any()) }와 같이 any()를 통해 인자 자리를 채워주면 된다.

'무엇을 할 때에 (When)'는 '모든 작업 리스트를 요청하면'이다.
즉, TaskServicegetAllTasks() 메소드를 실제로 호출해야 한다.

TaskServicegetAllTask() 메소드 호출에 필요한 TaskRepository는 이미 MockK를 통해 모킹되었기 때문에, 그냥 그대로 호출하면 된다.

첫번째 '이런 결과가 나온다 (Then)'는 '결과 리스트를 조회해야 한다'이다.
즉, TaskRepositorygetAllTasks() 메소드가 실제로 불리었는지 체크해야 한다.

verify(exactly = 1) {} 구문을 통해 TaskServicegetAllTasks()를 호출했을 때에 TaskRepositorygetAllTasks() 메소드가 실제로 1번만 불리었는지 확인한다.

반대로, 특정 메소드가 한번도 불리지 않았다는 것을 검증하기 위한다면 verify(exactly = 0) {}를 사용하면 된다.

두번째 '이런 결과가 나온다 (Then)'는 '조회된 결과 리스트에 N개의 작업이 존재해야 한다'이다.
즉, TaskServicegetAllTasks() 메소드의 반환 값이 1개 이상의 데이터를 반환했는지 체크해야 한다.
내가 진행하고 있는 프로젝트가 WebFlux를 사용하지 않았다면 List<DTO>가 반환되었을 것이다.
그렇다면 result.size shouldBe dataSourceList.size와 같은 꼴로 확인할 수 있었을 것이다.

하지만 WebFlux를 사용하고 있기에 Flux<DTO>를 검증해야 됐고, 이는 Kotest와 MockK 외에 Reactor-Test의 StepVerifier를 사용해야 한다.

  • expectSubscription(): onSubscribe 이벤트를 검증하며, 제일 우선적으로 호출해야 한다.
  • expectNext(): onNext 이벤트를 검증하며, 각 데이터의 값에 대해 검증할 수 있다.
    • vararg T에 대한 오버로딩도 있기에 Spread Operator를 써서 쉽게 순차 검증을 할 수 있다.
  • expectComplete(): onComplete 이벤트를 검증하며, 스트림이 정상 종료되었음을 확인하기 위해 마지막에 호출해야 한다.
  • verify(): StepVerifier에 정의된 검증 케이스들을 모두 수행한다. (이 시점에 테스트 케이스의 성공/실패가 결정된다.)

마치며

개인적으로 'Kotest + MockK' 조합으로 작성한 테스트 코드에 대해서는 매우 만족 중이다.
테스트 목적에 맞는 스타일을 사용하고, 메소드마다 붙던 @Test 어노테이션이 없어지면서 테스트 코드의 가독성도 훨씬 높아졌다.
StepVerifierWebTestClient와 같이 기존 Spring Boot 테스트에 쓰이던 도구들과의 호환도 문제 없이 되고.
개인적으로는 여태까지 작성했던 'JUnit + Mockito' 테스트 코드를 전부 'Kotest + MockK' 조합으로 갈아엎고 싶을 정도이다.

물론, 이미 잘 동작하는 테스트 코드를 전부 바꾸는 건 해서는 안되는 짓이니 패스.
대신에 새로운 프로젝트를 시작할 때에는 무조건 'Kotest + MockK'를 사용할 예정이다.

부록

시행착오들에 대한 내 개인적인 메모

clearAllMocks()에 대해

afterContainer {
    clearAllMocks()
}

위에 기술된 세 줄... 매우 중요하다!

풀어서 쓰면 아래와 같은 의미이다.

  • afterContainer: Describe나 Given 등, 각 컨테이너 블록이 끝나고 난 뒤에 실행되는 동작을 기술한다.
  • clearAllMocks(): 모킹된 모든 객체들을 다시 원상 복구한다.

그러니까... 얘 빼먹으면 이전에 이미 수행된 테스트 케이스의 객체들이 그대로 사용되어, 원치 않은 결과가 나올 수 있다.

Kotest / Mockk의 라이프 사이클에 대한 잡담

이렇게 작성해도 정상적으로 동작한다.

class SimpleServiceTest : BehaviorSpec({
    val simpleEntity = Simple(/* ... */)
    init {
        val simpleRepository = mockk<simpleRepository>()
        /* ... */
        Given("Test Situation 1,") {
            /* ... */
        }
        /* ... */
    }
}

이렇게 작성해도 정상적으로 동작한다.

class SimpleServiceTest : BehaviorSpec({
    val simpleRepository = mockk<simpleRepository>()
    /* ... */
    Given("Test Situation 1,") {
        /* ... */
    }
    /* ... */
}) {
    companion object {
        val simpleEntity = Simple(/* ... */)
    }
}

근데 이렇게 작성하면 동작하지 않는다.

class SimpleServiceTest : BehaviorSpec() {
    val simpleEntity = Simple(/* ... */)
    val simpleRepository = mockk<simpleRepository>()
    /* ... */
    Given("Test Situation 1,") {
        /* ... */
    }
    /* ... */
}

무슨 말을 하고 싶은거냐면... Kotest / Mockk 테스트 코드는 테스트 클래스가 로드/초기화 되는 시점에 존재해야 한다.


Footnotes

[1]: Test-Driven-Development (테스트 주도 개발), 별도 TIL로 작성 예정.
[2]: Behaivor-Driven-Developement(행위 주도 개발), 사용자 입장에서의 행위를 기반으로 테스트 시나리오를 작성한다.
[3]: Domain Specific Language, 특정 목적(도메인)을 위해 파생된 별도 언어로 라이브러리나 프레임워크의 사용성을 높여준다.
[4]: '첫 인상이 평생을 간다'라고, 사실 이 점이 가장 마음에 들었다.
[5]: Data Transfer Object, 계층간 데이터 교환을 하기 위해 명세를 제한한 데이터 객체 (Data Class).


References

profile
Software Engineer

0개의 댓글