Theme:

"JUnit과 Mockito로 코틀린 테스트를 작성하면 뭔가 어색합니다. 코틀린에 더 잘 맞는 테스트 도구는 없을까요?"

Kotest와 MockK는 코틀린을 위해 처음부터 설계된 테스트 프레임워크입니다. 코루틴 네이티브 지원, DSL 기반의 깔끔한 문법, 프로퍼티 기반 테스트 등을 제공합니다. JUnit + Mockito 조합과 비교하면서, 어떤 점이 다른지 살펴보겠습니다.

의존성 설정

KOTLIN
// build.gradle.kts
dependencies {
    testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
    testImplementation("io.kotest:kotest-assertions-core:5.8.0")
    testImplementation("io.kotest:kotest-property:5.8.0")
    testImplementation("io.mockk:mockk:1.13.8")
}

tasks.test {
    useJUnitPlatform()
}

Kotest 테스트 스타일

Kotest는 여러 가지 테스트 스타일을 제공합니다. 상황에 맞는 스타일을 선택하면 됩니다.

FunSpec — 가장 기본적인 스타일

KOTLIN
class CalculatorTest : FunSpec({
    test("두 수를 더한다") {
        val calculator = Calculator()
        calculator.add(2, 3) shouldBe 5
    }

    test("0으로 나누면 예외가 발생한다") {
        val calculator = Calculator()
        shouldThrow<ArithmeticException> {
            calculator.divide(10, 0)
        }
    }
})

BehaviorSpec — BDD 스타일

KOTLIN
class UserServiceTest : BehaviorSpec({
    val userRepository = mockk<UserRepository>()
    val userService = UserService(userRepository)

    Given("사용자가 존재할 때") {
        val user = User(id = 1, name = "김코틀린", email = "kotlin@test.com")
        every { userRepository.findById(1) } returns user

        When("ID로 조회하면") {
            val result = userService.findById(1)

            Then("사용자 정보를 반환한다") {
                result shouldNotBe null
                result!!.name shouldBe "김코틀린"
            }
        }

        When("이메일로 조회하면") {
            every { userRepository.findByEmail("kotlin@test.com") } returns user
            val result = userService.findByEmail("kotlin@test.com")

            Then("같은 사용자를 반환한다") {
                result shouldBe user
            }
        }
    }

    Given("사용자가 존재하지 않을 때") {
        every { userRepository.findById(999) } returns null

        When("존재하지 않는 ID로 조회하면") {
            Then("null을 반환한다") {
                userService.findById(999) shouldBe null
            }
        }
    }
})

StringSpec — 가장 간결한 스타일

KOTLIN
class StringSpecExample : StringSpec({
    "문자열의 길이를 반환한다" {
        "kotlin".length shouldBe 6
    }

    "빈 문자열은 비어있다" {
        "".shouldBeEmpty()
    }
})

DescribeSpec — Jasmine/RSpec 스타일

KOTLIN
class OrderServiceTest : DescribeSpec({
    describe("주문 생성") {
        val orderService = OrderService()

        it("총 금액을 계산한다") {
            val order = orderService.createOrder(
                items = listOf(Item("키보드", 100_000), Item("마우스", 50_000))
            )
            order.totalPrice shouldBe 150_000
        }

        context("할인 쿠폰이 있을 때") {
            it("할인이 적용된 금액을 반환한다") {
                val order = orderService.createOrder(
                    items = listOf(Item("키보드", 100_000)),
                    coupon = Coupon(discountPercent = 10)
                )
                order.totalPrice shouldBe 90_000
            }
        }
    }
})

Kotest Assertions — 풍부한 검증 DSL

Kotest의 assertion은 가독성이 뛰어납니다.

KOTLIN
// 기본 비교
result shouldBe expected
result shouldNotBe unexpected

// 컬렉션
list shouldContain "kotlin"
list shouldHaveSize 3
list.shouldContainAll("a", "b", "c")
list.shouldBeSorted()

// 문자열
"Hello Kotlin" shouldStartWith "Hello"
"Hello Kotlin" shouldContain "Kotlin"
"Hello Kotlin".shouldMatch(Regex("Hello.*"))

// 예외
shouldThrow<IllegalArgumentException> {
    validate(-1)
}.message shouldBe "음수는 허용되지 않습니다"

// nullable
result.shouldNotBeNull()
result.shouldBeNull()

// 범위
score shouldBeInRange 0..100
temperature shouldBeLessThan 100.0

Soft Assertions — 모든 실패를 한 번에 보기

KOTLIN
assertSoftly(user) {
    name shouldBe "김코틀린"
    email shouldBe "kotlin@test.com"
    age shouldBeGreaterThan 0
    // 모든 검증을 실행하고, 실패한 것들을 한 번에 보여줌
}

일반 assertion은 첫 번째 실패에서 멈추지만, assertSoftly는 모든 검증을 실행한 뒤 실패 목록을 한꺼번에 보여줍니다.

MockK — 코틀린 네이티브 모킹

기본 사용법

KOTLIN
// mock 생성
val repository = mockk<UserRepository>()

// stub 정의
every { repository.findById(1) } returns User(id = 1, name = "테스트")
every { repository.findById(any()) } returns null
every { repository.save(any()) } answers { firstArg() }

// 호출 검증
verify { repository.findById(1) }
verify(exactly = 0) { repository.delete(any()) }
verify(atLeast = 1) { repository.save(any()) }

suspend 함수 모킹 — coEvery, coVerify

KOTLIN
val apiClient = mockk<ApiClient>()

// suspend 함수는 coEvery/coVerify 사용
coEvery { apiClient.fetchUser(1) } returns User(id = 1, name = "코루틴")
coEvery { apiClient.fetchUser(999) } throws NotFoundException("없음")

// 테스트
runTest {
    val user = apiClient.fetchUser(1)
    user.name shouldBe "코루틴"
}

coVerify { apiClient.fetchUser(1) }

relaxed mock — 기본값 자동 반환

KOTLIN
// strict mock — stub이 없으면 예외 발생
val strict = mockk<UserRepository>()

// relaxed mock — stub이 없으면 기본값 반환 (0, false, "", 빈 컬렉션 등)
val relaxed = mockk<UserRepository>(relaxed = true)

relaxed.findById(1) // 예외 대신 null 반환 (nullable 타입)
relaxed.count()      // 0 반환

인자 캡처 — slot과 capture

KOTLIN
val slot = slot<User>()
every { repository.save(capture(slot)) } answers { slot.captured }

userService.createUser("김코틀린", "kotlin@test.com")

// 캡처된 인자 검증
slot.captured.name shouldBe "김코틀린"
slot.captured.email shouldBe "kotlin@test.com"

여러 번 호출되는 경우에는 MutableList를 사용합니다.

KOTLIN
val captured = mutableListOf<User>()
every { repository.save(capture(captured)) } answers { firstArg() }

userService.createUser("사용자1", "user1@test.com")
userService.createUser("사용자2", "user2@test.com")

captured shouldHaveSize 2
captured[0].name shouldBe "사용자1"
captured[1].name shouldBe "사용자2"

spyk — 실제 객체를 부분적으로 모킹

KOTLIN
val calculator = spyk(Calculator())

// 실제 메서드는 그대로 실행
calculator.add(2, 3) shouldBe 5

// 특정 메서드만 오버라이드
every { calculator.multiply(any(), any()) } returns 42
calculator.multiply(2, 3) shouldBe 42 // 오버라이드된 값

프로퍼티 기반 테스트

다양한 입력값에 대해 속성이 항상 성립하는지 검증합니다.

KOTLIN
class PropertyTest : FunSpec({
    test("문자열을 뒤집으면 길이가 같다") {
        forAll<String> { str ->
            str.reversed().length == str.length
        }
    }

    test("리스트를 정렬하면 크기가 유지된다") {
        forAll<List<Int>> { list ->
            list.sorted().size == list.size
        }
    }

    test("절대값은 항상 0 이상이다") {
        forAll(Arb.int()) { n ->
            abs(n.toLong()) >= 0
        }
    }
})

Kotest가 자동으로 다양한 입력(빈 문자열, 특수문자, 큰 수 등)을 생성해서 테스트합니다.

코루틴 테스트

KOTLIN
class CoroutineServiceTest : FunSpec({
    test("병렬 요청의 결과를 합산한다") {
        val apiClient = mockk<ApiClient>()
        coEvery { apiClient.fetchCount("a") } coAnswers {
            delay(100) // 시뮬레이션
            10
        }
        coEvery { apiClient.fetchCount("b") } coAnswers {
            delay(200)
            20
        }

        val service = AggregationService(apiClient)

        runTest {
            val result = service.totalCount(listOf("a", "b"))
            result shouldBe 30
        }
    }
})

runTestkotlinx-coroutines-test에서 제공하며, 가상 시간을 사용하여 delay를 즉시 건너뜁니다.

JUnit 5와의 비교

기능JUnit 5 + MockitoKotest + MockK
테스트 스타일어노테이션 기반DSL 기반, 여러 스타일
AssertionAssertJ 별도 추가내장 DSL
모킹Mockito (자바 기반)MockK (코틀린 네이티브)
코루틴 지원runBlocking 필요네이티브 지원
final class 모킹mockito-inline 필요기본 지원
프로퍼티 테스트별도 라이브러리내장

JUnit 5도 코틀린에서 충분히 잘 동작합니다. 기존 프로젝트가 JUnit 기반이라면 굳이 전환할 필요는 없습니다. 다만 MockK는 코루틴 모킹이 자연스러워서, JUnit + MockK 조합도 많이 사용합니다.

테스트 라이프사이클

KOTLIN
class LifecycleTest : FunSpec({
    beforeSpec { println("스펙 시작 전 — 한 번만 실행") }
    afterSpec { println("스펙 끝난 후 — 한 번만 실행") }

    beforeTest { println("각 테스트 전") }
    afterTest { println("각 테스트 후") }

    beforeEach { println("beforeTest와 동일") }
    afterEach { println("afterTest와 동일") }

    test("테스트 1") { /* ... */ }
    test("테스트 2") { /* ... */ }
})

정리

  • Kotest는 FunSpec, BehaviorSpec, DescribeSpec 등 다양한 스타일을 제공하여 상황에 맞는 테스트 구조를 선택할 수 있습니다
  • MockK는 코틀린의 final 클래스, 코루틴, 확장 함수 등을 네이티브로 모킹할 수 있습니다
  • coEvery/coVerify로 suspend 함수를 자연스럽게 모킹할 수 있습니다
  • assertSoftly로 모든 검증 실패를 한 번에 확인할 수 있습니다
  • 프로퍼티 기반 테스트(forAll)로 다양한 입력에 대한 견고한 검증이 가능합니다
댓글 로딩 중...