Upgrade to Pro — share decks privately, control downloads, hide ads and more …

コルーチンのエラーをテストするためのTips / Tips for testing Kotlin Coroutine errors

tkmnzm
March 03, 2023

コルーチンのエラーをテストするためのTips / Tips for testing Kotlin Coroutine errors

tkmnzm

March 03, 2023
Tweet

More Decks by tkmnzm

Other Decks in Programming

Transcript

  1. コルーチンのエラーをテストする
    ためのTips
    DeNA.apk#4 Nozomi Takuma

    View full-size slide

  2. 自己紹介
    ● Nozomi Takuma
    ● DeNA SWETグループ
    ○ 兼務: Pococha事業部システム部
    ● Androidとテストが好き

    View full-size slide

  3. 今日話すこと

    View full-size slide

  4. 今日話すこと
    ● suspend関数からthrowされるExceptionをテストする
    ● ViewModelScope内でのExceptionの振る舞い

    View full-size slide

  5. suspend関数から
    throwされるExceptionをテストする

    View full-size slide

  6. テスト対象のコード
    class NewsRepository(
    private val networkDataSource: NetworkDataSource
    ) {
    suspend fun getNewsResources(): List {
    return networkDataSource.getNewsResources()
    }
    }
    API通信をするsuspend関数
    APIからエラーが返ってきたときは
    そのままthrowされる

    View full-size slide

  7. Exceptionをthrowする関数のテストの書き方①
    @Test(expected = HttpException::class)
    fun getNewsResources() = runTest {
    // 省略. APIエラーを返すスタブの設定
    val newsRepository = NewsRepository(testDataSource)
    newsRepository.getNewsResources()
    }

    View full-size slide

  8. Exceptionをthrowするsuspend関数のテストの書き方①
    @Test(expected = HttpException::class)
    fun getNewsResources() = runTest {
    // 省略. APIエラーを返すスタブの設定
    val newsRepository = NewsRepository(testDataSource)
    newsRepository.getNewsResources()
    }
    Exceptionがthrowされるsuspend
    関数を実行

    View full-size slide

  9. Exceptionをthrowするsuspend関数のテストの書き方①
    @Test(expected = HttpException::class)
    fun getNewsResources() = runTest {
    // 省略. APIエラーを返すスタブの設定
    val newsRepository = NewsRepository(testDataSource)
    newsRepository.getNewsResources()
    }
    Throwされるクラスを指定
    同じ型のThrowableが投げられたら成功
    Throwableが投げられなかったり、違う
    型だった場合はテスト失敗

    View full-size slide

  10. Exceptionをthrowするsuspend関数のテストの書き方②
    @Test
    fun getNewsResources() = runTest {
    ..
    val exception = assertThrows(HttpException::class.java) {
    newsRepository.getNewsResources()
    }
    assertEquals("error", exception.message())
    }

    View full-size slide

  11. Exceptionをthrowするsuspend関数のテストの書き方②
    @Test
    fun getNewsResources() = runTest {
    ..
    val exception = assertThrows(HttpException::class.java) {
    newsRepository.getNewsResources()
    }
    assertEquals("error", exception.message())
    }
    Junit4に入っているThrowable用の
    アサーション
    ブロックの中でthrowする関数を呼び出す

    View full-size slide

  12. Exceptionをthrowするsuspend関数のテストの書き方②
    @Test
    fun getNewsResources() = runTest {
    ..
    val exception = assertThrows(HttpException::class.java) {
    newsRepository.getNewsResources()
    }
    assertEquals("error", exception.message())
    }
    Throwableの型 + インスタンス
    に対してアサートをすることが
    できる

    View full-size slide

  13. Exceptionをthrowするsuspend関数のテストの書き方②
    @Test
    fun getNewsResources() = runTest {
    ..
    val exception = assertThrows(HttpException::class.java) {
    newsRepository.getNewsResources()
    }
    assertEquals("error", exception.message())
    }

    View full-size slide

  14. Exceptionをthrowするsuspend関数のテストの書き方②
    @Test
    fun getNewsResources() = runTest {
    ..
    val exception = assertThrows(HttpException::class.java) {
    newsRepository.getNewsResources()
    }
    assertEquals("error", exception.message())
    }
    Junit4のassertThrowsのブロックの中で
    は直接suspend関数を呼び出せない
    (Junit5のassertThrowsは可)

    View full-size slide

  15. Exceptionをthrowするsuspend関数のテストの書き方②
    build.gradle
    testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"

    View full-size slide

  16. Exceptionをthrowするsuspend関数のテストの書き方②
    @Test
    fun getNewsResources() = runTest {
    ..
    val exception = assertFailWith {
    newsRepository.getNewsResources()
    }
    assertEquals("error", exception.message())
    }

    View full-size slide

  17. Exceptionをthrowするsuspend関数のテストの書き方②
    @Test
    fun getNewsResources() = runTest {
    ..
    val exception = assertFailWith {
    newsRepository.getNewsResources()
    }
    assertEquals("error", exception.message())
    }
    インライン関数なのでTestScopeの中で
    実行できる

    View full-size slide

  18. ViewModelScope内でのExceptionの振る舞い

    View full-size slide

  19. テスト対象のコード
    class NewsViewModel : ViewModel() {
    fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) {
    viewModelScope.launch {
    throw IOException()
    }
    }
    }

    View full-size slide

  20. テスト対象のコード
    class NewsViewModel : ViewModel() {
    fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) {
    viewModelScope.launch {
    throw IOException()
    }
    }
    }
    viewModelScope内で例外が発生
    エラーハンドリングをし忘れている

    View full-size slide

  21. テストコード
    @Test
    fun bookmarkNews() {
    viewModel.bookmarkNews("id", true)
    }

    View full-size slide

  22. テストコード
    @Test
    fun bookmarkNews() {
    viewModel.bookmarkNews("id", true)
    }

    View full-size slide

  23. テストコード
    @Test
    fun bookmarkNews() {
    viewModel.bookmarkNews("id", true)
    }
    例外が握りつぶされている

    View full-size slide

  24. ViewModelで問題になるのはなぜ?
    @Test(expected = HttpException::class)
    fun getNewsResources() = runTest {
    val newsRepository = NewsRepository(testDataSource)
    newsRepository.getNewsResources()
    }

    View full-size slide

  25. ViewModelで問題になるのはなぜ?
    @Test(expected = HttpException::class)
    fun getNewsResources() = runTest {
    val newsRepository = NewsRepository(testDataSource)
    newsRepository.getNewsResources()
    }
    ブロックの中はTestScopeで実行される

    View full-size slide

  26. ViewModelで問題になるのはなぜ?
    public val ViewModel.viewModelScope: CoroutineScope
    get() {
    val scope: CoroutineScope? = this.getTag(JOB_KEY)
    if (scope != null) {
    return scope
    }
    return setTagIfAbsent(
    JOB_KEY,
    CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    )
    }

    View full-size slide

  27. ViewModelで問題になるのはなぜ?
    public val ViewModel.viewModelScope: CoroutineScope
    get() {
    val scope: CoroutineScope? = this.getTag(JOB_KEY)
    if (scope != null) {
    return scope
    }
    return setTagIfAbsent(
    JOB_KEY,
    CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    )
    }

    View full-size slide

  28. 関連するIssue
    TestCoroutineDispatcher swallows exceptions #1205
    https://github.com/Kotlin/kotlinx.coroutines/issues/1205

    View full-size slide

  29. ViewModel 2.5.0でのアップデート
    public ViewModel(@NonNull Closeable... closeables) {
    mCloseables.addAll(Arrays.asList(closeables));
    }

    View full-size slide

  30. ViewModel 2.5.0でのアップデート
    public ViewModel(@NonNull Closeable... closeables) {
    mCloseables.addAll(Arrays.asList(closeables));
    }
    onClearで閉じられる

    View full-size slide

  31. ViewModel 2.5.0でのアップデート
    class CloseableCoroutineScope(
    context: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate
    ) : Closeable, CoroutineScope {
    override val coroutineContext: CoroutineContext = context
    override fun close() {
    coroutineContext.cancel()
    }
    }
    Closableを実装した
    CoroutineScopeを用意

    View full-size slide

  32. ViewModel 2.5.0でのアップデート
    class NewsViewModel(
    val customScope: CloseableCoroutineScope = CloseableCoroutineScope()
    ) : ViewModel(customScope) {
    fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) {
    customScope.launch {
    throw IOException()
    }
    }
    }

    View full-size slide

  33. ViewModel 2.5.0でのアップデート
    class NewsViewModel(
    val customScope: CloseableCoroutineScope = CloseableCoroutineScope()
    ) : ViewModel(customScope) {
    fun bookmarkNews(newsResourceId: String, bookmarked: Boolean) {
    customScope.launch {
    throw IOException()
    }
    }
    }
    ViewModelScopeと同じように使える

    View full-size slide

  34. テストコードの変更
    @Test
    fun bookmarkNews() = runTest {
    val scope = CloseableCoroutineScope(coroutineContext + UnconfinedTestDispatcher())
    val newsViewModel = NewsViewModel(scope)
    newsViewModel.bookmarkNews("id", true)
    }

    View full-size slide

  35. テストコードの変更
    @Test
    fun bookmarkNews() = runTest {
    val scope = CloseableCoroutineScope(coroutineContext + UnconfinedTestDispatcher())
    val newsViewModel = NewsViewModel(scope)
    newsViewModel.bookmarkNews("id", true)
    }
    TestScopeから
    ClosableCoroutineScopeを作る

    View full-size slide

  36. テストコードの変更
    @Test
    fun bookmarkNews() = runTest {
    val scope = CloseableCoroutineScope(coroutineContext + UnconfinedTestDispatcher())
    val newsViewModel = NewsViewModel(scope)
    newsViewModel.bookmarkNews("id", true)
    } ViewModelに渡す

    View full-size slide

  37. テストコードの変更
    @Test
    fun bookmarkNews() = runTest {
    val scope = CloseableCoroutineScope(this.coroutineContext + UnconfinedTestDispatcher())
    val newsViewModel = NewsViewModel(scope)
    newsViewModel.bookmarkNews("id", true)
    }

    View full-size slide

  38. 今日話したこと
    ● suspend関数からthrowされるExceptionをテストする
    ○ @Test(expected = Throwable)
    ○ Kotlin Test LibraryのassertFailWithも便利
    ● ViewModelScope内でのExceptionの振る舞い
    ○ Exception発生時にテストをコケさせるためには工夫がいる
    ○ エラーハンドリング漏れには注意しよう

    View full-size slide

  39. ご清聴ありがとうございました!

    View full-size slide