この記事はand factory.inc Advent Calendar 2021 1日目の記事です。
Kotlin coroutines 1.6.0-RCがリリースされ、テスト周りに大幅な変更が入ったので確認してみました。
※kotlinx-coroutines-testのバージョン1.6.0-RCで検証を行っています。
ほとんどのAPIがExperimentalなので将来的に変更される可能性もあります。
追記(2022/5/26)
Android Developer向けのドキュメントが非常にわかりやすいのでこちらもご確認ください。
Dispatchers.setMain()
Dispatchers.Main
が使われている箇所を任意のDispatcherに置き換える関数です。
これ自体はもともと存在していましたが、セットするDispatcherを変更する必要があります。
class SomeTest {
val dispatcher = TestCoroutineDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testA() = dispatcher.runBlockingTest {
// TODO
}
}
class SomeTest {
@Before
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testA() = runTest {
// TODO
}
}
setMain()
でStandardTestDispatcher()
をセットしてrunTest()
でテストを実行します。
これらの説明は後述します。
TestCoroutineScheduler
テストで使用されるコルーチンのスケジューラで、自動的にdelay()
等の遅延がスキップされます。
後述するTestDispatcher
はこのスケジューラによってパラメータ化されており、複数のDispatcherが同じスケジューラを共有できます。
Dispatcherはイベントのスケジューリングが必要な場合、スケジューラに通知してスケジューラがタスクの順序を決定する仕組みになっています。
時間を進めるadvanceTimeBy
、必要に応じて時間を進めながらタスクを実行するadvanceUntilIdle
、現段階までで実行が予定されているタスクを実行するrunCurrent
などのスケジュールをコントロールする関数が用意されています。
以前はDelayController
というinterfaceを用いて時間のコントロールを行っていましたが、こちらは非推奨となります。
TestScope
TestCoroutineScope
が非推奨となり、TestScope
が追加されました。
後述するrunTest
に統合されています。(TestScope()
関数でインスタンスを生成することも可能です。)
coroutineContext
にTestCoroutineScheduler
が含まれていており、取得することが可能です。
val scope = TestScope()
val scheduler = scope.testScheduler
runTest
runBlockingTest
が非推奨となり、runTest
が追加されました。
コルーチンを含むコードをテストするのに用い、suspend関数を実行できます。
runTest
内で子コルーチンを起動した場合、別のCoroutineDispatcherを渡していない限り単一スレッドで実行されるのでテスト本体と並行で実行されることはありません。
子コルーチンを実行するためにはテスト本体を一時停止させるか、TestCoroutineScheduler
を利用してスケジューリングを制御する必要があります。
以下のコードのコメントに書かれている数字は実行される順番を示します。
@Test
fun exampleTest() = runTest { // this: TestScope
// 1
val job = launch {
// 3
}
// 2
job.join() // テスト本体がここで中断されて子コルーチンが実行される
// or this.advanceUntilIdle(), this.runCurrent()
// 4
}
Dispatchers.setMain()
を使用してDispatcherをTestDispatcher
に置き換えた場合も同様の考慮が必要です。
以下のコードではSomeClassで実行するコルーチンのDispatcherをStandardTestDispatcher
に置き換えています。
この場合も子コルーチンと同様に並行で実行されることはないので、スケジュールを制御する必要があります。
class SomeTest {
@Before
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}
@Test
fun exampleTest() = runTest {
val some = SomeClass()
advanceUntilIdle() // これを実行することでSomeClassのコルーチンが実行される
assertEquals(some.flag, true)
}
}
class SomeClass {
var flag = false
private set
init {
CoroutineScope(Dispatchers.Main).launch {
flag = true
}
}
}
TestDispatcher
TestCoroutineDispatcher
が非推奨となり、StandardTestDispatcher()
とUnconfinedTestDispatcher()
2つのDispatcherが導入されました。
StandardTestDispatcher
TestCoroutineScheduler
にリンクされている以外に特別な動作はしないシンプルなDispatcherです。
runTest
やTestScope
でDispatcherを指定せずにコルーチンを実行した場合、デフォルトで用いられます。
runTest
内では子コルーチンが直ちに実行されないという話をしましたが、それはこのDispatcherの特性によるものです。
launch, asyncブロックはCoroutineStart.UNDISPATCHED
でパラメータ化されていない限りすぐには実行されないので注意が必要です。
UnconfinedTestDispatcher
Dispatchers.Unconfined
に似た動作をするDispatcherです。
(複数のコルーチンがこのDispatcherにキューイングされているとき、実行順序を保証しない)
StandardTestDispatcher
と同様にTestCoroutineScheduler
にリンクされています。
このDispatcherを用いてrunTest
内で子コルーチンを起動した場合(launch, async)は直ちに実行されます。
例えば、StateFlowやChannelをテストしたい場合に便利です。
以下のコードでは、StateFlowのサブスクライブにUnconfinedTestDispatcher
を利用しています。
子コルーチンが直ちに実行されることでStateFlowの値を変化させる前にcollectが動作し、すべての値を観測することができます。
class SomeTest {
@Before
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}
@Test
fun exampleTest() = = runTest {
val values = mutableListOf<Int>()
val stateFlow = MutableStateFlow(0)
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
stateFlow.collect {
values.add(it)
}
}
stateFlow.value = 1
stateFlow.value = 2
stateFlow.value = 3
job.cancel()
assertEquals(listOf(0, 1, 2, 3), values)
}
}
Migration
1.6.0-RC以前のAPIの多くは非推奨となっており、将来的に削除される予定なので新しいAPIに移行する必要があります。
既存のテストコードをリプレイスする際はマイグレーションガイドが用意されているので、規模によってはステップバイステップで切り替えていくのも良いかと思います。
参考