Kotest, MockK, 코루틴 테스트, 속성 기반 테스트 및 Kover 커버리지를 포함한 Kotlin 테스트 가이드입니다.
origin
ECC
Kotlin 테스트 주도 개발 (Kotlin Testing & TDD)
Kotest와 MockK를 사용하여 견고하고 표현력 있는 Kotlin 애플리케이션을 구축하기 위한 포괄적인 테스트 가이드입니다.
사용 시점
새로운 Kotlin 프로젝트를 시작하거나 기존 프로젝트에 테스트를 추가할 때
비즈니스 로직에 TDD(테스트 주도 개발)를 구현할 때
비동기 코드를 위한 코루틴 테스트가 필요할 때
MockK를 사용한 의존성 모킹(Mocking)이 필요할 때
Kotest 속성 기반 테스트(Property-based testing)로 엣지 케이스를 검증할 때
Kover를 사용하여 테스트 커버리지를 측정할 때
Kotest 설정 및 스타일
권장되는 Spec 스타일 (FunSpec)
classUserServiceTest : FunSpec({
test("user name should not be empty") {
val user = User(id = "1", name = "Alice")
user.name.shouldNotBeEmpty()
}
context("login validation") {
test("should fail with invalid credentials") {
// 테스트 로직
}
}
})
Matchers (Assertion)
// 기본
result shouldBe expected
result shouldNotBe null// 컬렉션
list shouldHaveSize 3
list shouldContain "Kotlin"
list.shouldNotBeEmpty()
str shouldStartWith
str shouldMatch Regex()
count shouldBeGreaterThan
price shouldInRange .
shouldThrow<IllegalArgumentException> {
validateAge(-)
}.message shouldBe
shouldNotThrow<Exception> {
validateAge()
}
// 문자열
"abc"
"[a-z]+"
// 숫자
0
1.0
.100
.0
// 예외
1
"Age must be positive"
25
커스텀 Matcher
funbeActiveUser() = object : Matcher<User> {
overridefuntest(value: User) = MatcherResult(
value.isActive && value.lastLogin != null,
{ "User ${value.id} should be active with a last login" },
{ "User ${value.id} should not be active" },
)
}
// 사용
user should beActiveUser()
MockK
기본 모킹
classUserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // relaxed: 기본값 반환val service = UserService(repository, logger)
beforeTest {
clearMocks(repository, logger)
}
test("findUser delegates to 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 returns null for unknown id") {
every { repository.findById(any()) } returns nullval result = service.findUser("unknown")
result.shouldBeNull()
}
})
코루틴 모킹
classAsyncUserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser suspend function") {
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 with delay") {
coEvery { repository.findById("1") } coAnswers {
delay(100) // 비동기 작업 시뮬레이션
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
인자 캡처 (Argument Capture)
test("save captures the user argument") {
val slot = slot<User>()
coEvery { repository.save(capture(slot)) } returns Unit
service.createUser(CreateUserRequest("Alice", "alice@example.com"))
slot.captured.name shouldBe "Alice"
slot.captured.email shouldBe "alice@example.com"
slot.captured.id.shouldNotBeNull()
}
Spy 및 부분 모킹 (Partial Mocking)
test("spy on real object") {
val realService = UserService(repository)
val spy = spyk(realService)
every { spy.generateId() } returns "fixed-id"
spy.createUser(request)
verify { spy.generateId() } // 오버라이드된 메서드 검증// 다른 메서드들은 실제 구현을 사용함
}
코루틴 테스트
Suspend 함수를 위한 runTest
import kotlinx.coroutines.test.runTest
classCoroutineServiceTest : FunSpec({
test("concurrent fetches complete together") {
runTest {
val service = DataService(testScope = this)
val result = service.fetchAllData()
result.users.shouldNotBeEmpty()
result.products.shouldNotBeEmpty()
}
}
test("timeout after delay") {
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
classFlowServiceTest : FunSpec({
test("observeUsers emits updates") {
runTest {
val service = UserFlowService()
val emissions = service.observeUsers()
.take(3)
.toList()
emissions shouldHaveSize 3
emissions.last().shouldNotBeEmpty()
}
}
test("searchUsers debounces input") {
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()
}
}
})
# 모든 테스트 실행
./gradlew test# 특정 테스트 클래스 실행
./gradlew test --tests "com.example.UserServiceTest"# 특정 테스트 실행
./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found"# 상세 출력과 함께 실행
./gradlew test --info
# 정적 분석 (detekt) 실행
./gradlew detekt
# 포맷팅 체크 (ktlint) 실행
./gradlew ktlintCheck
# 연속 테스트 실행
./gradlew test --continuous
모범 사례 (Best Practices)
권장 사항:
테스트를 먼저 작성하세요 (TDD)
프로젝트 전반에서 Kotest의 Spec 스타일을 일관되게 사용하세요
suspend 함수에는 MockK의 coEvery/coVerify를 사용하세요
코루틴 테스트에는 runTest를 사용하세요
구현이 아닌 동작(behavior)을 테스트하세요
순수 함수에는 속성 기반 테스트를 사용하세요
명확성을 위해 data class 테스트 피처(fixtures)를 사용하세요
피해야 할 사항:
여러 테스트 프레임워크를 섞어 쓰지 마세요 (Kotest를 선택했다면 그것만 사용)
데이터 클래스를 모킹하지 마세요 (실제 인스턴스 사용)
코루틴 테스트에서 Thread.sleep()을 사용하지 마세요 (advanceTimeBy 사용)
TDD에서 RED 단계를 건너뛰지 마세요
private 함수를 직접 테스트하지 마세요
불안정한(flaky) 테스트를 방치하지 마세요
기억하세요: 테스트는 문서입니다. Kotlin 코드가 어떻게 사용되어야 하는지 보여줍니다. Kotest의 표현력 있는 매처를 사용하여 테스트를 읽기 쉽게 만들고, MockK를 사용하여 의존성을 깔끔하게 모킹하세요.