백엔드도 코틀린 언어로 많이 넘어온 것 같습니다. 사내외에서 코틀린으로 전환했다라는 이야기도 많이 들려오고 제가 있는 팀도 최근 코틀린을 사용하기 시작했는데요. 코틀린에서는 기존 자바 진영에서 많이 사용되는 junit5 + mockito 보다 코틀린에 더 fit하게 단위 테스트를 할 수 있는 프레임워크인 kotest와 mockK가 인기가 많은 것 같습니다. 새로운 언어와 테스트 프레임워크로 전환하며 알게 된 Kotest와 mockK를 소개하고 간단한 예제를 작성해보았습니다.
코틀린을 코틀린스럽게 테스트할 수 있는 테스트 프레임워크로 구체적으로는 다음과 같은 기능을 제공합니다.
BDD(Behavior Driven Development)
TDD에서 파생된 개발 방법으로 시나리오를 기반으로 테스트 케이스를 작성하기 때문에 테스트를 이해하기 쉽습니다.
Given(주어진 환경에서)-When(이렇게 실현이 된다면)-Then(다음과 같은 결과가 나와야 한다) 구조를 기본 패턴으로 사용합니다.
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()
}
Kotest에서 제공하는 주요 Test Layout을 소개합니다.
String으로 간결하게 테스트 구문을 작성할 수 있도록 제공합니다.
class MyTests : StringSpec({
"strings.length should return size of string" {
"hello".length shouldBe 5
}
})
should 함수를 string인수로 호출해 테스트를 기술할 수 있습니다.
class MyTests : ShouldSpec({
should("return the length of the string") {
"sammy".length shouldBe 5
"".length shouldBe 0
}
})
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
}
}
}
})
코틀린 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")
코틀린에서 mockking 할 수 있도록 제공하는 라이브러리로 자바진영에서 많이 사용되는 mokito를 대체합니다.
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)
간단한 계좌이체 송금 테스트 케이스 입니다.
/**
* [ 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
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/