Kotlin 테스트 패턴
Kotest와 MockK를 사용하여 TDD 방법론을 따르는 신뢰할 수 있고 유지 관리 가능한 테스트 작성을 위한 포괄적인 Kotlin 테스트 패턴입니다.
사용 시기
- 새 Kotlin 함수나 클래스를 작성할 때
- 기존 Kotlin 코드에 테스트 커버리지를 추가할 때
- 속성 기반 테스트를 구현할 때
- Kotlin 프로젝트에서 TDD 워크플로를 따를 때
- Kover를 사용한 코드 커버리지 설정 시
작동 방식
- 대상 코드 식별 — 테스트할 함수, 클래스 또는 모듈을 찾습니다.
- Kotest 사양 작성 — 테스트 범위에 맞는 사양 스타일(StringSpec, FunSpec, BehaviorSpec)을 선택합니다.
- 의존성 모의(Mock) — MockK를 사용하여 테스트 대상을 격리합니다.
- 테스트 실행(RED 단계) — 테스트가 예상한 오류와 함께 실패하는지 확인합니다.
- 코드 구현(GREEN 단계) — 테스트를 통과하는 최소한의 코드를 작성합니다.
- 리팩터링 — 테스트를 통과하는 상태를 유지하면서 구현을 개선합니다.
- 커버리지 확인 —
./gradlew koverHtmlReport를 실행하고 80% 이상의 커버리지를 확인합니다.
예제
다음 섹션에는 각 테스트 패턴에 대한 상세하고 실행 가능한 예제가 포함되어 있습니다.
빠른 참조
- Kotest 사양 — Kotest 사양 스타일의 StringSpec, FunSpec, BehaviorSpec, DescribeSpec 예제
- 모의(Mocking) — MockK의 MockK 설정, 코루틴 모의, 인자 캡처
- TDD 워크스루 — Kotlin TDD 워크플로의 EmailValidator를 사용한 전체 RED/GREEN/REFACTOR 주기
- 커버리지 — Kover 커버리지의 Kover 설정 및 명령어
- Ktor 테스트 — Ktor testApplication 테스트의 testApplication 설정
Kotlin TDD 워크플로
RED-GREEN-REFACTOR 주기
RED -> 먼저 실패하는 테스트 작성
GREEN -> 테스트를 통과하는 최소한의 코드 작성
REFACTOR -> 테스트를 통과하는 상태를 유지하면서 코드 개선
REPEAT -> 다음 요구사항으로 계속 진행
Kotlin 단계별 TDD
// 1단계: 인터페이스/시그니처 정의
// EmailValidator.kt
package com.example.validator
fun validateEmail(email: String): Result<String> {
TODO("not implemented")
}
// 2단계: 실패하는 테스트 작성 (RED)
// EmailValidatorTest.kt
package com.example.validator
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.result.shouldBeFailure
import io.kotest.matchers.result.shouldBeSuccess
class EmailValidatorTest : StringSpec({
"유효한 이메일은 성공 반환" {
validateEmail("[email protected]").shouldBeSuccess("[email protected]")
}
"빈 이메일은 실패 반환" {
validateEmail("").shouldBeFailure()
}
"@가 없는 이메일은 실패 반환" {
validateEmail("userexample.com").shouldBeFailure()
}
})
// 3단계: 테스트 실행 - 실패 확인
// $ ./gradlew test
// EmailValidatorTest > 유효한 이메일은 성공 반환 FAILED
// kotlin.NotImplementedError: An operation is not implemented
// 4단계: 최소한의 코드 구현 (GREEN)
fun validateEmail(email: String): Result<String> {
if (email.isBlank()) return Result.failure(IllegalArgumentException("이메일은 비어 있을 수 없습니다"))
if ('@' !in email) return Result.failure(IllegalArgumentException("이메일은 @를 포함해야 합니다"))
val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
if (!regex.matches(email)) return Result.failure(IllegalArgumentException("잘못된 이메일 형식"))
return Result.success(email)
}
// 5단계: 테스트 실행 - 통과 확인
// $ ./gradlew test
// EmailValidatorTest > 유효한 이메일은 성공 반환 PASSED
// EmailValidatorTest > 빈 이메일은 실패 반환 PASSED
// EmailValidatorTest > @가 없는 이메일은 실패 반환 PASSED
// 6단계: 필요 시 리팩터링 후 테스트가 여전히 통과하는지 확인
Kotest 사양 스타일
StringSpec (가장 간단함)
class CalculatorTest : StringSpec({
"두 양수 더하기" {
Calculator.add(2, 3) shouldBe 5
}
"음수 더하기" {
Calculator.add(-1, -2) shouldBe -3
}
"0 더하기" {
Calculator.add(0, 5) shouldBe 5
}
})
FunSpec (JUnit 스타일)
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser가 사용자를 찾으면 반환") {
val expected = User(id = "1", name = "Alice")
coEvery { repository.findById("1") } returns expected
val result = service.getUser("1")
result shouldBe expected
}
test("getUser가 사용자를 찾지 못하면 예외 발생") {
coEvery { repository.findById("999") } returns null
shouldThrow<UserNotFoundException> {
service.getUser("999")
}
}
})
BehaviorSpec (BDD 스타일)
class OrderServiceTest : BehaviorSpec({
val repository = mockk<OrderRepository>()
val paymentService = mockk<PaymentService>()
val service = OrderService(repository, paymentService)
Given("유효한 주문 요청") {
val request = CreateOrderRequest(
userId = "user-1",
items = listOf(OrderItem("product-1", quantity = 2)),
)
When("주문이 접수됨") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Success
coEvery { repository.save(any()) } answers { firstArg() }
val result = service.placeOrder(request)
Then("확인된 주문을 반환해야 함") {
result.status shouldBe OrderStatus.CONFIRMED
}
Then("결제가 청구되어야 함") {
coVerify(exactly = 1) { paymentService.charge(any()) }
}
}
When("결제 실패") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
Then("PaymentException 예외를 던져야 함") {
shouldThrow<PaymentException> {
service.placeOrder(request)
}
}
}
}
})
DescribeSpec (RSpec 스타일)
class UserValidatorTest : DescribeSpec({
describe("validateUser") {
val validator = UserValidator()
context("유효한 입력으로") {
it("일반 사용자 승인") {
val user = CreateUserRequest("Alice", "[email protected]")
validator.validate(user).shouldBeValid()
}
}
context("잘못된 이름으로") {
it("빈 이름 거부") {
val user = CreateUserRequest("", "[email protected]")
validator.validate(user).shouldBeInvalid()
}
it("최대 길이 초과 이름 거부") {
val user = CreateUserRequest("A".repeat(256), "[email protected]")
validator.validate(user).shouldBeInvalid()
}
}
}
})
Kotest 매처
핵심 매처
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.*
import io.kotest.matchers.collections.*
import io.kotest.matchers.nulls.*
// 동등성
result shouldBe expected
result shouldNotBe unexpected
// 문자열
name shouldStartWith "Al"
name shouldEndWith "ice"
name shouldContain "lic"
name shouldMatch Regex("[A-Z][a-z]+")
name.shouldBeBlank()
// 컬렉션
list shouldContain "item"
list shouldHaveSize 3
list.shouldBeSorted()
list.shouldContainAll("a", "b", "c")
list.shouldBeEmpty()
// null
result.shouldNotBeNull()
result.shouldBeNull()
// 타입
result.shouldBeInstanceOf<User>()
// 숫자
count shouldBeGreaterThan 0
price shouldBeInRange 1.0..100.0
// 예외
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "나이는 양수여야 합니다"
shouldNotThrow<Exception> {
validateAge(25)
}
사용자 정의 매처
fun beActiveUser() = object : Matcher<User> {
override fun test(value: User) = MatcherResult(
value.isActive && value.lastLogin != null,
{ "사용자 ${value.id}는 활성 상태이며 마지막 로그인이 있어야 합니다" },
{ "사용자 ${value.id}는 활성 상태가 아니어야 합니다" },
)
}
// 사용법
user should beActiveUser()
MockK
기본 모의
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // Relaxed: 기본값 반환
val service = UserService(repository, logger)
beforeTest {
clearMocks(repository, logger)
}
test("findUser가 repository에 위임") {
val expected = User(id = "1", name = "Alice")
every { repository.findById("1") } returns expected
val result = service.findUser("1")
result shouldBe expected
verify(exactly = 1) { repository.findById("1") }
}
test("findUser가 알 수 없는 ID에 대해 null 반환") {
every { repository.findById(any()) } returns null
val result = service.findUser("unknown")
result.shouldBeNull()
}
})
코루틴 모의
class AsyncUserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser 일시 중단 함수") {
coEvery { repository.findById("1") } returns User(id = "1", name = "Alice")
val result = service.getUser("1")
result.name shouldBe "Alice"
coVerify { repository.findById("1") }
}
test("getUser 지연 포함") {
coEvery { repository.findById("1") } coAnswers {
delay(100) // 비동기 작업 시뮬레이션
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
인자 캡처
test("save가 사용자 인자를 캡처") {
val slot = slot<User>()
coEvery { repository.save(capture(slot)) } returns Unit
service.createUser(CreateUserRequest("Alice", "[email protected]"))
slot.captured.name shouldBe "Alice"
slot.captured.email shouldBe "[email protected]"
slot.captured.id.shouldNotBeNull()
}
스파이 및 부분 모의
test("실제 객체에 대한 스파이") {
val realService = UserService(repository)
val spy = spyk(realService)
every { spy.generateId() } returns "fixed-id"
spy.createUser(request)
verify { spy.generateId() } // 재정의됨
// 다른 메서드는 실제 구현 사용
}
코루틴 테스트
일시 중단 함수를 위한 runTest
import kotlinx.coroutines.test.runTest
class CoroutineServiceTest : FunSpec({
test("동시 가져오기가 함께 완료됨") {
runTest {
val service = DataService(testScope = this)
val result = service.fetchAllData()
result.users.shouldNotBeEmpty()
result.products.shouldNotBeEmpty()
}
}
test("지연 후 타임아웃") {
runTest {
val service = SlowService()
shouldThrow<TimeoutCancellationException> {
withTimeout(100) {
service.slowOperation() // 100ms보다 오래 걸림
}
}
}
}
})
Flow 테스트
import io.kotest.matchers.collections.shouldContainInOrder
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
class FlowServiceTest : FunSpec({
test("observeUsers가 업데이트를 방출") {
runTest {
val service = UserFlowService()
val emissions = service.observeUsers()
.take(3)
.toList()
emissions shouldHaveSize 3
emissions.last().shouldNotBeEmpty()
}
}
test("searchUsers가 입력을 디바운스") {
runTest {
val service = SearchService()
val queries = MutableSharedFlow<String>()
// 참고: 아래 결과 수집은 실제로는 변경될 수 있습니다.
// 디바운스 동작은 테스트 환경에 따라 다를 수 있습니다.
val results = mutableListOf<List<User>>()
val job = launch {
service.searchUsers(queries).collect { results.add(it) }
}
queries.emit("a")
queries.emit("ab")
queries.emit("abc") // 이 값만 검색을 트리거해야 함
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
class DispatcherTest : FunSpec({
test("제어된 실행을 위한 테스트 디스패처 사용") {
val dispatcher = StandardTestDispatcher()
runTest(dispatcher) {
var completed = false
launch {
delay(1000)
completed = true
}
completed shouldBe false
advanceTimeBy(1000)
completed shouldBe true
}
}
})
속성 기반 테스트
Kotest 속성 테스트
import io.kotest.core.spec.style.FunSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
import io.kotest.property.checkAll
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
// 참고: 아래 직렬화 라운드트립 테스트는 User 데이터 클래스가
// @Serializable (kotlinx.serialization에서) 어노테이션되어 있어야 합니다.
class PropertyTest : FunSpec({
test("문자열 뒤집기는 멱등(involutory)이다") {
forAll<String> { s ->
s.reversed().reversed() == s
}
}
test("리스트 정렬은 멱등(idempotent)이다") {
forAll(Arb.list(Arb.int())) { list ->
list.sorted() == list.sorted().sorted()
}
}
test("직렬화 라운드트립은 데이터를 보존한다") {
checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email ->
User(name = name, email = "$email@test.com")
}) { user ->
val json = Json.encodeToString(user)
val decoded = Json.decodeFromString<User>(json)
decoded shouldBe user
}
}
})
사용자 정의 생성기
class CustomGeneratorTest : FunSpec({
test("사용자 정의 생성기로 이메일 테스트") {
val emailArb = Arb.string(5..30, Arb.Companion.stringVal().let {
Arb.string(5..30) // 임시: 유효한 이메일 생성을 위한 실제 구현 필요
})
forAll<String> { email ->
val atCount = email.count { it == '@' }
atCount <= 1
}
}
})
Kover 커버리지
Gradle 설정
// build.gradle.kts
plugins {
kotlin("jvm") version "1.9.22"
id("org.jetbrains.kotlinx.kover") version "0.7.6"
}
dependencies {
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
testImplementation("io.kotest:kotest-property:5.9.1")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
}
// Kover 설정 (선택 사항)
kover {
engine.set("intellij") // 또는 "jacoco"
}
커버리지 명령어
# HTML 보고서 생성
./gradlew koverHtmlReport
# 보고서 위치: build/reports/kover/html/index.html
# XML 보고서 (CI용)
./gradlew koverXmlReport
# 보고서 위치: build/reports/kover/xml/report.xml
# 검증 (기본: 80% 라인 커버리지)
./gradlew koverVerify
# 특정 규칙으로 검증
# build.gradle.kts:
# kover {
# verify {
# rule {
# minOf(80) // 최소 라인 커버리지 80%
# }
# }
# }
커버리지 제외
// build.gradle.kts에서 특정 클래스 제외
kover {
excludeClassses {
classes(
"com.example.generated.*",
"com.example.MainKt*",
)
}
}
// 코드에서 제외
@Suppress("UNUSED")
class UtilityClass {
// Kover가 이 클래스를 무시하도록 함
}
Ktor testApplication 테스트
testApplication 설정
class UserApiTest : FunSpec({
test("GET /users/{id}가 사용자를 반환") {
testApplication {
application {
configureRouting()
}
val response = client.get("/users/1")
response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldContain "\"id\":\"1\""
}
}
test("POST /users가 사용자 생성") {
testApplication {
application {
configureRouting()
}
val newUser = CreateUserRequest("Bob", "[email protected]")
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(newUser)
}
response.status shouldBe HttpStatusCode.Created
}
}
})
모범 사례
일반 지침
-
의미 있는 테스트 이름 사용 — 테스트 이름은 동작을 설명해야 함
- 좋은 예:
"빈 이메일 주소로 로그인하면 예외가 발생" - 나쁜 예:
"testLoginFails"
- 좋은 예:
-
AAA 패턴 따르기 — Arrange, Act, Assert
test("사용자 생성") { // Arrange val request = CreateUserRequest("Alice", "[email protected]") every { repository.save(any()) } returns User("1", "Alice", "[email protected]") // Act val user = service.createUser(request) // Assert user.id.shouldNotBeNull() user.name shouldBe "Alice" } -
테스트 격리 — 각 테스트는 독립적이어야 함
beforeTest/afterTest에서 모의 객체 초기화- 공유 가변 상태 피하기
-
모의는 적게 — 필요한 경우에만 모의 사용
- 값 객체나 순수 함수는 모의하지 않음
- 외부 의존성(데이터베이스, API)에 모의 사용
-
모든 경로 테스트 — 성공 케이스, 오류 케이스, 경계 케이스
- 경계 케이스: 빈 입력, null, 최대 길이, 특수 문자
- 오류 케이스: 예외, 타임아웃, 거부
테스트 구조
src/
├── main/kotlin/
│ └── com/example/
│ ├── validator/
│ │ └── EmailValidator.kt
│ └── service/
│ └── UserService.kt
└── test/kotlin/
└── com/example/
├── validator/
│ └── EmailValidatorTest.kt
└── service/
└── UserServiceTest.kt
참고: 표준 Gradle 프로젝트 구조 유지 — 테스트는 src/test/kotlin/ 아래에 있고, 프로덕션 코드와 동일한 패키지를 사용합니다.
TDD 체크리스트
- 구현 전에 실패하는 테스트를 먼저 작성했습니까?
- 테스트가 프로덕션 코드 변경 없이 독립적으로 실행됩니까?
- 모의는 필요한 의존성에만 사용되었습니까?
- 코루틴 코드에서
runTest를 사용했습니까? - 엣지 케이스(비어 있음, null, 특수 문자)를 테스트했습니까?
- 커버리지가 80% 이상입니까?
- 속성 기반 테스트가 주요 동작을 검증합니까?
- 각 테스트는 단일 책임을 가지며, 한 가지를 명확히 테스트합니까?