Communitygithub.com

kotlin-testing

Kotest, MockK, 코루틴 테스트, 속성 기반 테스트, Kover 커버리지를 활용한 Kotlin 테스트 패턴. TDD 방법론과 관용적인 Kotlin 관행을 따릅니다. Kotest나 MockK로 Kotlin 테스트를 작성하거나, 코루틴을 테스트하고 커버리지를 확인할 때 사용하세요.

kotlin-testing란 무엇인가요?

이 스킬은 Kotlin 개발자가 TDD(테스트 주도 개발) 방법론을 따르며, Kotest 프레임워크와 MockK 모킹 라이브러리를 사용해 신뢰성 있고 유지보수 가능한 테스트를 작성하는 방법을 제공합니다. 주요 플랫폼은 Kotlin/JVM 프로젝트이며, Kotest의 다양한 스펙 스타일(StringSpec, FunSpec, BehaviorSpec, DescribeSpec), MockK의 기본 모킹, 코루틴 모킹, 인자 캡처, 스파이 기능을 다룹니다. 또한 `runTest`를 사용한 코루틴 테스트, Flow 테스트, StandardTestDispatcher 사용법을 포함하며, 속성 기반 테스트(Property-Based Testing)로 무작위 입력을 검증하는 방법도 설명합니다. TDD 워크플로우(RED→GREEN→REFACTOR)와 함께 Kover를 통한 코드 커버리지 측정(`./gradlew koverHtmlReport`)으로 80% 이상의 커버리지를 유지하는 모범 사례를 제공합니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/everything-claude-code/tree/main/skills/kotlin-testing

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

Kotlin 테스트 패턴

TDD 방법론을 따라 Kotest와 MockK로 신뢰성 있고 유지보수 가능한 테스트를 작성하기 위한 포괄적인 Kotlin 테스트 패턴입니다.

사용 시기

  • 새로운 Kotlin 함수 또는 클래스를 작성할 때
  • 기존 Kotlin 코드에 테스트 커버리지를 추가할 때
  • 속성 기반(Property-Based) 테스트를 구현할 때
  • Kotlin 프로젝트에서 TDD 워크플로우를 따를 때
  • Kover를 사용해 코드 커버리지를 구성할 때

작동 방식

  1. 테스트 대상 코드 식별 — 테스트할 함수, 클래스 또는 모듈을 찾습니다
  2. Kotest 스펙 작성 — 테스트 범위에 맞는 스펙 스타일(StringSpec, FunSpec, BehaviorSpec)을 선택합니다
  3. 의존성 모킹 — MockK를 사용해 테스트 대상을 격리합니다
  4. 테스트 실행 (RED) — 예상된 오류와 함께 테스트가 실패하는지 확인합니다
  5. 코드 구현 (GREEN) — 테스트를 통과하는 최소한의 코드를 작성합니다
  6. 리팩토링 — 테스트가 통과하는 상태를 유지하면서 구현을 개선합니다
  7. 커버리지 확인 — ./gradlew koverHtmlReport를 실행하고 80% 이상의 커버리지를 확인합니다

예제

다음 섹션에는 각 테스트 패턴에 대한 상세하고 실행 가능한 예제가 포함되어 있습니다:

빠른 참조

Kotlin용 TDD 워크플로우

RED-GREEN-REFACTOR 사이클

RED     -> 먼저 실패하는 테스트를 작성
GREEN   -> 테스트를 통과하는 최소한의 코드를 작성
REFACTOR -> 테스트가 통과하는 상태를 유지하면서 코드 개선
REPEAT  -> 다음 요구사항으로 계속 진행

Kotlin에서의 단계별 TDD

// Step 1: 인터페이스/시그니처 정의
// EmailValidator.kt
package com.example.validator

fun validateEmail(email: String): Result<String> {
    TODO("not implemented")
}

// Step 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({
    "valid email returns success" {
        validateEmail("[email protected]").shouldBeSuccess("[email protected]")
    }

    "empty email returns failure" {
        validateEmail("").shouldBeFailure()
    }

    "email without @ returns failure" {
        validateEmail("userexample.com").shouldBeFailure()
    }
})

// Step 3: 테스트 실행 - 실패 확인
// $ ./gradlew test
// EmailValidatorTest > valid email returns success FAILED
//   kotlin.NotImplementedError: An operation is not implemented

// Step 4: 최소한의 코드 구현 (GREEN)
fun validateEmail(email: String): Result<String> {
    if (email.isBlank()) return Result.failure(IllegalArgumentException("Email cannot be blank"))
    if ('@' !in email) return Result.failure(IllegalArgumentException("Email must contain @"))
    val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
    if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Invalid email format"))
    return Result.success(email)
}

// Step 5: 테스트 실행 - 통과 확인
// $ ./gradlew test
// EmailValidatorTest > valid email returns success PASSED
// EmailValidatorTest > empty email returns failure PASSED
// EmailValidatorTest > email without @ returns failure PASSED

// Step 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 Matcher

핵심 Matcher

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 "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) // 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 with delay") {
        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()
}

Spy 및 부분 모킹

test("실제 객체에 대한 spy") {
    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
        }
    }
})

커스텀 생성기

import io.kotest.property.Arb
import io.kotest.property.arbitrary.*

// 사용자 생성기
val Arb.Companion.user: Arb<User>
    get() = Arb.bind(Arb.string(1..50), Arb.email(), Arb.bool()) { name, email, active ->
        User(id = UUID.randomUUID().toString(), name = name, email = email, isActive = active)
    }

// 이메일 생성기
fun Arb.Companion.email(): Arb<String> = arbitrary {
    val local = Arb.string(5..20).bind().lowercase()
    val domain = Arb.string(3..10).bind().lowercase()
    val tld = Arb.string(2..3).bind().lowercase()
    "$local@$domain.$tld"
}

class CustomGeneratorTest : FunSpec({
    test("커스텀 생성기로 사용자 검증") {
        checkAll(Arb.user) { user ->
            val validation = validateUserRegistration(user)
            if (user.isActive && user.email.contains("@")) {
                validation.shouldBeValid()
            } else {
                validation.shouldBeInvalid()
            }
        }
    }
})

Kover 커버리지

Gradle 설정

// build.gradle.kts
plugins {
    id("org.jetbrains.kotlin.jvm") version "1.9.0"
    id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0"
    id("org.jetbrains.kotlinx.kover") version "0.7.5"
}

kotlin {
    jvmToolchain(11)
}

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.10")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}

tasks.test {
    useJUnitPlatform()
}

Kover 명령어

# HTML 리포터로 커버리지 보고서 생성
./gradlew koverHtmlReport
# 보고서 경로: build/reports/kover/html/index.html

# XML 리포터 생성 (CI 도구용)
./gradlew koverXmlReport
# 보고서 경로: build/reports/kover/xml/report.xml

# 검증을 위한 커버리지 검사
./gradlew koverVerify

# 개별 소스 세트에 대한 커버리지 검사
./gradlew koverVerify --source-set-name main

Kover 커버리지 규칙 구성

// build.gradle.kts
kover {

    filters {
        // 리포트에서 클래스 제외
        classes {
            excludes += listOf(
                "com.example.dto.*",
                "com.example.model.*",
                "com.example.generated.*",
            )
        }
    }

    verify {
        rule {
            minBound(80) // 최소 라인 커버리지 80%
        }
    }
}

추가 리소스

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

accessibility

Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA. Use when building or auditing UI that must meet WCAG 2.2 Level AA, or when reviewing a change for keyboard, contrast, or screen-reader support.

affaan-m/claude-api

Anthropic Claude API patterns for Python and TypeScript. Covers Messages API, streaming, tool use, vision, extended thinking, batches, prompt caching, and Claude Agent SDK. Use when building applications with the Claude API or Anthropic SDKs.

affaan-m/everything-claude-code

End-to-end marketing campaign planning and execution. Covers audience research, positioning, campaign angle definition, landing page copy, email sequences, social posts, ad copy, short-form video scripts, and content calendars. Use as the orchestration layer for multi-channel product launches. Use when planning or executing a multi-channel product launch, or producing landing page, email, social, or ad copy.

affaan-m/everything-claude-code

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/everything-claude-code-conventions

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/frontend-design

Create distinctive, production-grade frontend interfaces with high design quality. Use when the user asks to build web components, pages, or applications and the visual direction matters as much as the code quality.

affaan-m/gget

gget CLI and Python workflow for quick genomic database queries, sequence lookup, BLAST-style searches, enrichment checks, and reproducible bioinformatics evidence logs.

affaan-m/literature-review

Systematic literature-review workflow for academic, biomedical, technical, and scientific topics, including search planning, source screening, synthesis, citation checks, and evidence logging.

affaan-m/motion-ui

Production-ready UI motion system for React/Next.js. Use when implementing animations, transitions, or motion patterns.

affaan-m/project-guidelines-example

Example project-specific skill template based on a real production application.

affaan-m/pubmed-database

Direct PubMed and NCBI E-utilities search workflows for biomedical literature, MeSH queries, PMID lookup, citation retrieval, and API-backed literature monitoring.

affaan-m/scholar-evaluation

Structured scholarly-work evaluation for papers, proposals, literature reviews, methods sections, evidence quality, citation support, and research-writing feedback.

affaan-m/uspto-database

USPTO patent and trademark data workflow for official record lookup, PatentSearch queries, TSDR checks, assignment data, and reproducible IP research logs.

agent-architecture-audit

Full-stack diagnostic for agent and LLM applications. Audits the 12-layer agent stack for wrapper regression, memory pollution, tool discipline failures, hidden repair loops, and rendering corruption. Produces severity-ranked findings with code-first fixes. Essential for developers building agent applications, autonomous loops, or any LLM-powered feature. Use when an agent or LLM feature misbehaves and the failing layer is unknown, or before shipping an agent stack.

agent-eval

Head-to-head comparison of coding agents (Claude Code, Aider, Codex, etc.) on custom tasks with pass rate, cost, time, and consistency metrics. Use when choosing between coding agents, or when a change to an agent setup needs measured pass rate, cost, and time rather than an impression.

agent-harness-construction

Design and optimize AI agent action spaces, tool definitions, and observation formatting for higher completion rates. Use when defining or revising an agent

agentic-engineering

Operate as an agentic engineer using eval-first execution, decomposition, and cost-aware model routing. Use when planning or executing engineering work that agents will carry out end to end.

agentic-os

Build persistent multi-agent operating systems on Claude Code. Covers kernel architecture, specialist agents, slash commands, file-based memory, scheduled automation, and state management without external databases. Use when building a persistent multi-agent system on Claude Code with its own memory, commands, and scheduling.

agent-introspection-debugging

Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry.

agent-payment-x402

Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet.

관련 스킬