Android での Kotlin コルーチンのテスト

コルーチンを使用する単体テストのコードの実行は、非同期、かつ複数のスレッド間で行われる可能性があるため、特別な注意が必要です。このガイドでは、suspend 関数のテスト方法、知っておく必要があるテストの構成、コルーチンを使用するコードをテスト可能にする方法について説明します。

このガイドで使用する API は、kotlinx.coroutines.test ライブラリに含まれています。こうした API にアクセスするには、プロジェクトにテスト依存関係としてアーティファクトを追加します。

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

テストでの suspend 関数の呼び出し

テストで suspend 関数を呼び出すには、コルーチン内で行う必要があります。JUnit テスト関数自体は suspend 関数ではないため、テスト内でコルーチン ビルダーを呼び出して新しいコルーチンを開始する必要があります。

runTest は、テスト用に設計されたコルーチン ビルダーです。これを使用して、コルーチンを含むすべてのテストをラップします。コルーチンは、テスト本体で直接開始できるだけでなく、テストで使用しているオブジェクトからも開始できます。

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

一般に、テストごとに 1 回 runTest を呼び出す必要があります。また、式本体を使用することをおすすめします。

テストのコードを runTest でラップすると、基本的な suspend 関数をテストできるようになります。また、コルーチンの遅延がすべて自動的にスキップされるため、上記のテストは 1 秒もかからずに完了します。

ただし、テスト対象のコードの動作によっては、他にも留意すべき点があります。

  • runTest が作成するトップレベルのテスト コルーチン以外の新しいコルーチンをコードで作成する際は、適切な TestDispatcher を選択して、その新しいコルーチンのスケジューリング方法を制御する必要があります。
  • コルーチンの実行を他のディスパッチャに移動する場合(withContext を使用するなど)、runTest は通常どおり機能しますが、遅延はスキップされず、テストはコードが複数のスレッドで実行されるため、予測可能性が低くなります。そのため、テストでは、ディスパッチャを挿入して実際のディスパッチャを置き換える必要があります。

TestDispatchers

TestDispatchers は、テストを目的とした CoroutineDispatcher の実装です。テスト時に新しいコルーチンを作成し、新しいコルーチンの実行を予測できるようにするには、TestDispatchers を使用する必要があります。

TestDispatcher の実装には StandardTestDispatcherUnconfinedTestDispatcher の 2 つがあります。これらは新しく開始されるコルーチンのスケジューリング方法が異なります。どちらも TestCoroutineScheduler を使用して仮想時間を制御し、テスト内で実行中のコルーチンを管理します。

1 つのテストで使用するスケジューラ インスタンスは 1 つだけとし、すべての TestDispatchers で共有する必要があります。スケジューラの共有については、TestDispatchers の挿入をご覧ください。

トップレベルのテスト コルーチンを開始するために、runTestTestScope を作成します。これは、CoroutineScope の実装であり、常に TestDispatcher を使用します。指定しない場合、TestScope はデフォルトで StandardTestDispatcher を作成し、それを使用してトップレベルのテスト コルーチンを実行します。

runTest は、TestScope のディスパッチャが使用するスケジューラのキューに追加されているコルーチンを追跡し、そのスケジューラで保留中の作業がある限り、戻りません。

StandardTestDispatcher

StandardTestDispatcher で新しいコルーチンを開始すると、そのコルーチンが基になるスケジューラのキューに追加され、テストスレッドが空いているときに実行されます。この新しいコルーチンを実行させるには、テストスレッドを明け渡す必要があります(他のコルーチンで使用できるように解放します)。このキューの動作により、テスト中の新規コルーチンの動作を細かく制御できます。これは、本番環境のコードにおけるコルーチンのスケジューリングに似ています。

トップレベルのテスト コルーチンの実行中にテストスレッドが渡されない場合、新しいコルーチンはテスト コルーチンが完了した後(ただし runTest が返される前)にのみ実行されます。

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

テスト コルーチンを明け渡して、キューに追加されたコルーチンを実行できるようにするには、いくつかの方法があります。上記のすべての呼び出しで、他のコルーチンをテストスレッドで実行させてから戻ります。

  • advanceUntilIdle: キューに何も残らなくなるまで、スケジューラで他のコルーチンをすべて実行します。保留中のすべてのコルーチンを実行できるようにするデフォルトとして適切な方法であり、ほとんどのテストシナリオで機能します。
  • advanceTimeBy: 仮想時間を指定の分だけ進め、その時点までにスケジュール設定されているすべてのコルーチンを実行します。
  • runCurrent: 現在の仮想時間にスケジュール設定されているコルーチンを実行します。

前述のテストを修正するために、advanceUntilIdle を使用して、アサーションを続行する前に 2 つの保留中のコルーチンが処理を実行するようにできます。

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle() // Yields to perform the registrations

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

UnconfinedTestDispatcher

UnconfinedTestDispatcher で新しいコルーチンが開始されると、現在のスレッドで積極的に開始されます。つまり、コルーチン ビルダーが戻るのを待たずに、すぐに実行が開始されます。多くの場合、こうしたディスパッチ動作により、新しいコルーチンの実行を目的としてテストスレッドを手動で生成する必要がないため、テストコードがシンプルになります。

ただし、この動作は、テスト以外のディスパッチャを使用する本番環境での動作とは異なります。同時実行を重視するテストの場合は、代わりに StandardTestDispatcher を使用することをおすすめします。

このディスパッチャを、デフォルトではなく runTest のトップレベルのテスト コルーチンに使用するには、インスタンスを作成してパラメータとして渡します。これにより、runTest 内で作成された新しいコルーチンは、TestScope からディスパッチャを継承するため、積極的に実行されるようになります。

@Test
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

この例では、launch 呼び出しにより、UnconfinedTestDispatcher で新しいコルーチンが積極的に開始されます。つまり、launch の各呼び出しが戻るのは、登録が完了した後のみです。

UnconfinedTestDispatcher により新しいコルーチンが積極的に開始されますが、必ずしも最後まで積極的に実行されるわけではありません。新しいコルーチンが停止すると、他のコルーチンの実行が再開されます。

たとえば、このテストで開始された新しいコルーチンで Alice が登録されますが、delay が呼び出されると停止します。これにより、トップレベルのコルーチンでアサーションが続行され、Bob がまだ登録されていないためテストは失敗します。

@Test
fun yieldingTest() = runTest(UnconfinedTestDispatcher()) {
    val userRepo = UserRepository()

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ❌ Fails
}

テスト ディスパッチャの挿入

テスト対象のコードでは、ディスパッチャを使用してスレッドを切り替えたり(withContext を使用)、新しいコルーチンを開始したりする場合があります。複数のスレッドで並行してコードを実行すると、テストが不安定になることがあります。制御できないバックグラウンド スレッドで実行されていると、適切なタイミングでのアサーションの実行や、タスク完了の待機が難しいこともあります。

テストでは、こうしたディスパッチャは TestDispatchers のインスタンスに置き換えます。これには、さまざまなメリットがあります。

  • コードは単一のテストスレッドで実行されるため、テストの確定性が向上
  • 新しいコルーチンのスケジューリング方法と実行方法を制御可能
  • TestDispatchers は仮想時間にスケジューラを使用しており、自動的に遅延をスキップし、手動で時間を進めることが可能

依存関係挿入を使用してクラスにディスパッチャを提供すると、テストで実際のディスパッチャを簡単に置き換えることができます。これらの例では CoroutineDispatcher を挿入しますが、 幅広い CoroutineContext これにより、テストの柔軟性をさらに高めることができます。

コルーチンを開始するクラスの場合は、ディスパッチャの代わりに CoroutineScope を挿入することもできます(スコープの注入を参照)。

デフォルトでは、TestDispatchers はインスタンス化されたときに新しいスケジューラを作成します。runTest 内で、TestScopetestScheduler プロパティにアクセスし、新しく作成された TestDispatchers に渡すことができます。これにより、仮想時間についての認識が共有され、advanceUntilIdle などのメソッドはすべてのテスト ディスパッチャでコルーチンを実行して完了します。

次の例では、Repository クラスの initialize メソッドで IO ディスパッチャを使用して新しいコルーチンを作成し、fetchData メソッドで呼び出し元を IO ディスパッチャに切り替えています。

// Example class demonstrating dispatcher use cases
class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)
    val initialized = AtomicBoolean(false)

    // A function that starts a new coroutine on the IO dispatcher
    fun initialize() {
        scope.launch {
            initialized.set(true)
        }
    }

    // A suspending function that switches to the IO dispatcher
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

テストでは、TestDispatcher 実装を挿入して IO ディスパッチャを置き換えることができます。

以下の例では、StandardTestDispatcher をリポジトリに挿入し、advanceUntilIdle を使用して、initialize で開始された新しいコルーチンが完了したことを確認してから処理を進めます。

fetchDataTestDispatcher で実行してもメリットがあります。テストスレッドで実行され、テスト中の遅延がスキップされるためです。

class RepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)

        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        assertEquals("Hello world", data)
    }
}

TestDispatcher で開始される新しいコルーチンは、上記の initialize と同様に手動で進めることができます。ただし、これは本番環境のコードでは不可能であり、望ましくありません。代わりに、このメソッドは、一時停止する(順次実行の場合)か、Deferred 値を返す(同時実行の場合)ように設計し直す必要があります。

たとえば、async を使用して新しいコルーチンを開始し、Deferred を作成できます。

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

これにより、テストコードと本番環境コードの両方で、このコードの完了を安全に await できます。

@Test
fun repoInitWorks() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterRepository(dispatcher)

    repository.initialize().await() // Suspends until the new coroutine is done
    assertEquals(true, repository.initialized.get())
    // ...
}

runTest は、スケジューラを共有する TestDispatcher にコルーチンがある場合、保留中のコルーチンが完了するのを待ってから戻ります。また、他のディスパッチャの場合でも、トップレベルのテスト コルーチンの子であるコルーチンを待ちます(dispatchTimeoutMs パラメータで指定されたタイムアウトまで。デフォルトでは 60 秒)。

メイン ディスパッチャの設定

ローカル単体テストでは、Android デバイスではなくローカル JVM 上で実行されるため、Android UI スレッドをラップする Main ディスパッチャは使用できません。テスト対象のコードがメインスレッドを参照すると、単体テスト中に例外がスローされます。

場合によっては、前のセクションで説明したように、Main ディスパッチャを他のディスパッチャと同じ方法で挿入し、テストで TestDispatcher に置き換えることができます。ただし、viewModelScope などの一部の API は、内部でハードコードされた Main ディスパッチャを使用します。

以下に、viewModelScope を使用してデータを読み込むコルーチンを起動する ViewModel の実装の例を示します。

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

いずれの場合も、Main ディスパッチャを TestDispatcher に置き換えるには、Dispatchers.setMain 関数と Dispatchers.resetMain 関数を使用します。

class HomeViewModelTest {
    @Test
    fun settingMainDispatcher() = runTest {
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)
        Dispatchers.setMain(testDispatcher)

        try {
            val viewModel = HomeViewModel()
            viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
            assertEquals("Greetings!", viewModel.message.value)
        } finally {
            Dispatchers.resetMain()
        }
    }
}

Main ディスパッチャが TestDispatcher に置き換えられた場合、新しく作成された TestDispatchers は自動的に Main ディスパッチャのスケジューラを使用します(他のディスパッチャが渡されない場合は runTest で作成された StandardTestDispatcher など)。

これにより、テスト中に使用するスケジューラが 1 つだけであることを簡単に確認できます。これを機能させるには、Dispatchers.setMain を呼び出した後に、他のすべての TestDispatcher インスタンスを作成するようにしてください。

各テストで Main ディスパッチャを置き換えるコードが重複しないように、JUnit のテストルールに抽出するのが一般的なパターンです。

// Reusable JUnit4 TestRule to override the Main dispatcher
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest { // Uses Main’s scheduler
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        assertEquals("Greetings!", viewModel.message.value)
    }
}

このルールの実装はデフォルトで UnconfinedTestDispatcher を使用しますが、Main ディスパッチャが特定のテストクラスで積極的に実行されないようにするには、StandardTestDispatcher をパラメータとして渡します。

テスト本体で TestDispatcher インスタンスが必要な場合は、目的の型であるならば、ルールの testDispatcher を再利用できます。テストで使用する TestDispatcher の型を明示したい場合や、Main で使用しているものとは異なる型の TestDispatcher が必要な場合は、runTest 内に新しい TestDispatcher を作成できます。Main ディスパッチャは TestDispatcher に設定されているため、新しく作成された TestDispatchers は自動的にそのスケジューラを共有します。

class DispatcherTypesTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
        // Use the UnconfinedTestDispatcher from the Main dispatcher
        val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)

        // Create a new StandardTestDispatcher (uses Main’s scheduler)
        val standardRepo = Repository(StandardTestDispatcher())
    }
}

テスト外でのディスパッチャの作成

場合によっては、テストメソッド外で TestDispatcher を使用できるようにする必要があります。たとえば、テストクラスのプロパティの初期化時は、以下のようになります。

class ExampleRepository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = ExampleRepository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
        // ...
    }
}

前のセクションで示したように、Main ディスパッチャを置き換えた場合、Main ディスパッチャの置き換え後に作成された TestDispatchers は自動的にそのスケジューラを共有します。

ただし、テストクラスのプロパティとして作成された TestDispatchers や、テストクラスのプロパティの初期化中に作成された TestDispatchers には当てはまりません。この場合、Main ディスパッチャが置き換えられる前に初期化されるため、新しいスケジューラが作成されます。

テストにスケジューラが 1 つしかないようにするには、まず MainDispatcherRule プロパティを作成します。その後、必要に応じて他のクラスレベル プロパティのイニシャライザでそのディスパッチャ(別の型の TestDispatcher が必要な場合は、そのスケジューラ)を再利用します。

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = ExampleRepository(mainDispatcherRule.testDispatcher)

    @Test
    fun someRepositoryTest() = runTest { // Takes scheduler from Main
        // Any TestDispatcher created here also takes the scheduler from Main
        val newTestDispatcher = StandardTestDispatcher()

        // Test the repository...
    }
}

テスト内で作成された runTestTestDispatchers は依然として Main ディスパッチャのスケジューラを自動的に共有しますので、ご注意ください。

Main ディスパッチャを置き換えない場合は、クラスのプロパティとして最初の TestDispatcher(新しいスケジューラを作成)を作成します。次に、そのスケジューラを、runTest の各呼び出しと、新しく作成した各 TestDispatcher に(プロパティとして、およびテスト内で)手動で渡します。

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = ExampleRepository(testDispatcher)

    @Test
    fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
        // Take the scheduler from the TestScope
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // Or take the scheduler from the first dispatcher, they’re the same
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // Test the repository...
    }
}

このサンプルでは、最初のディスパッチャからのスケジューラが runTest に渡されます。これにより、そのスケジューラを使用して、TestScope の新しい StandardTestDispatcher が作成されます。また、ディスパッチャを直接 runTest に渡して、そのディスパッチャでテスト コルーチンを実行することもできます。

独自の TestScope の作成

TestDispatchers と同様に、テスト本体外で TestScope へのアクセスが必要になることがあります。runTest は内部で自動的に TestScope を作成しますが、runTest で使用する独自の TestScope を作成することもできます。

その際は必ず、作成した TestScoperunTest を呼び出すようにしてください。

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

上記のコードは、TestScopeStandardTestDispatcher と新しいスケジューラを暗黙的に作成します。これらのオブジェクトはすべて明示的に作成することもできます。これは、依存関係挿入の設定と統合する必要がある場合に便利です。

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

スコープの挿入

テスト中の制御が必要になるコルーチンを作成するクラスがある場合は、そのクラスにコルーチン スコープを挿入して、テスト内で TestScope に置き換えることができます。

次の例では、UserState クラスが UserRepository に依存して新しいユーザーを登録し、登録済みユーザーのリストを取得します。これらの UserRepository の呼び出しは suspend 関数の呼び出しであるため、UserState は挿入された CoroutineScope を使用して、registerUser 関数内で新しいコルーチンを開始します。

class UserState(
    private val userRepository: UserRepository,
    private val scope: CoroutineScope,
) {
    private val _users = MutableStateFlow(emptyList<String>())
    val users: StateFlow<List<String>> = _users.asStateFlow()

    fun registerUser(name: String) {
        scope.launch {
            userRepository.register(name)
            _users.update { userRepository.getAllUsers() }
        }
    }
}

このクラスをテストするには、UserState オブジェクトの作成時に runTest から TestScope を渡します。

class UserStateTest {
    @Test
    fun addUserTest() = runTest { // this: TestScope
        val repository = FakeUserRepository()
        val userState = UserState(repository, scope = this)

        userState.registerUser("Mona")
        advanceUntilIdle() // Let the coroutine complete and changes propagate

        assertEquals(listOf("Mona"), userState.users.value)
    }
}

テスト関数の外側(テストクラスのプロパティとして作成されたテスト対象のオブジェクトなど)にスコープを挿入する場合は、独自の TestScope の作成をご覧ください。

参考情報