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

Androidのモダンな技術選択にあわせて自動テストも アップデートしよう / Update...

tkmnzm
October 05, 2022

Androidのモダンな技術選択にあわせて自動テストも アップデートしよう / Update your automated tests to match Android's modern technology choices

DroidKaigi 2022
「Androidのモダンな技術選択にあわせて自動テストも アップデートしよう
」の発表資料です。

tkmnzm

October 05, 2022
Tweet

More Decks by tkmnzm

Other Decks in Programming

Transcript

  1. 本セッションで話すこと • Androidアプリ開発において新しく採用されるケースが多い、次の技術のテスト方 法を紹介 ◦ Kotlin Coroutine / Kotlin Flow(1.6)

    ◦ Jetpack Compose ◦ Dagger Hilt • 各技術のテスト方法をGuide to app architectureで推奨されるレイヤ毎(UIレイヤ ・Dataレイヤ)に整理 • 各技術を採用したプロダクトでテストを書き始めるときに、テストの実装イメージが 掴めて、何から始めればいいかがわかるようになっていることがゴール 3
  2. データレイヤ • アプリデータと、アプリデータの作成・保存・ 変更方法を決定するビジネスロジックを持つ • データソースクラスはファイル・API・ローカ ルDB等、1 つのデータソースのみを処理す る役割 •

    リポジトリクラスはデータレイヤのエントリー ポイントとなり、データソースを抽象化する 14 Repositories Data Sources Kotlin Coroutine/ Flowで実装していること を想定してテスト方法を紹介
  3. データレイヤ • アプリデータと、アプリデータの作成・保存・ 変更方法を決定するビジネスロジックを持つ • データソースクラスはファイル・API・ローカ ルDB等、1 つのデータソースのみを処理す る役割 •

    リポジトリクラスはデータレイヤのエントリー ポイントとなり、データソースを抽象化する 15 Repositories Data Sources • シンプルなsuspend関数のテスト • Flowの変更をテストする • Flowの変更をcollectしてテストする • delayを入れる • withContextでDisptacherを切り替える
  4. データレイヤ • アプリデータと、アプリデータの作成・保存・ 変更方法を決定するビジネスロジックを持つ • データソースクラスはファイル・API・ローカ ルDB等、1 つのデータソースのみを処理す る役割 •

    リポジトリクラスはデータレイヤのエントリー ポイントとなり、データソースを抽象化する 16 Repositories Data Sources Retrofitで生成されるServiceやRoomのDaoが含まれる それらのテスト方法はセッションの本筋からずれるため除外
  5. シンプルな非同期処理のテストを書く 18 interface NetworkDataSource { suspend fun getTopics(): List<TopicResponse> }

    class TopicRepository(val networkDataSource: NetworkDataSource) { suspend fun getTopics(): List<Topic> { val response: List<TopicResponse> = networkDataSource.getTopics() return response.map { it.asModel() } } }
  6. シンプルな非同期処理のテストを書く 19 interface NetworkDataSource { suspend fun getTopics(): List<TopicResponse> }

    class TopicRepository(val networkDataSource: NetworkDataSource) { suspend fun getTopics(): List<Topic> { val response: List<TopicResponse> = networkDataSource.getTopics() return response.map { it.asModel() } } } APIレスポンスをアプリ内で使いやすいデー タに変換するsuspend関数
  7. シンプルな非同期処理のテストを書く 20 @Test fun getTopics() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val actual = topicRepository.getTopics() assertThat(actual).isEqualTo( listOf( Topic( "id", … ) }
  8. シンプルな非同期処理のテストを書く 21 @Test fun getTopics() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val actual = topicRepository.getTopics() assertThat(actual).isEqualTo( listOf( Topic( "id", … ) } runTestでテストコードを囲む
  9. スライドに出てくるコードの注意点 24 @Test fun getTopics() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val actual = topicRepository.getTopics() assertThat(actual).isEqualTo( listOf( Topic( "id", … ) } テスト用のデータ返却の実装等は別途 やっている前提で進めていきます
  10. Flowで変更を通知する実装のテストを書く 29 private val _topicList = MutableSharedFlow<List<Topic>>(replay = 1) val

    topicListFlow: SharedFlow<List<Topic>> = _topicList suspend fun refreshTopicList() { val response: List<TopicResponse> = networkDataSource.getTopics() _topicList.emit(response.map { it.asModel() }) }
  11. Flowで変更を通知する実装のテストを書く 30 private val _topicList = MutableSharedFlow<List<Topic>>(replay = 1) val

    topicListFlow: SharedFlow<List<Topic>> = _topicList suspend fun refreshTopicList() { val response: List<TopicResponse> = networkDataSource.getTopics() _topicList.emit(response.map { it.asModel() }) } APIの取得結果をFlowに流す 呼び出し元はFlowを通して変更を受け取る
  12. Flowで変更を通知する実装のテストを書く 31 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource, FakeTopicDao()) topicRepository.refreshTopicList() assertThat(topicRepository.topicListFlow.first().size).isEqualTo(1) }
  13. Flowで変更を通知する実装のテストを書く 32 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource, FakeTopicDao()) topicRepository.refreshTopicList() assertThat(topicRepository.topicListFlow.first().size).isEqualTo(1) } first()でemitされたデータを取得する
  14. Flowで変更を通知する実装のテストを書く(collect) 33 @Test fun refreshTopic() = runTest { // 1.

    変更を受け取るための箱 (MutableList)を用意する // 2. 変更を監視して、受け取った結果を Listに追加する // 3. テストしたいコードを実行 // 4. Listの中身を検証する }
  15. Flowで変更を通知する実装のテストを書く(collect) 34 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() // 1. 変更を保存するMutable List launch { topicRepository.topicListFlow.collect { // 2. 変更を監視して、受け取った結果を Listに追加する result.add(it) } } topicRepository.refreshTopicList() // 3. テストしたいコードを実行 assertThat(result.size).isEqualTo(1) // 4. Listの中身を検証する }
  16. Flowで変更を通知する実装のテストを書く(collect) 35 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } collectはsuspend関数なので完了する まで次の処理が進まない launchで別のコルーチンを開始して collectする
  17. Flowで変更を通知する実装のテストを書く(collect) 36 @Test fun refreshTopic() = runTest { // This

    : TestScope val topicRepository = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } runTestのレシーバはTestScope テスト用のCoroutine Scope実装なので、中 でlaunchやasyncの実行ができる
  18. Flowで変更を通知する実装のテストを書く(collect) 37 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) }
  19. runTest内のlaunchの振る舞い 38 @Test fun launch() = runTest { launch {

    println("Launch new Coroutine") } println("End of Test Body") }
  20. runTest内のlaunchの振る舞い 39 @Test fun launch() = runTest { launch {

    println("Launch new Coroutine") } println("End of Test Body") } 先 後
  21. runTest内のlaunchの振る舞い 40 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) }
  22. runTest内のlaunchの振る舞い 41 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } 先 後
  23. UnconfinedTestDispatcherを使ってlaunchする 44 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) }
  24. UnconfinedTestDispatcherを使ってlaunchする 45 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } TestScopeのプロパティのTestScheduler
  25. UnconfinedTestDispatcherを使ってlaunchする 46 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } TestSchedulerはコルーチンの実行タイミン グを管理している TestDispatcherはテストの中で複数作ること ができるが、TestSchedulerは1つのテストの 中で1つになるようにする
  26. UnconfinedTestDispatcherを使ってlaunchする 47 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } 後 先
  27. UnconfinedTestDispatcherを使ってlaunchする 48 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) }
  28. collectを終わらせてあげる 49 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() val job = launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) job.cancel() }
  29. collectを終わらせてあげる 50 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() val job = launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) job.cancel() }
  30. collectを終わらせてあげる 51 @Test fun refreshTopic() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() val job = launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) job.cancel() } runTestはScope内で起動されたCoroutineの完了を待つ 完了しない場合はテストがタイムアウトでコケる
  31. delayを入れたテストを書く 72 var isDownloading: Boolean = false suspend fun download(url:

    String) { if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } }
  32. delayを入れたテストを書く 73 var isDownloading: Boolean = false suspend fun download(url:

    String) { if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } } isDownloadingがtrueだったら処理をスキップ ➝ スキップされることをテストする
  33. delayを入れたテストを書く 74 var isDownloading: Boolean = false suspend fun download(url:

    String) { if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } } テスト用のDataSourceでこの部分をdelay させるようにする
  34. delayを入れたテストを書く 75 class SpyNetworkDataSource : NetworkDataSource { var downloadCallCount: Int

    = 0 override suspend fun download(url: String): Unit { delay(1000) downloadCallCount++ } } 1秒間遅延した状態をエミュレートするため、テスト 用のDataSourceでdelayを入れる
  35. delayを入れたテストを書く 76 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) }
  36. delayを入れたテストを書く 77 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } テストは実時間を1秒待つことなく実行が 完了する TestSchedulerが実時間ではなく、仮 想時間で実行を管理するため
  37. delayを入れたテストを書く 78 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) }
  38. delayを入れたテストを書く 79 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } 初期状態の仮想時間は0
  39. delayを入れたテストを書く 80 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } ①1つめのコルーチンがキューに積まれる
  40. delayを入れたテストを書く 81 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } ②2つめのコルーチンがキューに積まれる
  41. delayを入れたテストを書く 82 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } ③仮想時間を進めてキューに積まれたコルーチンを実行
  42. delayを入れたテストを書く 83 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } ④ 仮想時間が0のときにスケジュールされた1 つめのコルーチンが実行され、 isDownloadingがtrueになる delayにより1度停止し、1000ミリ秒の時点 で再開するようにスケジュールされる
  43. delayを入れたテストを書く 84 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } ④ 仮想時間が0のときにスケジュールされた1 つめのコルーチンを実行され、 isDownloadingがtrueになる delayにより1度停止し、1000ミリ秒の時点 で再開するようにスケジュールされる UnconfinedTestDispatcherを使った ときもdelayで中断するのは同様
  44. delayを入れたテストを書く 85 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } ⑤ 仮想時間が0のときにスケジュールされた2 つめのコルーチンを実行される isDownloadingがtrueのため、分岐に入ら ず処理が終了する
  45. delayを入れたテストを書く 86 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } ⑥ 仮想時間が1001ミリ秒進むと一時停止して いたコルーチンが再開する ダウンロード実行回数がインクリメントされ て、isDownloadingがfalseになる
  46. delayを入れたテストを書く 87 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } ⑦ダウンロード回数のassertが行われる
  47. delayを入れたテストを書く 88 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } runCurrent() advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) }
  48. delayを入れたテストを書く 89 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } runCurrent() advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } 現在の仮想時間にスケジュールされたコ ルーチンを実行 2つのコルーチンが起動し、1つめはdelayで 一時停止
  49. delayを入れたテストを書く 90 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } runCurrent() advanceTimeBy(1001L) assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } 一時停止していたコルーチンを再開
  50. delayを入れたテストを書く 91 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) }
  51. delayを入れたテストを書く 92 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } スケジューリングされているコルーチンがなく なるまで時間をすすめる
  52. delayを入れたテストを書く 93 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } スケジューリングされているコルーチンがなく なるまで時間をすすめる
  53. withContextでDispatcherが指定されたテストを書く 94 var isDownloading: Boolean = false suspend fun download(url:

    String) = withContext(Dispatchers.IO){ if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } }
  54. withContextでDispatcherが指定されたテストを書く 95 var isDownloading: Boolean = false suspend fun download(url:

    String) = withContext(Dispatchers.IO){ if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } }
  55. withContextでDispatcherが指定されたテストを書く 96 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } runTestはコルーチンの実行が他のディスパッ チャに移動する場合も動作するため テストは成功する
  56. withContextでDispatcherが指定されたテストを書く 97 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } 前のテストのように delayが入っていたら...?
  57. withContextでDispatcherが指定されたテストを書く 98 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) }
  58. withContextでDispatcherが指定されたテストを書く 99 @Test fun download() = runTest { val spyNetworkDataSource

    = StubNetworkDataSource() val repository = AssetRepository(spyNetworkDataSource) launch { repository.download("first") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } 同じTestSchedulerを共有する Disptacherでないと完了を待つことが できない
  59. withContextでDispatcherが指定されたテストを書く 100 class AssetRepository( val networkDataSource: NetworkDataSource, val ioDispatcher: CoroutineDispatcher,

    ) { var isDownloading: AtomicBoolean = AtomicBoolean(false) suspend fun download(url: String) = withContext(ioDispatcher) { .. } } Dispatcherを引数で受け取れるように する
  60. withContextでDispatcherが指定されたテストを書く 101 @Test fun download() = runTest { val repository

    = AssetRepository(networkDataSource, StandardTestDispatcher(testScheduler)) launch { repository.download("firstUrl") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) }
  61. withContextでDispatcherが指定されたテストを書く 102 @Test fun download() = runTest { val repository

    = AssetRepository(networkDataSource, StandardTestDispatcher(testScheduler)) launch { repository.download("firstUrl") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) }
  62. withContextでDispatcherが指定されたテストを書く 103 @Test fun download() = runTest { val repository

    = AssetRepository(networkDataSource, StandardTestDispatcher(testScheduler)) launch { repository.download("firstUrl") } launch { repository.download("second") } advanceUntilIdle() assertThat(spyNetworkDataSource.downloadCallCount).isEqualTo(1) } 同じTestSchedulerを共有する TestDispatcherを渡してあげる
  63. データレイヤのテストで紹介したこと • コルーチンのテストはrunTestで囲む • Flowはfirst()でemitされたデータを検証する • Flowをcollectしてテストするときは、UnconfinedTestDispatcherで新しいコルー チンを起動する • advanceUntilIdleやadvanceTimeByを使うことでコルーチンの実行をテストコー

    ドで管理でき、排他制御などのテストが楽にかける • runTest内(TestDispatcherで実行されるコルーチン)のdelayは仮想時間内で遅 延するだけなので、テストの実行時間に影響しない • Dispatcherは外から渡せるようにすることで、コルーチンの実行がコントロールし やすくなる 104
  64. UIレイヤ 107 UI elements State holders • アプリデータを画面に表示する役割で、ユー ザーの操作やAPIレスポンスなどの外部入 力によってデータの変更をUIに反映する

    • State holdersはUIに表示する状態(UI State)の保持と更新を管理 • UI elementsはユーザーイベントのState holderへの通知と、State holderがもつ Stateの内容のレンダリングを行う State holderをViewModelで実装している想定で 次のテストについて紹介 • suspend関数の呼び出し • UI Stateの更新(Flow / State)のテスト • UI Stateの中間状態を見る • リポジトリからFlowでの変更を受け取る
  65. suspend関数を呼び出す実装のテストを書く 108 class TopicViewModel constructor( private val userDataRepository: UserDataRepository, )

    : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } }
  66. suspend関数を呼び出す実装のテストを書く 109 class TopicViewModel constructor( private val userDataRepository: UserDataRepository, )

    : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } } viewModelScopeでコルーチンを起動して、リ ポジトリのsuspend関数を呼び出し
  67. suspend関数を呼び出す実装のテストを書く 110 @Test fun followTopicToggle() { val viewModel = TopicViewModel(userDataRepository)

    viewModel.followTopic("Id", true) assertThat(userDataRepository.currentUserData.followedTopics).isEqualTo(setOf("Id")) }
  68. suspend関数を呼び出す実装のテストを書く 111 @Test fun followTopicToggle() { val viewModel = TopicViewModel(userDataRepository)

    viewModel.followTopic("Id", true) assertThat(userDataRepository.currentUserData.followedTopics).isEqualTo(setOf("Id")) }
  69. suspend関数を呼び出す実装のテストを書く 112 @Test fun followTopicToggle() { val viewModel = TopicViewModel(userDataRepository)

    viewModel.followTopic("Id", true) assertThat(userDataRepository.currentUserData.followedTopics).isEqualTo(setOf("Id")) } viewModelScopeはMainディスパッチャ(Android のUIスレッド)でコルーチンを実行する Local TestはAndroidデバイスではなくローカル JVMで実行されるため、AndroidのUIスレッドがない = Mainディスパッチャを使えない
  70. Local TestでMainディスパッチャを置き換える 114 @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After

    fun tearDown() { Dispatchers.resetMain() } Mainディスパッチャをテストディスパッチャに置 き換えてくれる その他のディスパッチャと異なり、Mainディス パッチャはコンストラクタでの差し替えが難しい
  71. Local TestでMainディスパッチャを置き換える 115 @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After

    fun tearDown() { Dispatchers.resetMain() } TestDispatcherはStandardとUnconfinedの どちらを使う?
  72. Local TestでMainディスパッチャを置き換える 116 class TopicViewModel constructor( private val userDataRepository: UserDataRepository,

    ) : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } .. } }
  73. Local TestでMainディスパッチャを置き換える 117 class TopicViewModel constructor( private val userDataRepository: UserDataRepository,

    ) : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } .. } } アプリで動かしたときは Dispatchers.Main.immediateで実行
  74. Local TestでMainディスパッチャを置き換える 118 class TopicViewModel constructor( private val userDataRepository: UserDataRepository,

    ) : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } .. } } 後 先
  75. Local TestでMainディスパッチャを置き換える 119 class TopicViewModel constructor( private val userDataRepository: UserDataRepository,

    ) : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } .. } } 後 先 UnconfinedTestDispatcherを使うと 同じ実行順になる
  76. suspend関数を呼び出す実装のテストを書く 120 @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) } @After fun

    tearDown() { Dispatchers.resetMain() } @Test fun followTopicToggle() { val viewModel = TopicViewModel(userDataRepository) viewModel.followTopic("Id", true) assertThat(userDataRepository.currentUserData.followedTopics).isEqualTo(setOf("Id")) }
  77. Flowで公開されたUiStateのテストを書く 121 data class TopicUiState(val isFollowed: Boolean = false) val

    _uiState = MutableStateFlow(TopicUiState()) val uiState: StateFlow<TopicUiState> = _uiState.asStateFlow() fun followTopic(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(currentTopicId, followed) _uiState.update { it.copy(isFollowed = followed) } } }
  78. Flowで公開されたUiStateのテストを書く 122 data class TopicUiState(val isFollowed: Boolean = false) val

    _uiState = MutableStateFlow(TopicUiState()) val uiState: StateFlow<TopicUiState> = _uiState.asStateFlow() fun followTopic(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(currentTopicId, followed) _uiState.update { it.copy(isFollowed = followed) } } } StateFlowでUIStateを公開
  79. Flowで公開されたUiStateのテストを書く 123 data class TopicUiState(val isFollowed: Boolean = false) val

    _uiState = MutableStateFlow(TopicUiState()) val uiState: StateFlow<TopicUiState> = _uiState.asStateFlow() fun followTopic(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(currentTopicId, followed) _uiState.update { it.copy(isFollowed = followed) } } } ユーザーアクションにあわせて UiStateを更新
  80. Flowで公開されたUiStateのテストを書く(collect) 125 @Test fun uiState() = runTest { val stateList

    = mutableListOf<TopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( // isFollowed = false, isFollowed = true の順にUIStateが変化している ) job.cancel() }
  81. Flowで公開されたUiStateのテストを書く(collect) 126 @Test fun uiState() = runTest { val stateList

    = mutableListOf<TopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( // isFollowed = false, isFollowed = true の順にUIStateが変化している ) job.cancel() } runTestでコルーチンを起動できるようにして、 UnconfinedTestDispatcherでcollectする
  82. Flowで公開されたUiStateのテストを書く(collect) 127 @Test fun uiState() = runTest { val stateList

    = mutableListOf<TopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( // isFollowed = false, isFollowed = true の順にUIStateが変化している ) job.cancel() } TestSchedulerを渡さなくていいの?
  83. Flowで公開されたUiStateのテストを書く(collect) 128 @Test fun uiState() = runTest { val stateList

    = mutableListOf<TopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( // isFollowed = false, isFollowed = true の順にUIStateが変化している ) job.cancel() } Mainディスパッチャを置き換えると、それ以降の TestDispatchersは自動的に置き換えたディスパッチャの スケジューラを共有するので省略可
  84. Stateで公開されたUiStateのテストを書く 129 var uiState by mutableStateOf(TopicUiState()) private set fun followTopic(followed:

    Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) uiState = uiState.copy(isFollowed = followed) } }
  85. Stateで公開されたUiStateのテストを書く 130 var uiState by mutableStateOf(TopicUiState()) private set fun followTopic(followed:

    Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) uiState = uiState.copy(isFollowed = followed) } }
  86. Stateで公開されたUiStateのテストを書く(Snapshot) 132 @Test fun uiState() = runTest { val result

    = mutableListOf(viewModel.uiState) Snapshot.registerGlobalWriteObserver { if (it is MutableState<*> && it.value is TopicUiState) { result.add(it.value as TopicUiState) } } viewModel.followTopic(true) assertThat(result).isEqualTo( // isFollowed = false, isFollowed = trueの順に変わっているか見る ) }
  87. Stateで公開されたUiStateのテストを書く(Snapshot) 133 @Test fun uiState() = runTest { val result

    = mutableListOf(viewModel.uiState) Snapshot.registerGlobalWriteObserver { if (it is MutableState<*> && it.value is TopicUiState) { result.add(it.value as TopicUiState) } } viewModel.followTopic(true) assertThat(result).isEqualTo( // isFollowed = false, isFollowed = trueの順に変わっているか見る ) }
  88. Stateで公開されたUiStateのテストを書く(Snapshot) 134 @Test fun uiState() = runTest { val result

    = mutableListOf(viewModel.uiState) Snapshot.registerGlobalWriteObserver { if (it is MutableState<*> && it.value is TopicUiState) { result.add(it.value as TopicUiState) } } viewModel.followTopic(true) assertThat(result).isEqualTo( // isFollowed = false, isFollowed = trueの順に変わっているか見る ) } SnapshotはComposeのStateの変化を監視している 書き込みがあったときのObserverを追加して、変更を箱に入れていく
  89. Stateで公開されたUiStateのテストを書く(Snapshot) 135 @Test fun uiState() = runTest { val result

    = mutableListOf(viewModel.uiState) Snapshot.registerGlobalWriteObserver { if (it is MutableState<*> && it.value is TopicUiState) { result.add(it.value as TopicUiState) } } viewModel.followTopic(true) assertThat(result).isEqualTo( // isFollowed = false, isFollowed = trueの順に変わっているか見る ) } GlobalObserverは1つのStateのみを監視するものではなくAnyが返ってくるの で、変更を受け取りたいUiStateへのキャストをしてあげると安心
  90. Stateで公開されたUiStateのテストを書く(Snapshot) 136 @Test fun uiState() = runTest { val result

    = mutableListOf(viewModel.uiState) Snapshot.registerGlobalWriteObserver { if (it is MutableState<*> && it.value is TopicUiState) { result.add(it.value as TopicUiState) } } viewModel.followTopic(true) assertThat(result).isEqualTo( // isFollowed = false, isFollowed = trueの順に変わっているか見る ) } 初期値がObserverに流れてこないので、自分で設定してあげる
  91. UiStateの中間状態を見るテストを書く 138 data class TopicUiState( val isLoading: Boolean = false

    ) fun followTopic(followed: Boolean) { viewModelScope.launch { _uiStateFlow.update { it.copy(isLoading = true) } userDataRepository.toggleFollowedTopicId(topicId, followed) _uiStateFlow.update { it.copy(isLoading = false) } } }
  92. UiStateの中間状態を見るテストを書く 139 data class TopicUiState( val isLoading: Boolean = false

    ) fun followTopic(followed: Boolean) { viewModelScope.launch { _uiStateFlow.update { it.copy(isLoading = true) } userDataRepository.toggleFollowedTopicId(topicId, followed) _uiStateFlow.update { it.copy(isLoading = false) } } } 通信中開始時にisLoadingをtrueにして、終 了したらfalseにする 初期状態はfalse
  93. UiStateの中間状態を見るテストを書く 140 @Test fun uiState() = runTest { val stateList

    = mutableListOf<NewTopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiStateFlow.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( .. ) // isLoading = false -> true -> false job.cancel() }
  94. UiStateの中間状態を見るテストを書く 141 @Test fun uiState() = runTest { val stateList

    = mutableListOf<NewTopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiStateFlow.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( .. ) // isLoading = false -> true -> false job.cancel() }
  95. UiStateの中間状態を見るテストを書く 142 data class TopicUiState( val isLoading: Boolean = false

    ) fun followTopic(followed: Boolean) { viewModelScope.launch { _uiStateFlow.update { it.copy(isLoading = true) } userDataRepository.toggleFollowedTopicId(topicId, followed) _uiStateFlow.update { it.copy(isLoading = false) } } }
  96. UiStateの中間状態を見るテストを書く 143 data class TopicUiState( val isLoading: Boolean = false

    ) fun followTopic(followed: Boolean) { viewModelScope.launch { _uiStateFlow.update { it.copy(isLoading = true) } userDataRepository.toggleFollowedTopicId(topicId, followed) _uiStateFlow.update { it.copy(isLoading = false) } } } 実際に通信処理をしないようにテスト用のリポジトリを使うケースが多い テスト用のリポジトリでは処理を非同期にしないため、後続のisLoading = false まで同期的に行われる 初期値と変更がないため、1回目しかcollectで流れてこない
  97. UiStateの中間状態を見るテストを書く 144 class TestUserDataRepository : UserDataRepository { override suspend fun

    toggleFollowedTopicId(followedTopicId: String, followed: Boolean) { delay(100L) .. } } テスト用のリポジトリでdelayを差し込む これにより、通信完了後のisLoading = falseになる前に一度コルー チンが停止する
  98. UiStateの中間状態を見るテストを書く 145 class TestUserDataRepository : UserDataRepository { override suspend fun

    toggleFollowedTopicId(followedTopicId: String, followed: Boolean) { delay(100L) .. } } テスト用のリポジトリでdelayを差し込む これにより、通信完了後のisLoading = falseになる前に一度コルー チンが停止する
  99. UiStateの中間状態を見るテストを書く 146 @Test fun uiState() = runTest { val stateList

    = mutableListOf<NewTopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiStateFlow.collect { stateList.add(it) } } viewModel.followTopic(true) advanceUntilIdle() assertThat(stateList).isEqualTo( .. ) // isLoading = false -> true -> false job.cancel() } テストしたいメソッドを呼び出した時に isLoading = false -> isLoading = trueま でcollectできる
  100. UiStateの中間状態を見るテストを書く 147 @Test fun uiState() = runTest { val stateList

    = mutableListOf<NewTopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiStateFlow.collect { stateList.add(it) } } viewModel.followTopic(true) advanceUntilIdle() assertThat(stateList).isEqualTo( .. ) // isLoading = false -> true -> false job.cancel() } 停止していたコルーチンを再開する
  101. リポジトリからFlowの変更を受け取る 151 class TopicViewModel @Inject constructor(val userDataRepository: UserDataRepository ) :

    ViewModel() { val followedTopicIds: Flow<Set<String>> = userDataRepository.userDataStream.map{ .. } fun followTopicToggle(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } }
  102. リポジトリからFlowの変更を受け取る 152 class TopicViewModel @Inject constructor(val userDataRepository: UserDataRepository ) :

    ViewModel() { val followedTopicIds: Flow<Set<String>> = userDataRepository.userDataStream.map{ .. } fun followTopicToggle(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } } ここでリポジトリに更新をお願 いすると
  103. リポジトリからFlowの変更を受け取る 153 class TopicViewModel @Inject constructor(val userDataRepository: UserDataRepository ) :

    ViewModel() { val followedTopicIds: Flow<Set<String>> = userDataRepository.userDataStream.map{ .. } fun followTopicToggle(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } } こっちに変更が流れてくる
  104. UIレイヤ データホルダーのテストで紹介したこと • メインディスパッチャをUnconfinedTestDispatcherに置き換える • UI Stateは都度値を確認、もしくは変更を継続してみることでテストする • ComposeのStateの変更を継続して見るのにはSnapshotが使える •

    中間状態のUI Stateを見る場合は、delayを差し込むとテストがしやすい • リポジトリからFlowで変更を受け取る場合は、Fakeの実装を用意するか結合範囲 を変更するかを検討する 156
  105. UIレイヤ 158 UI elements State holders • アプリデータを画面に表示する役割で、ユー ザーの操作やAPIレスポンスなどの外部入 力によってデータの変更をUIに反映する

    • State holdersはUIに表示する状態(UI State)の保持と更新を管理 • UI elementsはユーザーイベントのState holderへの通知と、State holderがもつ Stateの内容のレンダリングを行う
  106. UIレイヤ 159 UI elements State holders • アプリデータを画面に表示する役割で、ユー ザーの操作やAPIレスポンスなどの外部入 力によってデータの変更をUIに反映する

    • State holdersはUIに表示する状態(UI State)の保持と更新を管理 • UI elementsはユーザーイベントのState holderへの通知と、State holderがもつ Stateの内容のレンダリングを行う
  107. UIテストの結合範囲のパターン 166 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

    Sources データレイヤ 空のActivityに、ViewModelに依存しないComposable関数を セットして起動する UI StateとUI elementsに着目したテストになり、レイヤをまたいで 処理されるユーザーイベントのテストはできない
  108. UIテストの結合範囲のパターン 168 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

    Sources データレイヤ 実際のActivityやFragmentを起動、もしくは空のActivityに ViewModelを持つComposable関数をセットして起動する テストの速度・安定性のためにデータレイヤーをテストダブルに置き換 えたいケースが多い
  109. UIテストの結合範囲のパターン 169 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

    Sources データレイヤ 実際のActivityやFragmentを起動、もしくは空のActivityに ViewModelを持つComposable関数をセットして起動する テストの速度・安定性のためにデータレイヤーをテストダブルに置き換 えたいケースが多い ➜ Dagger Hiltの出番
  110. 前準備(Instrumentation Test) 170 build.gradle dependencies { // for dagger hilt

    androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_ver" kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_ver" // for compose androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ver" }
  111. EarlyEntryPoint 174 // @Inject ← remove @Inject lateinit var workerFactory:

    HiltWorkerFactory override fun onCreate() { super.onCreate() workerFactory = EarlyEntryPoints.get(this, MyEarlyEntryPoint::class.java).workerFactory() } @EarlyEntryPoint @InstallIn(SingletonComponent::class) interface MyEarlyEntryPoint { fun workerFactory(): HiltWorkerFactory }
  112. 前準備(Instrumentation Test) 175 package com.example class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } }
  113. 前準備(Instrumentation Test) 176 package com.example class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } } CustomApplicationを指定した場合は HiltTestApplication_Application
  114. テストコードの全体像 178 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val

    hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } }
  115. Dagger hiltでリポジトリを差し替える 179 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0)

    val hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } } まずはHiltでテスト用のリポジトリのセットアップする
  116. Dagger hiltでリポジトリを差し替える 180 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0)

    val hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } } テストクラスごとのComponentの定義ファイルが生成される (XXXTest_HiltsComponents)
  117. Dagger hiltでリポジトリを差し替える 181 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0)

    val hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } } テスト実行時にテストクラスごとのComponentを生成する
  118. Dagger hiltでリポジトリを差し替える 182 @Module @InstallIn(SingletonComponent::class) interface DataModule { @Binds fun

    bindsUserDataRepository( userDataRepository: DefaultUserDataRepository ): UserDataRepository }
  119. Dagger hiltでリポジトリを差し替える 183 @Module @InstallIn(SingletonComponent::class) interface DataModule { @Binds fun

    bindsUserDataRepository( userDataRepository: DefaultUserDataRepository ): UserDataRepository } このModuleをテスト用のModuleに置き換える
  120. Dagger hiltでリポジトリを差し替える 186 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @Module @InstallIn(SingletonComponent::class)

    class TestDataModule { @Provides fun provide(): UserDataRepository = TestUserDataRepository() } … } DataModuleを削除する
  121. Dagger hiltでリポジトリを差し替える 187 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @Module @InstallIn(SingletonComponent::class)

    class TestDataModule { @Provides fun provide(): UserDataRepository = TestUserDataRepository() } … } テスト用の実装に置き換えた Moduleを定義して追加する
  122. Dagger hiltでリポジトリを差し替える 188 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @Module @InstallIn(SingletonComponent::class)

    class TestDataModule { @Provides fun provide(): UserDataRepository = TestUserDataRepository() } … } 削除したModuleに定義されていた ものは全て定義する必要あり
  123. Dagger hiltでリポジトリを差し替える 190 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @JvmField @BindValue

    val userDataRepository: UserDataRepository = TestUserDataRepository() … } @BindValueを使うとテストから参照したいと きに便利 Moduleに定義されていない依存を差し替え るときは@BindValueだけでもOK
  124. Dagger hiltでリポジトリを差し替える 191 @Module @TestInstallIn( components = [SingletonComponent::class], replaces =

    [DataModule::class] ) interface TestDataModule { @Provide fun provide(): UserDataRepository = TestUserDataRepository }
  125. Dagger hiltでリポジトリを差し替える 192 @Module @TestInstallIn( components = [SingletonComponent::class], replaces =

    [DataModule::class] ) interface TestDataModule { @Provide fun provide(): UserDataRepository = TestUserDataRepository } トップレベルに定義することで すべてのテストでModuleを差し替える
  126. Dagger hiltでリポジトリを差し替える 193 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0)

    val hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } }
  127. ComposeをセットするActivityを起動する 194 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val

    hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } }
  128. ComposeをセットするActivityを起動する 195 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val

    hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } } HiltAndroidRuleの後に適用されるようにする
  129. ComposeをセットするActivityを起動する 196 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val

    hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } } ComposeのテストをしやすくするためのRule
  130. ComposeをセットするActivityを起動する 197 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val

    hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } } 画面起動時にHiltで依存解決させるためには @AndroidEntryPointがついたActivityである必 要がある
  131. ComposeをセットするActivityを起動する 198 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val

    hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } } 実際のActivityを使うか、テスト用のActivityを別 途用意してsrc/debugに配置する
  132. Composeをセットする 202 @HiltViewModel class TopicViewModel @Inject constructor( .. ): ViewModel

    @Composable fun TopicRoute( modifier: Modifier = Modifier, viewModel: TopicViewModel = hiltViewModel() ) { .. }
  133. Composeをセットする 203 @HiltViewModel class TopicViewModel @Inject constructor( .. ): ViewModel

    @Composable fun TopicRoute( modifier: Modifier = Modifier, viewModel: TopicViewModel = hiltViewModel() ) { .. } 今回テストしたいComposable関数 自動的にHiltによって依存解決されるので テスト用のリポジトリを参照するようになっている
  134. Composeをセットする 204 @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test

    fun test() { composeRule.setContent { TopicRoute() } // UIテストを実装していく }
  135. Composeをセットする 205 @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test

    fun test() { composeRule.setContent { TopicRoute() } // UIテストを実装していく } ComposeTestRuleによって、テスト開始時には Activityが起動している
  136. Composeをセットする 206 @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test

    fun test() { composeRule.setContent { TopicRoute() } // UIテストを実装していく } 任意のComposable関数を設定する
  137. Composeをセットする 207 @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test

    fun test() { composeRule.setContent { TopicRoute() } // UIテストを実装していく } デフォルト引数でViewModelのインスタンスも生成 されており、テスト用のリポジトリを参照するように なっている
  138. テストの全体像 208 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val

    hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } }
  139. Dispatcherの差し替え 209 • UIテストにおいて非同期処理の待ち合わせは難易度の高いポイントの1つ ◦ 同期的に実行される場合はあまり気にしなくてOK • クラスの中で直接Dispatcherを指定していると、自動で待ち合わせをするのが難し くなる ◦

    ComposeTestRule#waitUntilの待ち合わせが必要になる • 自動でコルーチンを待ち合わせする手段 ◦ DispatcherをUnconfinedDispatcherに置き換える ◦ 実行を監視できるExecutorからDispatcherを作りIdlingResourceで待ち合 わせ
  140. Dispatcherの差し替え 210 • UIテストにおいて非同期処理の待ち合わせは難易度の高いポイントの1つ ◦ 同期的に実行される場合はあまり気にしなくてOK • クラスの中で直接Dispatcherを指定していると、自動で待ち合わせをするのが難し くなる ◦

    ComposeTestRule#waitUntilの待ち合わせが必要になる • 自動でコルーチンを待ち合わせする手段 ◦ DispatcherをUnconfinedDispatcherに置き換える ◦ 実行を監視できるExecutorからDispatcherを作りIdlingResourceで待ち合 わせ
  141. Dispatcherの差し替え 211 @Qualifier @Retention(RUNTIME) annotation class IODispatcher class DefaultTopicsRepository @Inject

    constructor( @IODispatcher private val ioDispatcher: CoroutineDispatcher, ) : TopicsRepository {
  142. Dispatcherの差し替え 212 @Qualifier @Retention(RUNTIME) annotation class IODispatcher class DefaultTopicsRepository @Inject

    constructor( @IODispatcher private val ioDispatcher: CoroutineDispatcher, ) : TopicsRepository { コンストラクタでIO Dispatcherを差し替えら れるようにする
  143. Dispatcherの差し替え 213 @Module @InstallIn(SingletonComponent::class) object DispatchersModule { @Provides @IODispatcher fun

    providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO } IO DispatcherをProvideするModule
  144. Dispatcherの差し替え 214 @HiltAndroidTest @UninstallModules(DispatchersModule::class) class Test { val testDispatcher =

    UnconfinedTestDispatcher() @BindValue @IODispatcher val ioDispatcher: CoroutineDispatcher = testDispatcher .. }
  145. Dispatcherの差し替え 215 @HiltAndroidTest @UninstallModules(DispatchersModule::class) class Test { val testDispatcher =

    UnconfinedTestDispatcher() @BindValue @IODispatcher val ioDispatcher: CoroutineDispatcher = testDispatcher .. } テスト側でDispatcherを差し替え (@TestInstallInでもOK)
  146. UIレイヤ UI elementsのテストで紹介したこと • Dagger Hiltをつかって一部の依存を差し替えるためには、@HiltAndroidTestと HiltAndroidRuleを使う • 依存の差し替えにはModuleを削除してTest用のModuleを追加する •

    Compose用のRuleには@AndroidEntryPointがついたActivityを設定する • ComposeTestRule#setContentで任意のComposabel関数を設定する • hiltViewModel()でViewModelにインスタンスを作っていれば、自動的にHiltに よってテスト用の依存に置き換わっている • DispacherはHiltで差し替えられるようになっていると、自動待ち合わせが実現し やすい 216
  147. データレイヤのテストで紹介したこと • コルーチンのテストはrunTestで囲む • Flowはfirst()でemitされたデータを検証する • Flowをcollectしてテストするときは、UnconfinedTestDispatcherで新しいコルー チンを起動する • advanceUntilIdleやadvanceTimeByを使うことでコルーチンの実行をテストコー

    ドで管理でき、排他制御などのテストが楽にかける • runTest内(TestDispatcherで実行されるコルーチン)のdelayは仮想時間内で遅 延するだけなので、テストの実行時間に影響しない • Dispatcherは外から渡せるようにすることで、コルーチンの実行がコントロールし やすくなる 218
  148. UIレイヤ UI elementsのテストで紹介したこと • Dagger Hiltをつかって一部の依存を差し替えるためには、@HiltAndroidTestと HiltAndroidRuleを使う • 依存の差し替えにはModuleを削除してTest用のModuleを追加する •

    Compose用のRuleには@AndroidEntryPointがついたActivityを設定する • ComposeTestRule#setContentで任意のComposabel関数を設定する • hiltViewModel()でViewModelにインスタンスを作っていれば、自動的にHiltに よってテスト用の依存に置き換わっている • DispacherはHiltで差し替えられるようになっていると、自動待ち合わせが実現し やすい 220
  149. 参考リンク • Androidでのコルーチンのテスト ◦ https://developer.android.com/kotlin/coroutines/test • AndroidでのKotin Flowのテスト ◦ https://developer.android.com/kotlin/flow/test

    • Hiltテストガイド ◦ https://developer.android.com/training/dependency-injection/hilt-testing • Now in android ◦ https://github.com/android/nowinandroid 221