Kotest + MockK를 활용한 코틀린 단위 테스트

조재윤·2022년 6월 1일
2
post-thumbnail

서론

백엔드도 코틀린 언어로 많이 넘어온 것 같습니다. 사내외에서 코틀린으로 전환했다라는 이야기도 많이 들려오고 제가 있는 팀도 최근 코틀린을 사용하기 시작했는데요. 코틀린에서는 기존 자바 진영에서 많이 사용되는 junit5 + mockito 보다 코틀린에 더 fit하게 단위 테스트를 할 수 있는 프레임워크인 kotest와 mockK가 인기가 많은 것 같습니다. 새로운 언어와 테스트 프레임워크로 전환하며 알게 된 Kotest와 mockK를 소개하고 간단한 예제를 작성해보았습니다.

Kotest란?

코틀린을 코틀린스럽게 테스트할 수 있는 테스트 프레임워크로 구체적으로는 다음과 같은 기능을 제공합니다.

  1. 코틀린에서 제공하는 코틀린 특화 기능(Coroutine, Extension Function, Kotlin DSL등)을 지원합니다.
  2. 다양한 Assertions를 kotlin DSL스타일로 제공합니다.
  3. BDD를 포함한 다양한 Test Layout을 제공합니다.

    BDD(Behavior Driven Development)
    TDD에서 파생된 개발 방법으로 시나리오를 기반으로 테스트 케이스를 작성하기 때문에 테스트를 이해하기 쉽습니다.
    Given(주어진 환경에서)-When(이렇게 실현이 된다면)-Then(다음과 같은 결과가 나와야 한다) 구조를 기본 패턴으로 사용합니다.

Dependencies

build.gradle.kts

dependencies {   
    testImplementation("io.kotest:kotest-runner-junit5-jvm:${KOTEST_VERSION}")
    testImplementation("io.kotest:kotest-assertions-core-jvm:${KOTEST_VERSION}")
}

tasks.test {
    useJUnitPlatform()
}

Testing Styles

Kotest에서 제공하는 주요 Test Layout을 소개합니다.

StringSpec

String으로 간결하게 테스트 구문을 작성할 수 있도록 제공합니다.

class MyTests : StringSpec({
    "strings.length should return size of string" {
        "hello".length shouldBe 5
    }
})

ShouldSpec

should 함수를 string인수로 호출해 테스트를 기술할 수 있습니다.

class MyTests : ShouldSpec({
    should("return the length of the string") {
        "sammy".length shouldBe 5
        "".length shouldBe 0
    }
})

BehaviorSpec

BDD의 기본 템플릿인 Given-When-Then구조로 테스트를 작성할 수 있습니다.

class MyTests : BehaviorSpec({
    given("a broomstick") {
        `when`("I sit on it") {
            then("I should be able to fly") {
                // test code
            }
        }
        `when`("I throw it away") {
            then("it should come back") {
                // test code
            }
        }
    }
})

Assertions

코틀린 DSL스타일의 간결한 assetions를 제공합니다.

name shouldBe "sam"
name shouldNotBe null
"substring".shouldContain("str")
           .shouldBeLowerCase()
val exception = shouldThrow<IllegalAccessException> {
   // code in here that you expect to throw an IllegalAccessException
}
exception.message should startWith("Something went wrong")

mockK란?

코틀린에서 mockking 할 수 있도록 제공하는 라이브러리로 자바진영에서 많이 사용되는 mokito를 대체합니다.

Dependencies

dependencies {
    testImplementation("io.mockk:mockk:{$MOCKK_VERSION}")
}

every, verify등 다양한 mocking 및 검증함수를 제공합니다.

val car = mockk<Car>()

every { car.drive(Direction.NORTH) } returns Outcome.OK

car.drive(Direction.NORTH) // returns OK

verify { car.drive(Direction.NORTH) }

confirmVerified(car)

Example

간단한 계좌이체 송금 테스트 케이스 입니다.

/**
 *    [ Test 작성 순서 ]
 *    실패테스트 작성 → 테스트 성공시키기 → 코드 리팩토링
 *
 *    [ 요구 사항 ]
 *    1. 보내거나 받는계좌가 존재해야 한다.
 *    2. 송금액은 송금계좌의 잔액이하여야 한다.
 *    3. 송금액은 하루 출금 가능 금액을 초과할 수 없다.
 *    4. 송금액은 한번에 출금 가능한 금액을 초과할 수 없다.
 *    5. 이체금액이 입,출금계좌에 잔액에 반영되어야 한다.
 *    6. 입,출금 계좌에 이체 기록이 남아야 한다.
 */
 
val historyRepository: HistoryRepository = mockk(relaxed = true)
val accountRepository: AccountRepository = mockk()
val transferRepository: TransferRepository = mockk()
 
@InjectMockKs
val transferValidator: TransferValidator = TransferValidator(transferRepository)
 
@InjectMockKs
val transferService: TransferService = TransferService(accountRepository, transferValidator, historyRepository)
 
class TransferTest : BehaviorSpec({
 
    Given("송금이 가능한 상황에서") {
        val transferAmount = 10L
        val beforeAmount = 1000L
        val fromAccount = Account(
            id = 1L,
            balance = beforeAmount,
            withdrawalAvailableAmountPerDay = 10000L,
            withdrawalAvailableAmountAtOnce = 100L
        )
        val toAccount = Account(
            id = 2L,
            balance = beforeAmount
        )
        every { accountRepository.findById(fromAccount.id) } returns Optional.of(fromAccount)
        every { accountRepository.findById(toAccount.id) } returns Optional.of(toAccount)
        every { transferRepository.getWithdrawalAmount(fromAccount, LocalDate.now()) } returns 0L
 
        When("송금하기를 요청하면") {
            transferService.transfer(fromAccount.id, toAccount.id, transferAmount)
            Then("송신계좌의 잔액은 송금액만큼 빠져냐가야 한다") {
                fromAccount.balance shouldBe (beforeAmount - transferAmount)
            }
            Then("수신계좌의 잔액은 송금액만큼 늘어나야 한다") {
                toAccount.balance shouldBe (beforeAmount + transferAmount)
            }
            Then("이체기록이 남아야 한다") {
                verify(exactly = 1) { historyRepository.save(fromAccount, transferAmount, HistoryType.WITHDRAW) }
                verify(exactly = 1) { historyRepository.save(toAccount, transferAmount, HistoryType.DEBIT) }
            }
        }
    }
 
    Given("송금액이 한번에 출금 가능한 금액을 초과하는 상황에서") {
        val fromAccount = Account(
            id = 1L,
            balance = 10000L,
            withdrawalAvailableAmountPerDay = 10000L,
            withdrawalAvailableAmountAtOnce = 100L
        )
        val toAccount = Account(
            id = 2L
        )
        every { accountRepository.findById(fromAccount.id) } returns Optional.of(fromAccount)
        every { accountRepository.findById(toAccount.id) } returns Optional.of(toAccount)
        every { transferRepository.getWithdrawalAmount(fromAccount, LocalDate.now()) } returns 0L
 
        When("송금하기를 요청하면") {
            val exception = shouldThrow<ExceedWithdrawalAmountAtOnceException> {
                transferService.transfer(fromAccount.id, toAccount.id, fromAccount.withdrawalAvailableAmountAtOnce + 1)
            }
            Then("에외가 발생해야한다.") {
                exception.message shouldBe ExceedWithdrawalAmountAtOnceException.ERROR_MESSAGE
            }
        }
    }
 
    Given("송금액이 하루 출금 가능 금액을 초과한 상황에서") {
        val fromAccount = Account(
            id = 1L,
            balance = 1000L,
            withdrawalAvailableAmountPerDay = 1000L
        )
        val toAccount = Account(
            id = 2L
        )
        every { accountRepository.findById(fromAccount.id) } returns Optional.of(fromAccount)
        every { accountRepository.findById(toAccount.id) } returns Optional.of(toAccount)
        every {
            transferRepository.getWithdrawalAmount(
                fromAccount,
                LocalDate.now()
            )
        } returns fromAccount.withdrawalAvailableAmountPerDay
        When("송금하기를 요청했을 때") {
            val exception = shouldThrow<ExceedWithdrawalAmountPerDayException> {
                transferService.transfer(fromAccount.id, toAccount.id, 1000L)
            }
            Then("예외가 발생해야 한다") {
                exception.message shouldBe ExceedWithdrawalAmountPerDayException.ERROR_MESSAGE
            }
        }
    }
 
    Given("송금액이 송신계좌의 잔액 이상인 상황에서") {
        val fromAccount = Account(
            id = 1L,
            balance = 1000L
        )
        val toAccount = Account(
            id = 2L
        )
        every { accountRepository.findById(fromAccount.id) } returns Optional.of(fromAccount)
        every { accountRepository.findById(toAccount.id) } returns Optional.of(toAccount)
        When("송금하기를 요청하면") {
            val exception = shouldThrow<LeakOfBalanceException> {
                transferService.transfer(fromAccount.id, toAccount.id, fromAccount.balance + 1)
            }
            Then("예외가 발생해야 한다.") {
                exception.message shouldBe LeakOfBalanceException.ERROR_MESSAGE
            }
        }
    }
 
 
    Given("보내거나 받는계좌가 존재하지 않는 상황일 때") {
        every { accountRepository.findById(-1L) } returns Optional.empty()
        every { accountRepository.findById(1L) } returns Optional.of(Account(id = 1L))
        When("송금하기를 요청하면") {
            val exception = shouldThrow<AccountNotFoundException> {
                transferService.transfer(-1L, 1L, 1000L)
            }
            Then("예외가 발생해야한다. (송신 계좌가 없을 때)") {
                exception.message shouldBe AccountNotFoundException.ERROR_MESSAGE
            }
        }
 
        When("송금하기를 요청하면") {
            val exception = shouldThrow<AccountNotFoundException> {
                transferService.transfer(1L, -1L, 1000L)
            }
            Then("예외가 발생해야한다. (수신 계좌가 없을 때)") {
                exception.message shouldBe AccountNotFoundException.ERROR_MESSAGE
            }
        }
    }
})

전체코드는 아래에서 확인할 수 있습니다.
https://github.com/jaeyun-jo/tdd

References

https://kotest.io/docs/framework/framework.html
https://kotest.io/docs/assertions/assertions.html
https://mockk.io/
https://techblog.woowahan.com/5825/
https://veluxer62.github.io/explanation/comparing-testing-library-for-kotlin/

profile
NAVER Backend Engineer

0개의 댓글