0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Kotlin】CancellationExceptionをテストする

0
Last updated at Posted at 2026-03-12

やったこと

 例外処理とCancellationExceptiontonの扱いを保証するUnitTestを書いたので備忘のためメモ

CancellationExceptionの扱いが面倒

 DataADataBというデータから作られるクラスDataCがあるとします↓

data class DataC(...) {
    constructor(
        dataA: DataA,
        dataB: DataB,
    ) : this(...)
}

 DataADataBはそれぞれDataSourceA.loadA()DataSourceB.loadB()というsuspend関数から取得できるとします。
 失敗をどう扱うかは色々だと思いますが、今回は簡単のためにそれぞれの関数は結果を返すか、例外を吐くかのどちらかの動作をするとします↓

// loadA()は失敗したら例外を吐く
interface DataSourceA {
    suspend fun loadA(): DataA
}

// loadB()は失敗したら例外を吐く
interface DataSourceB {
    suspend fun loadB(): DataB
}

 loadA()loadB()が独立した処理のとき、asyncで両方の関数を非同期で呼びDataCを作成する && どちらかが失敗したら、もう片方を待たずに失敗を返す関数RepositoryC.loadC()を考えます。
 ここも返り値は様々あるかと思いますが、今回はkotlin.Resultを使っています↓

interface RepositoryC {
    suspend fun loadC(): Result<DataC>
}

class DefaultRepositoryC(
    private val dataSourceA: DataSourceA,
    private val dataSourceB: DataSourceB,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : RepositoryC {
    override suspend fun loadC(): Result<DataC> = runCatching {
        withContext(dispatcher) {
            val deferredA = async { dataSourceA.loadA() }
            val deferredB = async { dataSourceB.loadB() }
            DataC(deferredA.await(), deferredB.await())
        }
    }.onFailure { if (it is CancellationException) throw it }
}

 .onFailure {}で失敗時の例外がCancellationExceptionの場合だけ例外を投げ返しています。
 これはKotlinのコルーチンがCancellationExceptionを利用してジョブのキャンセルを伝えるため、この例外をkotlin.Resultに詰めて返してしまうと、キャンセル時に正常にキャンセルが伝播しなくなるためです↓

Caution: To enable coroutine cancellation, don't consume exceptions of type CancellationException (don't catch them, or always rethrow them if caught). Prefer catching specific exception types like IOException over generic types like Exception or Throwable.

 当然気を付けていれば問題はないのですが、正直かなりややこしいです。
 例えばこのloadC()runCatching {}withContext(dispatcher) {}の順番を入れ替えると、キャンセル以外の失敗時にも例外が親コルーチンまで返ります↓

NG
    override suspend fun loadC(): Result<DataC> = withContext(dispatcher) { // async内部で例外を起こすとcatchを超えてここに返る
        runCatching {
            val deferredA = async { DataSourceA.loadA() } // loadAは例外を吐く
            val deferredB = async { DataSourceB.loadB() } // loadBは例外を吐く
            DataC(deferredA.await(), deferredB.await())
        }.onFailure { if (it is CancellationException) throw it }
    }

 これではおちおち触れないため、このコルーチンと例外周りのロジックを保証する用のUnitTestを用意しました。

UnitTestで保証する

 JUnit4、org.jetbrains.kotlinx:kotlinx-coroutines-testio.mockk:mockkを使用しています。

import io.mockk.coEvery
import io.mockk.junit4.MockKRule
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.coroutines.cancellation.CancellationException

@OptIn(ExperimentalCoroutinesApi::class)
class RepositoryCTest {
    @get:Rule
    val mockkRule = MockKRule(this)
    
    private val dataSourceA: DataSourceA = mockk()
    private val dataSourceB: DataSourceB = mockk()
    
    @Before
    fun setup() {
        coEvery { dataSourceB.loadB() } answers { mockk(relaxed = true) }
    }

    // 正常系
    @Test
    fun `loadC success`() = runTest {
        val repository = createRepository()
        coEvery { dataSourceA.loadA() } answers { mockk(relaxed = true) }
        val result = repository.loadC()
        assertTrue(result.isSuccess)
    }

    // loadAの実行に失敗
    @Test
    fun `loadC error`() = runTest {
        val repository = createRepository()
        coEvery { dataSourceA.loadA() } throws (Exception(""))
        val result = repository.loadC()
        assertTrue(result.isFailure)
    }

    // 親コルーチンがキャンセル
    @Test
    fun `loadC parent cancel`() = runTest {
        val repository = createRepository()
        coEvery { dataSourceA.loadA() } coAnswers {
            delay(1000)
            mockk(relaxed = true)
        }
        
        var isJobContinue = false
        val job = launch {
            repository.loadC()
            isJobContinue = true
        }
        advanceTimeBy(500)
        job.cancelAndJoin()
        assertTrue(!isJobContinue)
    }

    // 子コルーチンからキャンセル通知
    @Test
    fun `loadC child cancel`() = runTest {
        val repository = createRepository()
        coEvery { dataSourceA.loadA() } throws (CancellationException(""))
        
        var isJobContinue = false
        val job = launch {
            repository.loadC()
            isJobContinue = true
        }
        job.join()
        assertTrue(!isJobContinue)
    }
    
    private fun TestScope.createRepository() = DefaultRepositoryC(
        dataSourceA = dataSourceA,
        dataSourceB = dataSourceB,
        dispatcher = StandardTestDispatcher(testScheduler),
    )
}

感想

やはりテストは大事だなと思いました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?