Kotlin: JUnit 5とMockKによるKotlinテスト

最終更新:2026-08-26

Bobの信条は「テストされていないコードは、検証されていない仮定にすぎない」です。JUnit 5とMockKは、Kotlinに最適なテストの組み合わせです。MockKはKotlin向けに設計されてコルーチンのモックに対応し、JUnit 5は最新のテスト基盤を提供します。

1. 学習内容


2. QAリーダーの実体験

(1) 課題:MockitoとKotlinの非互換性

Bobは、Mockitoを使ってKotlinクラスをモック化する際、頻繁に「Finalクラス」のエラーに遭遇しました(Kotlinクラスはデフォルトでfinalです)。そのため、mock-maker-inlineの拡張設定が必要となりました。また、suspendの関数は、モック化することがまったく不可能でした。

(2) MockKによる解決策

KOTLIN
// Mockito: problems with final classes and suspend functions
when(repo.findById("ORD-001")).thenReturn(order)  // Fails on final class!

// MockK: Kotlin native, supports everything
every { repo.findById("ORD-001") } returns order  // Works on any class!
coEvery { repo.fetchOrderAsync("ORD-001") } returns order  // suspend functions!

MockKはKotlin向けにネイティブに設計されており、追加の設定は不要で、finalクラス、拡張関数、およびコルーチンを完全にサポートしています。


3. JUnit 5 の基礎

(1) 依存関係の設定

KOTLIN
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.1")
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}

tasks.test {
    useJUnitPlatform()
}

(2) 基本テスト

KOTLIN
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class OrderTest {
    @Test
    fun `should create order with correct id`() {
        val order = Order("ORD-001", 299.99, "PENDING")
        assertEquals("ORD-001", order.id)
        assertEquals(299.99, order.total)
        assertEquals("PENDING", order.status)
    }

    @Test
    fun `should throw on negative total`() {
        assertThrows(IllegalArgumentException::class.java) {
            Order("ORD-001", -50.0, "PENDING")
        }
    }
}

(3) パラメータ化されたテスト

KOTLIN
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource

class OrderPriorityTest {
    @ParameterizedTest
    @CsvSource(
        "100.0, LOW",
        "1500.0, MEDIUM",
        "15000.0, HIGH"
    )
    fun `should determine priority based on total`(total: Double, expected: String) {
        val priority = when {
            total > 10_000 -> "HIGH"
            total > 1_000 -> "MEDIUM"
            else -> "LOW"
        }
        assertEquals(expected, priority)
    }
}

(4) ネストされたテスト

KOTLIN
class OrderProcessorTest {
    @Nested
    inner class OrderCreation {
        @Test
        fun `should create pending order`() { ... }

        @Test
        fun `should reject negative total`() { ... }
    }

    @Nested
    inner class OrderProcessing {
        @Test
        fun `should confirm pending order`() { ... }

        @Test
        fun `should reject confirming shipped order`() { ... }
    }
}

4. MockK の基礎

(1) モックの作成とスタブ化

KOTLIN
import io.mockk.*

class OrderServiceTest {
    // Create mock
    val repo = mockk<OrderRepository>()
    val service = OrderService(repo)

    @Test
    fun `should find order by id`() {
        // Stub: define behavior
        every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING")
        every { repo.findById(any()) } returns null

        // Act
        val order = service.findOrder("ORD-001")

        // Assert
        assertEquals(299.99, order?.total)

        // Verify: check interaction
        verify(exactly = 1) { repo.findById("ORD-001") }
    }
}

(2) MockK 共通 API

API 目的
every { } returns スタブの戻り値 every { repo.findById(any()) } returns order
every { } throws スタブ例外 every { repo.save(any()) } throws SQLException()
every { } answers 動的計算 every { repo.findById(any()) } answers { ... }
verify { } 通話が行われたことを確認 verify { repo.save(order) }
verifySequence { } 呼び出し順序の確認 verifySequence { f1(); f2() }
confirmVerified(repo) 未確認の着信を許可しない confirmVerified(repo)
coEvery { } returns コルーチンのスタブ coEvery { repo.fetchAsync(any()) } returns order
coVerify { } コルーチンの検証 coVerify { repo.fetchAsync(id) }

(3) MockK 対 Mockito

ディメンション Mockito MockK
Kotlinのfinalクラス 追加の設定が必要 ネイティブサポート
コルーチンのモック 未対応 coEvery / coVerify
拡張関数のモック 未対応 mockkStatic
オブジェクトのモック化 難易度高 mockkObject
構文 Java スタイル Kotlin DSL スタイル

5. コルーチンのテスト

(1) runTest

KOTLIN
import kotlinx.coroutines.test.runTest

class OrderServiceTest {
    val repo = mockk<OrderRepository>()
    val service = OrderService(repo)

    @Test
    fun `should fetch order asynchronously`() = runTest {
        // Given
        coEvery { repo.fetchOrder("ORD-001") } returns Order("ORD-001", 299.99, "PENDING")

        // When
        val order = service.fetchOrderAsync("ORD-001")

        // Then
        assertEquals(299.99, order.total)
        coVerify { repo.fetchOrder("ORD-001") }
    }
}

(2) コルーチンのテストツール

ツール 用途
runTest コルーチンテストのエントリポイント(runBlockingに代わるもの)
coEvery スタブコルーチン関数
coVerify コルーチン関数の呼び出しを確認
TestDispatcher コルーチンディスパッチの制御(仮想時間)
advanceUntilIdle() 保留中のすべてのコルーチンを先に実行

6. テストライフサイクル

KOTLIN
class OrderServiceTest {
    private lateinit var repo: OrderRepository
    private lateinit var service: OrderService

    @BeforeEach
    fun setup() {
        repo = mockk()
        service = OrderService(repo)
    }

    @AfterEach
    fun cleanup() {
        unmockkAll()  // Clear all mocks
    }

    @Test
    fun `should process order`() { ... }
}

(1) ライフサイクル注釈

注釈 実行タイミング 目的
@BeforeEach 各テストの実行前 モックおよびテスト対象オブジェクトの初期化
@AfterEach 各テスト終了後 リソースのクリーンアップ
@BeforeAll すべてのテストの前に(コンパニオンオブジェクトが必要) コストのかかる1回限りの初期化
@AfterAll すべてのテスト終了後 グローバルクリーンアップ

7. テストピラミッドとフロー

100%
flowchart TD
    A[Unit Tests<br/>MockK + JUnit5<br/>Fast, isolated] --> B[Integration Tests<br/>Spring Boot Test<br/>Real DB/HTTP]
    B --> C[E2E Tests<br/>TestContainers<br/>Full stack]
    A --> D[runTest for coroutines]
    A --> E[MockK for dependencies]
    D --> F[Virtual time control]

8. 完全な例:OrderProcessorTest

▶ サンプル:OrderProcessorのテスト

KOTLIN
// ============================================
// OrderProcessor - Test Suite
// Feature: Full test coverage with JUnit5 + MockK
// ============================================

import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
import io.mockk.*
import kotlinx.coroutines.test.runTest

// Domain classes
data class Order(val id: String, val total: Double, var status: String, val customerId: String)

interface OrderRepository {
    fun findById(id: String): Order?
    fun save(order: Order)
    suspend fun fetchOrderAsync(id: String): Order
}

class OrderService(private val repo: OrderRepository) {
    fun findOrder(id: String): Order = repo.findById(id) ?: throw NoSuchElementException("Order $id not found")

    fun processOrder(id: String): Order {
        val order = findOrder(id)
        if (order.status != "PENDING") throw IllegalStateException("Order $id is not pending")
        order.status = "CONFIRMED"
        repo.save(order)
        return order
    }

    suspend fun fetchOrderAsync(id: String): Order = repo.fetchOrderAsync(id)

    fun calculatePriority(order: Order): String = when {
        order.total > 10_000 -> "HIGH"
        order.total > 1_000 -> "MEDIUM"
        else -> "LOW"
    }
}

class OrderServiceTest {
    private lateinit var repo: OrderRepository
    private lateinit var service: OrderService

    @BeforeEach
    fun setup() {
        repo = mockk(relaxed = true)  // Relaxed: returns default values for unstubbed methods
        service = OrderService(repo)
    }

    @AfterEach
    fun cleanup() {
        unmockkAll()
    }

    @Nested
    @DisplayName("Order Retrieval")
    inner class OrderRetrieval {
        @Test
        fun `should find existing order`() {
            // Given
            every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")

            // When
            val order = service.findOrder("ORD-001")

            // Then
            assertEquals("ORD-001", order.id)
            assertEquals(299.99, order.total)
        }

        @Test
        fun `should throw when order not found`() {
            every { repo.findById(any()) } returns null
            assertThrows(NoSuchElementException::class.java) {
                service.findOrder("ORD-999")
            }
        }
    }

    @Nested
    @DisplayName("Order Processing")
    inner class OrderProcessing {
        @Test
        fun `should confirm pending order`() {
            every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")
            every { repo.save(any()) } just Runs

            val processed = service.processOrder("ORD-001")

            assertEquals("CONFIRMED", processed.status)
            verify { repo.save(match { it.status == "CONFIRMED" }) }
        }

        @Test
        fun `should reject non-pending order`() {
            every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "SHIPPED", "CUST-001")

            assertThrows(IllegalStateException::class.java) {
                service.processOrder("ORD-001")
            }
            verify(exactly = 0) { repo.save(any()) }
        }
    }

    @Nested
    @DisplayName("Priority Calculation")
    inner class PriorityCalculation {
        @Test
        fun `should assign HIGH priority for orders over 10000`() {
            val order = Order("ORD-001", 15_000.00, "PENDING", "CUST-001")
            assertEquals("HIGH", service.calculatePriority(order))
        }

        @Test
        fun `should assign MEDIUM priority for orders between 1000 and 10000`() {
            val order = Order("ORD-001", 2_500.00, "PENDING", "CUST-001")
            assertEquals("MEDIUM", service.calculatePriority(order))
        }

        @Test
        fun `should assign LOW priority for orders under 1000`() {
            val order = Order("ORD-001", 299.99, "PENDING", "CUST-001")
            assertEquals("LOW", service.calculatePriority(order))
        }
    }

    @Nested
    @DisplayName("Async Operations")
    inner class AsyncOperations {
        @Test
        fun `should fetch order asynchronously`() = runTest {
            coEvery { repo.fetchOrderAsync("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")

            val order = service.fetchOrderAsync("ORD-001")

            assertEquals("ORD-001", order.id)
            coVerify { repo.fetchOrderAsync("ORD-001") }
        }
    }
}

出力:すべてのテストに合格しました ✅


❓ よくある質問

Q MockKの「relaxed mock」と通常のモックの違いは何ですか?
A 「relaxed mock」(mockk(relaxed = true))は、スタブが設定されていないメソッドに対してデフォルト値(0/null/false)を返します。一方、通常のモックは、スタブが設定されていないメソッドに対してMockKExceptionをスローします。 設定されていない挙動が隠れてしまうのを避けるため、通常のモックを使用することをお勧めします。
Q runTest と runBlocking の違いは何ですか?
A runTest は仮想時間を使用するため(delay をスキップするため)、テストは数ミリ秒で完了します。 runBlockingは実時間を用い、実際の遅延時間が経過するまで待機します。コルーチンテストではrunTestを使用する必要があります。
Q コンパニオンオブジェクトをどのようにモック化すればよいですか?
A mockkObject(Order) を使用してコンパニオンオブジェクト全体をモック化し、次に every { ... } returns を実行します。テスト終了後は unmockkObject(Order) で元に戻してください。
Q 拡張関数をどのようにモックすればよいですか?
A 特定の拡張関数をモックするには mockkStatic("package.ClassNameKt") を使用します。ただし、できるだけ使用は避けてください。多くの場合、拡張関数はテストしやすくなるよう、通常の関数にリファクタリングすることができます。
Q テストカバレッジはどの程度の割合があれば十分ですか?
A 80%の行カバレッジを目標としますが、カバレッジが高いからといって品質が高いとは限りません。重要なビジネスロジック(決済、在庫、ステートマシンなど)については100%のカバレッジを確保すべきですが、単純なデータクラスについてはテストを行わなくても構いません。
Q 統合テストと単体テストはどう区別すればよいですか?
A 単体テストではすべての依存関係をモック化します(単一のクラスをテストします)。一方、統合テストでは実際の依存関係(データベース、HTTPなど)を使用します。単体テストは高速で安定していますが、統合テストは処理に時間がかかりますが、より現実的なテストが可能です。

📖 まとめ


📝 練習問題

  1. 初心者 (⭐): Order に対して JUnit 5 テストを作成し、id および status の動作を検証してください。ヒント:@Test fun \ は注文`() { ... }` を作成するはずです。
  2. 中級 (⭐⭐): MockK を使用して OrderRepository をモック化し、OrderService.processOrder() について正常ケースとエラーケースの両方をテストしてください。ヒント: every { repo.findById(any()) } returns order
  3. 課題 (⭐⭐⭐): runTestcoEvery を使用して、非同期の注文取得関数をテストし、コルーチン呼び出しのタイミングを確認してください。ヒント: coEvery { repo.fetchOrderAsync(any()) } returns ...

← 前へ | 次へ →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%