Kotlin: JUnit 5とMockKによるKotlinテスト
最終更新:2026-08-26
Bobの信条は「テストされていないコードは、検証されていない仮定にすぎない」です。JUnit 5とMockKは、Kotlinに最適なテストの組み合わせです。MockKはKotlin向けに設計されてコルーチンのモックに対応し、JUnit 5は最新のテスト基盤を提供します。
1. 学習内容
- JUnit 5 + Kotlin:
@Test、@ParameterizedTest、@Nestedのテスト - MockK: Kotlinネイティブのモックフレームワーク
- コルーチンのテスト:
runTest/coEvery/coVerify - テストライフサイクル:
@BeforeEach/@AfterEach - Charlieの実戦:OrderProcessorTestの完全カバレッジ
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. テストピラミッドとフロー
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など)を使用します。単体テストは高速で安定していますが、統合テストは処理に時間がかかりますが、より現実的なテストが可能です。
📖 まとめ
- JUnit 5 と MockK は、Kotlin テストにおける最強の組み合わせです
- MockK は Kotlin をネイティブにサポートしています:final クラス、コルーチン、拡張関数、オブジェクト
every { } returnsはスタブ用、verify { }は呼び出しの検証用runTest:コルーチンテスト用。仮想時間を使用するため、delayを待つ必要はありません。coEvery/coVerify:suspend関数のモック化および検証に特化したもの- ネストされたテスト (
@Nested) は関連するテストを整理し、パラメータ化されたテスト (@ParameterizedTest) は重複を削減します
📝 練習問題
- 初心者 (⭐):
Orderに対して JUnit 5 テストを作成し、idおよびstatusの動作を検証してください。ヒント:@Test fun \は注文`() { ... }` を作成するはずです。 - 中級 (⭐⭐): MockK を使用して
OrderRepositoryをモック化し、OrderService.processOrder()について正常ケースとエラーケースの両方をテストしてください。ヒント:every { repo.findById(any()) } returns order - 課題 (⭐⭐⭐):
runTestとcoEveryを使用して、非同期の注文取得関数をテストし、コルーチン呼び出しのタイミングを確認してください。ヒント:coEvery { repo.fetchOrderAsync(any()) } returns ...