PHpullh

KOTLIN · 심층 가이드

Kotlin 테스트 완전 정리

runTest의 가상 시간과 Turbine으로 코루틴·Flow를 결정적으로 검증하고, MockK로 Kotlin 특유의 final·확장 함수까지 다루는 14개 주제입니다.

주제 14개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

Kotlin 테스트가 자바와 갈리는 지점은 도구가 아니라 언어 특성입니다. 클래스가 기본 final이라 프록시 기반 목 라이브러리가 곧바로 막히고, 확장 함수와 최상위 함수, object 싱글턴은 인스턴스를 주입해 바꿔치기할 대상이 아예 없습니다. 그래서 Kotlin에서는 목을 잘 만드는 법보다 목이 필요 없게 경계를 자르는 법이 먼저입니다. 그래도 남는 자리를 MockK가 메웁니다.

JUnit5 + Kotest 기초로 실행 환경과 어서션 스타일을 정한 뒤 코루틴 테스트 (runTest)로 넘어가면, 지연이 있는 코드를 실제로 기다리지 않고 검증하는 방식이 잡힙니다. Flow는 값이 언제 몇 개 오는지가 검증 대상이라 Coroutine Testing (Turbine)이 훨씬 편합니다. 협력 객체를 끊어야 할 때 MockK를 사용한 모킹을 보고, 입력을 사람이 고르는 한계가 느껴지면 프로퍼티 기반 테스트로 넘어가는 흐름을 권합니다.

runTest의 가상 시간은 그 테스트가 제공하는 스케줄러를 쓰는 코루틴에만 적용됩니다. 프로덕션 코드가 Dispatchers.IODispatchers.Main을 직접 참조하면 그 부분은 실제 시간으로 돌아가, 테스트가 느려지거나 간헐적으로 실패합니다. 디스패처를 생성자로 주입하거나 Dispatchers.setMain으로 교체해 두는 것이 전제 조건입니다. 그리고 StateFlow는 값을 버퍼링하지 않으므로, 수집을 시작하기 전에 지나간 방출은 관측되지 않습니다.

01JUnit5 + Kotest 기초

Kotlin 친화적인 테스트 프레임워크로 읽기 쉬운 테스트를 작성합니다.

Kotlin code

// build.gradle.kts 의존성:
// testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
// testImplementation("io.kotest:kotest-assertions-core:5.8.0")

import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldThrow

class CalculatorTest : DescribeSpec({
    val calc = Calculator()

    describe("덧셈") {
        it("양수 + 양수") {
            calc.add(2, 3) shouldBe 5
        }
        it("음수 포함") {
            calc.add(-1, 1) shouldBe 0
        }
    }

    describe("나눗셈") {
        it("정상 나눗셈") {
            calc.divide(10, 2) shouldBe 5.0
        }
        it("0으로 나누면 예외") {
            shouldThrow<ArithmeticException> {
                calc.divide(10, 0)
            }
        }
    }
})

// JUnit5 스타일
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class CalcJUnit {
    @Test
    fun `두 수의 합`() {   // 백틱으로 한글 테스트명 가능
        assertEquals(5, Calculator().add(2, 3))
    }
}

class Calculator {
    fun add(a: Int, b: Int) = a + b
    fun divide(a: Int, b: Int): Double {
        if (b == 0) throw ArithmeticException("0으로 나눌 수 없음")
        return a.toDouble() / b
    }
}
알아두면 좋은 점

백틱(`) 함수명으로 한글 테스트 이름을 작성하면 테스트 리포트가 매우 읽기 쉬워집니다.

자주 하는 실수

Kotest의 shouldBeequals()로 비교합니다. 부동소수점 비교는 shouldBeWithinPercentageOfplusOrMinus를 사용하세요.

02Coroutine Testing (Turbine)

Flow 테스트를 위한 Turbine 라이브러리

Kotlin code

<span class="cm">// Coroutine Testing (Turbine) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Coroutine Testing (Turbine)") }
알아두면 좋은 점

KOTLIN 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

03코루틴 테스트 (runTest)

kotlinx-coroutines-testrunTest로 코루틴을 가상 시간에서 테스트합니다.

Kotlin code

import kotlinx.coroutines.*
import kotlinx.coroutines.test.*

class CacheService {
    private val cache = mutableMapOf<String, String>()

    suspend fun getOrFetch(key: String): String {
        cache[key]?.let { return it }
        delay(1000) // 네트워크 지연
        val value = "data-$key"
        cache[key] = value
        return value
    }

    fun cacheSize() = cache.size
}

// 테스트
fun main() = runTest {
    val service = CacheService()

    // 첫 호출: 캐시 미스
    val result1 = service.getOrFetch("key1")
    assert(result1 == "data-key1") { "첫 호출 실패" }
    assert(service.cacheSize() == 1) { "캐시 크기 불일치" }

    // 두 번째 호출: 캐시 히트 (delay 없음)
    val result2 = service.getOrFetch("key1")
    assert(result2 == "data-key1") { "캐시 히트 실패" }

    // 가상 시간 확인
    println("테스트 통과! 가상 시간: ${currentTime}ms")
    // delay(1000)이 즉시 실행됨
}
알아두면 좋은 점

runTestdelay를 자동으로 건너뛰어 테스트가 빠르게 완료됩니다. advanceTimeBy로 정밀하게 시간을 제어할 수도 있습니다.

자주 하는 실수

runBlocking으로 코루틴을 테스트하면 실제 delay만큼 대기합니다. 항상 runTest를 사용하세요.

04MockK를 사용한 모킹

MockK 라이브러리로 Kotlin 친화적인 목 객체를 생성합니다. 코루틴, 확장 함수, object 모킹을 지원합니다.

Kotlin code

// MockK 개념 시뮬레이션 (실제: io.mockk:mockk)
// 실제 코드 예시:

interface UserRepository {
    suspend fun findById(id: String): String?
    suspend fun save(name: String): String
}

class UserService(private val repo: UserRepository) {
    suspend fun getOrCreate(id: String, defaultName: String): String {
        return repo.findById(id) ?: repo.save(defaultName)
    }
}

// 수동 목 (MockK 없이)
class MockUserRepository : UserRepository {
    val findCalls = mutableListOf<String>()
    val saveCalls = mutableListOf<String>()
    var findResult: String? = null
    var saveResult: String = ""

    override suspend fun findById(id: String): String? {
        findCalls.add(id)
        return findResult
    }
    override suspend fun save(name: String): String {
        saveCalls.add(name)
        return saveResult
    }
}

fun main() = kotlinx.coroutines.runBlocking {
    // 테스트: 사용자 없으면 생성
    val mock = MockUserRepository().apply {
        findResult = null
        saveResult = "new-user"
    }
    val service = UserService(mock)
    val result = service.getOrCreate("id-1", "기본이름")

    assert(result == "new-user") { "생성 실패" }
    assert(mock.findCalls == listOf("id-1")) { "find 호출 검증" }
    assert(mock.saveCalls == listOf("기본이름")) { "save 호출 검증" }
    println("모든 테스트 통과!")
}
알아두면 좋은 점

MockK의 coEvery { }로 suspend 함수를 모킹하고, coVerify { }로 호출을 검증합니다.

자주 하는 실수

모든 것을 모킹하면 테스트가 구현 세부사항에 결합됩니다. 핵심 의존성만 모킹하고, 값 객체는 실제 인스턴스를 사용하세요.

05통합 테스트 (Integration Test)

여러 컴포넌트를 조합하여 시스템 동작을 검증하는 통합 테스트 패턴입니다.

Kotlin code

// 시스템 구성요소
class Database {
    private val store = mutableMapOf<String, String>()
    fun put(key: String, value: String) { store[key] = value }
    fun get(key: String): String? = store[key]
    fun clear() = store.clear()
}

class Cache(private val db: Database) {
    private val mem = mutableMapOf<String, String>()
    fun get(key: String): String? = mem[key] ?: db.get(key)?.also { mem[key] = it }
    fun put(key: String, value: String) { mem[key] = value; db.put(key, value) }
    fun invalidate(key: String) { mem.remove(key) }
}

class ApiService(private val cache: Cache) {
    fun getData(key: String): String = cache.get(key) ?: "NOT_FOUND"
    fun setData(key: String, value: String) = cache.put(key, value)
}

// 통합 테스트
fun integrationTest() {
    val db = Database()
    val cache = Cache(db)
    val api = ApiService(cache)

    // 테스트 1: 데이터 저장 및 조회
    api.setData("user:1", "김철수")
    assert(api.getData("user:1") == "김철수") { "저장/조회 실패" }

    // 테스트 2: 캐시 무효화 후 DB에서 조회
    cache.invalidate("user:1")
    assert(api.getData("user:1") == "김철수") { "DB 폴백 실패" }

    // 테스트 3: 존재하지 않는 키
    assert(api.getData("none") == "NOT_FOUND") { "미존재 키 처리 실패" }

    println("통합 테스트 모두 통과!")
}

fun main() {
    integrationTest()
}
알아두면 좋은 점

통합 테스트는 각 테스트 전에 상태를 초기화(setUp)하고 테스트 후 정리(tearDown)하여 테스트 간 격리를 보장하세요.

자주 하는 실수

통합 테스트에서 외부 서비스(API, DB)에 직접 의존하면 느리고 불안정합니다. 테스트용 인메모리 구현을 사용하세요.

06프로퍼티 기반 테스트 (Property-Based)

무작위 입력으로 속성(불변식)을 검증하는 프로퍼티 기반 테스트입니다. 엣지 케이스를 자동으로 발견합니다.

Kotlin code

import kotlin.random.Random

// 프로퍼티 테스트 프레임워크 (간략 구현)
fun <T> forAll(
    iterations: Int = 100,
    generator: () -> T,
    property: (T) -> Boolean
) {
    repeat(iterations) { i ->
        val input = generator()
        if (!property(input)) {
            throw AssertionError("반례 발견! 입력: $input (반복 #$i)")
        }
    }
}

// 테스트 대상
fun reverse(list: List<Int>): List<Int> = list.reversed()
fun sort(list: List<Int>): List<Int> = list.sorted()

fun main() {
    // 속성 1: 두 번 reverse하면 원본
    forAll(
        generator = { List(Random.nextInt(0, 20)) { Random.nextInt(-100, 100) } },
        property = { list -> reverse(reverse(list)) == list }
    )
    println("✓ reverse(reverse(x)) == x")

    // 속성 2: 정렬 후 크기 동일
    forAll(
        generator = { List(Random.nextInt(0, 50)) { Random.nextInt() } },
        property = { list -> sort(list).size == list.size }
    )
    println("✓ sort(x).size == x.size")

    // 속성 3: 정렬된 리스트는 순서 유지
    forAll(
        generator = { List(Random.nextInt(1, 30)) { Random.nextInt(-50, 50) } },
        property = { list ->
            val sorted = sort(list)
            sorted.zipWithNext().all { (a, b) -> a <= b }
        }
    )
    println("✓ sort(x)는 오름차순")

    println("모든 프로퍼티 테스트 통과!")
}
알아두면 좋은 점

Kotest의 property testing 모듈이나 jqwik을 사용하면 shrinking(최소 반례 찾기) 기능도 제공됩니다.

자주 하는 실수

프로퍼티 테스트의 generator가 엣지 케이스(빈 리스트, 극값 등)를 포함하도록 설계하세요. 균일 분포만으로는 부족합니다.

07BDD 스타일 테스트 (Given-When-Then)

Given-When-Then 형식으로 테스트를 구조화하여 비즈니스 요구사항을 명확히 표현합니다.

Kotlin code

// BDD 스타일 DSL
class BddTest(val name: String) {
    private var givenBlock: (() -> Unit)? = null
    private var whenBlock: (() -> Unit)? = null
    private var thenBlock: (() -> Unit)? = null

    fun given(desc: String, block: () -> Unit) { println("  Given: $desc"); givenBlock = block }
    fun whenever(desc: String, block: () -> Unit) { println("  When: $desc"); whenBlock = block }
    fun then(desc: String, block: () -> Unit) { println("  Then: $desc"); thenBlock = block }

    fun run() {
        println("시나리오: $name")
        givenBlock?.invoke()
        whenBlock?.invoke()
        thenBlock?.invoke()
        println("  ✓ 통과
")
    }
}

fun scenario(name: String, block: BddTest.() -> Unit) {
    BddTest(name).apply(block).run()
}

class ShoppingCart {
    private val items = mutableListOf<Pair<String, Int>>()
    fun add(name: String, price: Int) { items.add(name to price) }
    fun total() = items.sumOf { it.second }
    fun itemCount() = items.size
    fun clear() = items.clear()
}

fun main() {
    scenario("장바구니에 상품 추가") {
        val cart = ShoppingCart()
        given("빈 장바구니가 있다") { cart.clear() }
        whenever("상품 2개를 추가한다") {
            cart.add("노트북", 1500000)
            cart.add("마우스", 30000)
        }
        then("총액은 1,530,000원이다") {
            assert(cart.total() == 1530000) { "총액 불일치: ${cart.total()}" }
            assert(cart.itemCount() == 2) { "개수 불일치" }
        }
    }

    scenario("빈 장바구니 총액") {
        val cart = ShoppingCart()
        given("빈 장바구니가 있다") { cart.clear() }
        whenever("아무것도 추가하지 않는다") {}
        then("총액은 0원이다") {
            assert(cart.total() == 0) { "빈 장바구니 총액이 0이 아님" }
        }
    }
}
알아두면 좋은 점

Kotest의 BehaviorSpec이나 Spek을 사용하면 더 강력한 BDD 테스트 프레임워크를 활용할 수 있습니다.

자주 하는 실수

Given-When-Then의 경계를 모호하게 작성하면 테스트 의도가 불명확해집니다. Given은 전제조건, When은 동작, Then은 검증으로 명확히 구분하세요.

08테스트 더블 (Stub, Mock, Spy)

테스트 더블의 종류와 적절한 사용 시점을 이해합니다. Stub은 값 반환, Mock은 호출 검증, Spy는 부분 모킹입니다.

Kotlin code

interface NotificationService {
    fun send(to: String, message: String): Boolean
    fun getHistory(): List<String>
}

// Stub: 미리 정의된 값 반환
class StubNotification : NotificationService {
    override fun send(to: String, message: String) = true
    override fun getHistory() = listOf("msg1", "msg2")
}

// Mock: 호출 검증
class MockNotification : NotificationService {
    val sendCalls = mutableListOf<Pair<String, String>>()
    var sendResult = true

    override fun send(to: String, message: String): Boolean {
        sendCalls.add(to to message)
        return sendResult
    }
    override fun getHistory() = emptyList<String>()

    fun verifySendCalledWith(to: String, message: String) {
        assert(sendCalls.any { it == to to message }) {
            "send($to, $message) 호출되지 않음. 실제: $sendCalls"
        }
    }
    fun verifySendCallCount(count: Int) {
        assert(sendCalls.size == count) {
            "send 호출 횟수: ${sendCalls.size}, 기대: $count"
        }
    }
}

fun main() {
    // Stub 사용: 반환값만 중요
    val stub = StubNotification()
    assert(stub.send("user", "hello"))
    println("✓ Stub 테스트 통과")

    // Mock 사용: 호출 검증
    val mock = MockNotification()
    mock.send("admin@test.com", "알림")
    mock.send("user@test.com", "환영")
    mock.verifySendCalledWith("admin@test.com", "알림")
    mock.verifySendCallCount(2)
    println("✓ Mock 테스트 통과")
}
알아두면 좋은 점

상태 검증에는 Stub, 행위 검증에는 Mock을 사용하세요. Mock을 남용하면 테스트가 구현에 결합됩니다.

자주 하는 실수

모든 의존성을 Mock으로 만들면 테스트가 깨지기 쉽습니다. 인터페이스 경계에서만 Mock을 사용하고, 값 객체는 실제 인스턴스를 쓰세요.

09테스트 픽스처 (Test Fixture)

테스트에 필요한 데이터와 환경을 체계적으로 설정하는 픽스처 패턴입니다.

Kotlin code

data class User(val id: String, val name: String, val email: String, val role: String = "USER")
data class Order(val id: String, val userId: String, val items: List<String>, val total: Int)

// 테스트 픽스처 빌더
object TestFixtures {
    fun user(
        id: String = "user-${(1000..9999).random()}",
        name: String = "테스트유저",
        email: String = "$id@test.com",
        role: String = "USER"
    ) = User(id, name, email, role)

    fun admin(name: String = "관리자") = user(name = name, role = "ADMIN")

    fun order(
        userId: String = "user-001",
        items: List<String> = listOf("상품A"),
        total: Int = items.size * 10000
    ) = Order("order-${(1000..9999).random()}", userId, items, total)

    // 대량 데이터 생성
    fun users(count: Int) = List(count) { user(id = "user-${it + 1}", name = "유저${it + 1}") }
}

fun main() {
    // 기본 픽스처
    val user = TestFixtures.user()
    println("기본 유저: $user")

    // 커스텀 픽스처
    val admin = TestFixtures.admin("슈퍼관리자")
    println("관리자: $admin")

    // 연관 데이터
    val testUser = TestFixtures.user(id = "u-001", name = "김철수")
    val testOrder = TestFixtures.order(userId = testUser.id, items = listOf("노트북", "마우스"))
    println("주문: $testOrder")

    // 대량 데이터
    val users = TestFixtures.users(5)
    users.forEach { println("  $it") }
}
알아두면 좋은 점

픽스처 함수에 기본값을 제공하면 테스트마다 필요한 필드만 지정하면 됩니다. 테스트 의도가 명확해집니다.

자주 하는 실수

픽스처 간 상태를 공유하면 테스트 순서에 의존하게 됩니다. 각 테스트마다 새 픽스처를 생성하세요.

10파라미터화 테스트 (Parameterized Test)

동일한 테스트 로직을 다양한 입력 데이터로 반복 실행합니다. 엣지 케이스를 체계적으로 검증합니다.

Kotlin code

// 테스트 대상
fun fizzBuzz(n: Int): String = when {
    n % 15 == 0 -> "FizzBuzz"
    n % 3 == 0 -> "Fizz"
    n % 5 == 0 -> "Buzz"
    else -> n.toString()
}

// 파라미터화 테스트 유틸
data class TestCase<I, O>(val input: I, val expected: O, val desc: String = "")

fun <I, O> parameterizedTest(
    name: String,
    cases: List<TestCase<I, O>>,
    test: (I) -> O
) {
    println("=== $name ===")
    var passed = 0; var failed = 0
    cases.forEach { (input, expected, desc) ->
        val actual = test(input)
        if (actual == expected) {
            passed++
        } else {
            failed++
            println("  ✗ $desc: 입력=$input, 기대=$expected, 실제=$actual")
        }
    }
    println("  결과: $passed 통과, $failed 실패
")
}

fun main() {
    parameterizedTest("FizzBuzz", listOf(
        TestCase(1, "1", "일반 숫자"),
        TestCase(3, "Fizz", "3의 배수"),
        TestCase(5, "Buzz", "5의 배수"),
        TestCase(15, "FizzBuzz", "15의 배수"),
        TestCase(30, "FizzBuzz", "30"),
        TestCase(7, "7", "소수"),
    )) { fizzBuzz(it) }

    // 문자열 변환 테스트
    parameterizedTest("trim + uppercase", listOf(
        TestCase("  hello  ", "HELLO"),
        TestCase("kotlin", "KOTLIN"),
        TestCase("  ", ""),
        TestCase("ABC", "ABC"),
    )) { it.trim().uppercase() }
}
알아두면 좋은 점

JUnit5의 @ParameterizedTest@CsvSource를 사용하면 IDE에서 각 케이스를 개별 테스트로 표시합니다.

자주 하는 실수

파라미터화 테스트에 너무 많은 케이스를 넣으면 실패 원인 추적이 어렵습니다. 관련 있는 케이스끼리 그룹화하세요.

11성능 테스트 (Performance Test)

코드의 실행 시간을 측정하고 기준치와 비교합니다. 성능 회귀를 감지하는 간단한 벤치마크 테스트입니다.

Kotlin code

import kotlin.system.measureNanoTime
import kotlin.system.measureTimeMillis

data class BenchResult(val name: String, val avgNs: Long, val minNs: Long, val maxNs: Long)

fun benchmark(
    name: String,
    warmup: Int = 100,
    iterations: Int = 1000,
    block: () -> Unit
): BenchResult {
    // 워밍업
    repeat(warmup) { block() }

    // 측정
    val times = LongArray(iterations) { measureNanoTime(block) }
    return BenchResult(
        name = name,
        avgNs = times.average().toLong(),
        minNs = times.min(),
        maxNs = times.max()
    )
}

fun main() {
    val list = (1..10000).toList()

    val r1 = benchmark("List.filter+map") {
        list.filter { it % 2 == 0 }.map { it * 2 }
    }
    val r2 = benchmark("Sequence.filter+map") {
        list.asSequence().filter { it % 2 == 0 }.map { it * 2 }.toList()
    }
    val r3 = benchmark("forEach 수동") {
        val result = mutableListOf<Int>()
        for (i in list) { if (i % 2 == 0) result.add(i * 2) }
    }

    listOf(r1, r2, r3).forEach { r ->
        println("${r.name}: avg=${r.avgNs/1000}μs, min=${r.minNs/1000}μs, max=${r.maxNs/1000}μs")
    }

    // 성능 기준 검증
    assert(r1.avgNs < 10_000_000) { "List 처리가 너무 느림: ${r1.avgNs}ns" }
    println("
성능 테스트 통과!")
}
알아두면 좋은 점

워밍업(warmup) 단계에서 JIT 컴파일이 완료되도록 합니다. 워밍업 없이 측정하면 초기 실행이 느려 결과가 왜곡됩니다.

자주 하는 실수

마이크로벤치마크는 JVM 최적화에 크게 영향받습니다. 정밀한 벤치마크에는 JMH(Java Microbenchmark Harness)를 사용하세요.

12뮤테이션 테스트 (Mutation Testing)

코드를 의도적으로 변형하여 테스트가 실패하는지 검증합니다. 테스트의 품질을 측정하는 기법입니다.

Kotlin code

// 테스트 대상
fun isLeapYear(year: Int): Boolean =
    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)

// 뮤턴트 생성 (코드 변형)
fun isLeapYearMutant1(year: Int): Boolean =
    (year % 4 == 0 && year % 100 != 0) || (year % 400 != 0) // != → ==

fun isLeapYearMutant2(year: Int): Boolean =
    (year % 4 == 0 || year % 100 != 0) || (year % 400 == 0)  // && → ||

fun isLeapYearMutant3(year: Int): Boolean =
    (year % 4 != 0 && year % 100 != 0) || (year % 400 == 0) // == → !=

// 테스트 스위트
val testCases = listOf(
    2000 to true, 1900 to false, 2024 to true,
    2023 to false, 1600 to true, 100 to false
)

fun runTests(name: String, impl: (Int) -> Boolean): Boolean {
    return testCases.all { (year, expected) ->
        val result = impl(year) == expected
        if (!result) println("  뮤턴트 $name 탐지: year=$year")
        result
    }
}

fun main() {
    println("원본 테스트: ${if (runTests("원본", ::isLeapYear)) "통과" else "실패"}")
    println()

    val mutants = mapOf(
        "Mutant1(!=→==)" to ::isLeapYearMutant1,
        "Mutant2(&&→||)" to ::isLeapYearMutant2,
        "Mutant3(==→!=)" to ::isLeapYearMutant3,
    )

    var killed = 0
    mutants.forEach { (name, impl) ->
        val survived = runTests(name, impl)
        if (!survived) { killed++; println("  ✓ $name 제거됨") }
        else println("  ✗ $name 생존! (테스트 부족)")
    }
    println("
뮤테이션 점수: $killed/${mutants.size} (${killed * 100 / mutants.size}%)")
}
알아두면 좋은 점

PIT(pitest) 라이브러리를 사용하면 자동으로 뮤턴트를 생성하고 테스트 스위트의 품질을 측정합니다.

자주 하는 실수

뮤테이션 테스트에서 생존한 뮤턴트는 테스트가 부족하다는 의미입니다. 해당 분기를 검증하는 테스트를 추가하세요.

13코드 커버리지 (Code Coverage)

테스트가 코드를 얼마나 실행하는지 측정합니다. 라인, 분기, 조건 커버리지의 차이를 이해합니다.

Kotlin code

// 테스트 대상
fun classify(score: Int): String = when {
    score < 0 || score > 100 -> throw IllegalArgumentException("점수 범위 초과")
    score >= 90 -> "A"
    score >= 80 -> "B"
    score >= 70 -> "C"
    score >= 60 -> "D"
    else -> "F"
}

// 커버리지 측정 시뮬레이션
class CoverageTracker {
    private val branches = mutableMapOf<String, Boolean>()

    fun track(branch: String, hit: Boolean) {
        branches[branch] = branches.getOrDefault(branch, false) || hit
    }

    fun report() {
        val total = branches.size
        val covered = branches.count { it.value }
        println("분기 커버리지: $covered/$total (${covered * 100 / total}%)")
        branches.filter { !it.value }.forEach {
            println("  미실행: ${it.key}")
        }
    }
}

fun main() {
    val tracker = CoverageTracker()
    val testInputs = listOf(95, 85, 75, 65, 50)
    // 누락: 범위 초과 케이스

    val allBranches = listOf("범위초과", "A등급", "B등급", "C등급", "D등급", "F등급")
    allBranches.forEach { tracker.track(it, false) }

    testInputs.forEach { score ->
        val grade = classify(score)
        tracker.track("${grade}등급", true)
        println("점수 $score → $grade")
    }

    println("
=== 커버리지 리포트 ===")
    tracker.report()
    println("
→ '범위초과' 분기 테스트가 필요합니다!")
}
알아두면 좋은 점

JaCoCo를 Gradle에 추가하면 자동으로 커버리지를 측정하고 리포트를 생성합니다. jacocoTestReport 태스크를 사용하세요.

자주 하는 실수

100% 라인 커버리지가 모든 버그를 잡는 것은 아닙니다. 분기 커버리지와 뮤테이션 테스트를 함께 활용하여 테스트 품질을 높이세요.

14스텁/목/스파이 비교

테스트 더블의 세 가지 유형을 비교합니다. 각각의 사용 목적과 적합한 상황을 실습합니다.

Kotlin code

interface PaymentGateway {
    fun charge(amount: Int): Boolean
    fun refund(amount: Int): Boolean
    fun getBalance(): Int
}

// Stub: 고정된 결과 반환 (상태 검증용)
class StubGateway(private val chargeResult: Boolean = true) : PaymentGateway {
    override fun charge(amount: Int) = chargeResult
    override fun refund(amount: Int) = true
    override fun getBalance() = 100000
}

// Mock: 호출 기록 + 검증 (행위 검증용)
class MockGateway : PaymentGateway {
    val charges = mutableListOf<Int>()
    val refunds = mutableListOf<Int>()
    override fun charge(amount: Int): Boolean { charges.add(amount); return true }
    override fun refund(amount: Int): Boolean { refunds.add(amount); return true }
    override fun getBalance() = 0
}

// Spy: 실제 동작 + 기록 (부분 모킹)
class SpyGateway(private val real: PaymentGateway) : PaymentGateway {
    val callLog = mutableListOf<String>()
    override fun charge(amount: Int): Boolean {
        callLog.add("charge($amount)")
        return real.charge(amount) // 실제 호출
    }
    override fun refund(amount: Int): Boolean {
        callLog.add("refund($amount)")
        return real.refund(amount)
    }
    override fun getBalance(): Int { callLog.add("getBalance()"); return real.getBalance() }
}

fun main() {
    // Stub: "결제 성공 시 주문이 생성되는가?"
    val stub = StubGateway(chargeResult = true)
    assert(stub.charge(5000)) { "결제 실패" }
    println("✓ Stub: 결제 성공 시나리오")

    // Mock: "정확한 금액으로 결제가 호출되었는가?"
    val mock = MockGateway()
    mock.charge(15000)
    assert(mock.charges == listOf(15000)) { "호출 검증 실패" }
    println("✓ Mock: 결제 금액 검증")

    // Spy: "실제 동작 + 호출 기록"
    val spy = SpyGateway(StubGateway())
    spy.charge(3000)
    spy.getBalance()
    println("✓ Spy 호출 로그: ${spy.callLog}")
}
알아두면 좋은 점

Stub은 "무엇을 반환하는가", Mock은 "무엇이 호출되었는가", Spy는 "실제 동작 + 기록"으로 구분하세요.

자주 하는 실수

Mock과 Stub을 혼용하면 테스트 의도가 불명확해집니다. 하나의 테스트에서는 하나의 더블 유형에 집중하세요.

정리하며

  • final 기본과 확장 함수 때문에 목보다 경계 설계가 먼저입니다
  • 디스패처를 주입하지 않으면 runTest의 가상 시간이 적용되지 않습니다
  • Flow의 방출 순서와 개수 검증은 Turbine의 awaitItem 계열이 가장 짧습니다
  • StateFlow는 최신 값만 유지하므로 수집 시작 전 방출은 검증할 수 없습니다

더 깊이 들어가고 싶다면 Kotlin 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.