| name | kotlin-testing |
| description | 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)
class UserServiceTest : 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 "abc"
str shouldMatch Regex("[a-z]+")
count shouldBeGreaterThan 0
price shouldInRange 1.0..100.0
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "Age must be positive"
shouldNotThrow<Exception> {
validateAge(25)
}
커스텀 Matcher
fun beActiveUser() = object : Matcher<User> {
override fun test(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
기본 모킹
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true)
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 null
val result = service.findUser("unknown")
result.shouldBeNull()
}
})
코루틴 모킹
class AsyncUserServiceTest : 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
class CoroutineServiceTest : 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()
}
}
}
}
})
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 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()
}
}
})
TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
class DispatcherTest : FunSpec({
test("uses test dispatcher for controlled execution") {
val dispatcher = StandardTestDispatcher()
runTest(dispatcher) {
var completed = false
launch {
delay(1000)
completed = true
}
completed shouldBe false
advanceTimeBy(1000)
completed shouldBe true
}
}
})
속성 기반 테스트 (Property-Based Testing)
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
class PropertyTest : FunSpec({
test("string reverse is involutory") {
forAll<String> { s ->
s.reversed().reversed() == s
}
}
test("list sort is idempotent") {
forAll(Arb.list(Arb.int())) { list ->
list.sorted() == list.sorted().sorted()
}
}
test("serialization roundtrip preserves data") {
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
}
}
})
커스텀 Generator
val userArb: Arb<User> = Arb.bind(
Arb.string(minSize = 1, maxSize = 50),
Arb.email(),
Arb.enum<Role>(),
) { name, email, role ->
User(
id = UserId(UUID.randomUUID().toString()),
name = name,
email = Email(email),
role = role,
)
}
val moneyArb: Arb<Money> = Arb.bind(
Arb.long(1L..1_000_000L),
Arb.enum<Currency>(),
) { amount, currency ->
Money(amount, currency)
}
데이터 기반 테스트 (Data-Driven Testing)
Kotest의 withData
class ParserTest : FunSpec({
context("parsing valid dates") {
withData(
"2026-01-15" to LocalDate(2026, 1, 15),
"2026-12-31" to LocalDate(2026, 12, 31),
"2000-01-01" to LocalDate(2000, 1, 1),
) { (input, expected) ->
parseDate(input) shouldBe expected
}
}
context("rejecting invalid dates") {
withData(
nameFn = { "rejects '$it'" },
"not-a-date",
"2026-13-01",
"2026-00-15",
"",
) { input ->
shouldThrow<DateParseException> {
parseDate(input)
}
}
}
})
테스트 생명주기 및 Fixtures
BeforeTest / AfterTest
class DatabaseTest : FunSpec({
lateinit var db: Database
beforeSpec {
db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
transaction(db) {
SchemaUtils.create(UsersTable)
}
}
afterSpec {
transaction(db) {
SchemaUtils.drop(UsersTable)
}
}
beforeTest {
transaction(db) {
UsersTable.deleteAll()
}
}
test("insert and retrieve user") {
transaction(db) {
UsersTable.insert {
it[name] = "Alice"
it[email] = "alice@example.com"
}
}
val users = transaction(db) {
UsersTable.selectAll().map { it[UsersTable.name] }
}
users shouldContain "Alice"
}
})
Kotest Extensions
class DatabaseExtension : BeforeSpecListener, AfterSpecListener {
lateinit var db: Database
override suspend fun beforeSpec(spec: Spec) {
db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
}
override suspend fun afterSpec(spec: Spec) {
}
}
class UserRepositoryTest : FunSpec({
val dbExt = DatabaseExtension()
register(dbExt)
test("save and find user") {
val repo = UserRepository(dbExt.db)
}
})
Kover 커버리지
Gradle 설정
plugins {
id("org.jetbrains.kotlinx.kover") version "0.9.7"
}
kover {
reports {
total {
html { onCheck = true }
xml { onCheck = true }
}
filters {
excludes {
classes("*.generated.*", "*.config.*")
}
}
verify {
rule {
minBound(80)
}
}
}
}
커버리지 명령
./gradlew koverHtmlReport
./gradlew koverVerify
./gradlew koverXmlReport
커버리지 목표
| 코드 유형 | 목표 |
|---|
| 핵심 비즈니스 로직 | 100% |
| 퍼블릭 API | 90%+ |
| 일반 코드 | 80%+ |
| 자동 생성 / 설정 코드 | 제외 |
Ktor testApplication 테스트
class ApiRoutesTest : FunSpec({
test("GET /users returns list") {
testApplication {
application {
configureRouting()
configureSerialization()
}
val response = client.get("/users")
response.status shouldBe HttpStatusCode.OK
val users = response.body<List<UserResponse>>()
users.shouldNotBeEmpty()
}
}
test("POST /users creates user") {
testApplication {
application {
configureRouting()
configureSerialization()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("Alice", "alice@example.com"))
}
response.status shouldBe HttpStatusCode.Created
}
}
})
테스트 명령
./gradlew test
./gradlew test --tests "com.example.UserServiceTest"
./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found"
./gradlew test --info
./gradlew detekt
./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를 사용하여 의존성을 깔끔하게 모킹하세요.