この記事は Kotlin Advent Calendar 2020 の15日目の記事です。
非同期処理を書く際に、kotlin coroutinesは使いやすく、非常に強力です。
一方で、単体テスト等を書くのには一定のハードルがあります。
今回は、suspend functionをテストしたり、モックする方法について紹介します。
例えば、以下のような実行に10秒かかるsuspend functionがあったとします。
suspend fun sample(): Int {
delay(10_000)
return 10
}
これをテストする際に、runBlocking等を使ってしまうと、テストの実行に10秒かかってしまいます。
@Test
fun test() {
val actual = runBlocking {
sample()
}
assertThat(actual).isEqualTo(10)
}
こういったテストをたくさん書いていくと、テストの実行に時間がかかるようになってしまうため、テストは即座に完了することが望ましいとされています。
そのため、kotlin coroutinesにはkotlinx-coroutines-testというテストツールが用意されています。
このように依存関係を追加してください。
versionは使っているkotlin coroutinesのversionと合わせてください。
dependencies {
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:{version}'
}
そうすると、runBlockingTestが利用できるようになり、テストを即座に実行することができるようになります。
@Test
fun test() {
runBlockingTest {
val actual = sample()
assertThat(actual).isEqualTo(10)
}
}
例えば、このように sample
メソッドを呼び出した10秒後に isCalled
の値を書き換えるクラスが合ったとします。
class Sample {
private val dispatcher = Dispatchers.Main.immediate
private val scope = CoroutineScope(dispatcher)
var isCalled: Boolean = false
fun sample() {
scope.launch {
delay(10_000)
isCalled = true
}
}
}
このクラスをテストするためには、setMainをつかってDispatchers.Main
をTestCoroutineDispatcher差し替えることで、時間をかけずに正しくテストすることができます。
DelayController.advanceTimeByを使うことで、時間を操作することができます。
@ExperimentalCoroutinesApi
class SampleTest {
private val dispatcher = TestCoroutineDispatcher()
@Before
fun setup() {
Dispatchers.setMain(dispatcher)
}
@After
fun clear() {
Dispatchers.resetMain()
}
@Test
fun test() {
val sample = Sample()
sample.sample()
assertThat(sample.isCalled).isFalse()
dispatcher.advanceTimeBy(10_000)
assertThat(sample.isCalled).isTrue()
}
}
DelayController.advanceUntilIdleを使うことで、保留中のタスクがすべて終わるまで待機してくれるので、こちらの書き方でも良いでしょう。
@Test
fun test() {
val sample = Sample()
sample.sample()
assertThat(sample.isCalled).isFalse()
dispatcher.advanceUntilIdle()
assertThat(sample.isCalled).isTrue()
}
AndroidにおけるViewModelScopeも同じ方法で差し替えることができます。
以下のようなViewModelも上記のテストでカバーできます。
class SampleViewModel : ViewModel() {
var isCalled: Boolean = false
fun sample() {
viewModelScope.launch {
delay(10_000)
isCalled = true
}
}
}
以下のように、Dispatchers.Main
以外のcontextで起動してる場合、上記のテストは動きません。
class Sample {
private val scope = CoroutineScope(EmptyCoroutineContext)
var isCalled: Boolean = false
fun sample() {
scope.launch(Dispatchers.Default) {
delay(10_000)
isCalled = true
}
}
}
こういった場合は、現状contextをDIできるようにしておく必要がありそうです。
class Sample(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
private val scope = CoroutineScope(EmptyCoroutineContext)
var isCalled: Boolean = false
fun sample() {
scope.launch(defaultDispatcher) {
delay(10_000)
isCalled = true
}
}
}
@Test
fun test() {
val sample = Sample(dispatcher)
sample.sample()
assertThat(sample.isCalled).isFalse()
dispatcher.advanceTimeBy(10_000)
assertThat(sample.isCalled).isTrue()
}
mockkを使うことで、suspend functionをモックすることができます。
dependencies {
testImplementation "io.mockk:mockk:{version}"
}
例えば以下のような、DataSource
から取得した値に +1
するクラスが合ったとします。
class Sample(
private val dataSource: DataSource
) {
suspend fun sample(): Int {
return dataSource.getData() + 1
}
}
interface DataSource {
suspend fun getData(): Int
}
この DataSource
はモックしつつ、Sampleのテストを書きたい場合、coEvery
を使ってこのように書くことができます。
@Test
fun test() {
runBlockingTest {
val dataSource: DataSource = mockk()
// `dataSource.getData()` の返り値を定義
coEvery {
dataSource.getData()
} returns 10
val sample = Sample(dataSource)
val actual = sample.sample()
assertThat(actual).isEqualTo(11)
}
}
coVerify
を使って、メソッドが呼び出されたかのテストをすることもできます。
@Test
fun test() {
runBlockingTest {
val dataSource: DataSource = mockk()
coEvery {
dataSource.getData()
} returns 10
val sample = Sample(dataSource)
sample.sample()
// 1回 `dataSource.getData()` が呼び出されたことを確認
coVerify(exactly = 1) {
dataSource.getData()
}
}
}
以下のような1秒たったらtimeoutし、-1を返すメソッドをテストしたいとします。
class Sample(
private val dataSource: DataSource
) {
suspend fun sample(): Int {
return withTimeoutOrNull(1_000) {
dataSource.getData() + 1
} ?: -1
}
}
以下のように、coAnswers
を使うことで時間のかかるsuspend functionを定義することができます。
@Test
fun test() {
runBlockingTest {
val dataSource: DataSource = mockk()
// 5秒後に値を返すよう定義
coEvery {
dataSource.getData()
} coAnswers {
delay(5_000)
10
}
val sample = Sample(dataSource)
val actual = sample.sample()
assertThat(actual).isEqualTo(-1)
}
}
以下のようにDelayController.currentTimeを使うことで、かかった時間を測定することもできます。
@Test
fun test() {
runBlockingTest {
val dataSource: DataSource = mockk()
// 5秒後に値を返すよう定義
coEvery {
dataSource.getData()
} coAnswers {
delay(5_000)
10
}
val sample = Sample(dataSource)
val startTime = currentTime
sample.sample()
val endTime = currentTime
// 1秒しか経ってないこと確認
assertThat(endTime - startTime).isEqualTo(1_000)
}
}
今回はsuspend functionに関するテストの書き方を紹介しました。
本来時間がかかる処理であっても、テストでは必ず即座に実行されるよう工夫することで、テストにかかる時間を短縮することができます。
また、以前flowのテストについて紹介しています。よかったら参考にしてみてください。
kotlin coroutines flowのテストを快適に書く
非同期処理は難しく、またそれに対するテストも複雑になりがちです。
担保したいことは何なのかを意識しつつ、適切な粒度で書いていくことが大事だと考えています。
- 2021/01/31追記-
Kotlin Coroutinesの解説本をZennにて販売しています。より詳しく学びたい方は、こちらも合わせて確認してみて下さい。