Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

今日話すこと

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

ViewModelScope内でのExceptionの振る舞い

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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) ) }

Slide 27

Slide 27 text

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) ) }

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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を用意

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

まとめ

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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