コルーチンを使用する単体テストのコードの実行は、非同期、かつ複数のスレッド間で行われる可能性があるため、特別な注意が必要です。このガイドでは、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
の実装には StandardTestDispatcher
と UnconfinedTestDispatcher
の 2 つがあります。これらは新しく開始されるコルーチンのスケジューリング方法が異なります。どちらも TestCoroutineScheduler
を使用して仮想時間を制御し、テスト内で実行中のコルーチンを管理します。
1 つのテストで使用するスケジューラ インスタンスは 1 つだけとし、すべての TestDispatchers
で共有する必要があります。スケジューラの共有については、TestDispatchers の挿入をご覧ください。
トップレベルのテスト コルーチンを開始するために、runTest
は TestScope
を作成します。これは、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
内で、TestScope
の testScheduler
プロパティにアクセスし、新しく作成された 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
で開始された新しいコルーチンが完了したことを確認してから処理を進めます。
fetchData
は TestDispatcher
で実行してもメリットがあります。テストスレッドで実行され、テスト中の遅延がスキップされるためです。
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... } }
テスト内で作成された runTest
と TestDispatchers
は依然として 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
を作成することもできます。
その際は必ず、作成した TestScope
で runTest
を呼び出すようにしてください。
class SimpleExampleTest { val testScope = TestScope() // Creates a StandardTestDispatcher @Test fun someTest() = testScope.runTest { // ... } }
上記のコードは、TestScope
の StandardTestDispatcher
と新しいスケジューラを暗黙的に作成します。これらのオブジェクトはすべて明示的に作成することもできます。これは、依存関係挿入の設定と統合する必要がある場合に便利です。
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 の作成をご覧ください。