$30 off During Our Annual Pro Sale. View Details »

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

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のモダンな技術選択 にあわせて自動テストも アップデートしよう Nozomi Takuma 1

  2. 自己紹介 • Nozomi Takuma • 株式会社ディー・エヌ・エー SWET第二グループ所属 ◦ Pococha事業部システム部兼務 •

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

    ◦ Jetpack Compose ◦ Dagger Hilt • 各技術のテスト方法をGuide to app architectureで推奨されるレイヤ毎(UIレイヤ ・Dataレイヤ)に整理 • 各技術を採用したプロダクトでテストを書き始めるときに、テストの実装イメージが 掴めて、何から始めればいいかがわかるようになっていることがゴール 3
  4. Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI

    elementsのテストを書く 01 02 03 04 4
  5. Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI

    elementsのテストを書く 01 02 03 04 5
  6. Guide to app architectureに登場するレイヤ 6 ドメインレイヤ(Optional) UI elements State holders

    UIレイヤ Repositories Data Sources データレイヤ
  7. 本セッションで登場する技術 • Kotlin Coroutine / Kotlin Flow(1.6) • Jetpack Compose

    • Dagger Hilt 7
  8. レイヤと各技術の関係 8 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

    Sources データレイヤ Kotlin Coroutine /Flow
  9. レイヤと各技術の関係 9 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

    Sources データレイヤ Jetpack Compose
  10. レイヤと各技術の関係 10 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

    Sources データレイヤ Dagger Hilt
  11. 本セッションでテストの書き方を紹介するレイヤ 11 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

    Sources データレイヤ
  12. Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI

    elementsのテストを書く 01 02 03 04 12
  13. データレイヤ • アプリデータと、アプリデータの作成・保存・ 変更方法を決定するビジネスロジックを持つ • データソースクラスはファイル・API・ローカ ルDB等、1 つのデータソースのみを処理す る役割 •

    リポジトリクラスはデータレイヤのエントリー ポイントとなり、データソースを抽象化する 13 Repositories Data Sources
  14. データレイヤ • アプリデータと、アプリデータの作成・保存・ 変更方法を決定するビジネスロジックを持つ • データソースクラスはファイル・API・ローカ ルDB等、1 つのデータソースのみを処理す る役割 •

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

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

    リポジトリクラスはデータレイヤのエントリー ポイントとなり、データソースを抽象化する 16 Repositories Data Sources Retrofitで生成されるServiceやRoomのDaoが含まれる それらのテスト方法はセッションの本筋からずれるため除外
  17. 前準備 17 build.gradle dependencies { testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" }

  18. シンプルな非同期処理のテストを書く 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() } } }
  19. シンプルな非同期処理のテストを書く 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関数
  20. シンプルな非同期処理のテストを書く 20 @Test fun getTopics() = runTest { val topicRepository

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

    = TopicRepository(stubNetworkDataSource) val actual = topicRepository.getTopics() assertThat(actual).isEqualTo( listOf( Topic( "id", … ) } runTestでテストコードを囲む
  22. runTest • テスト用に新しいコルーチンを開始するコルーチンビルダー • テストコードをラップすることで、コルーチンの中でテストコードを実行する • 振る舞いはrunBlockingと似ているが、delayによる実時間の遅延がスキップされ る(後述) • コルーチンの実行が他のディスパッチャに移動する場合も動作する(後述)

    22
  23. スライドに出てくるコードの注意点 24 @Test fun getTopics() = runTest { val topicRepository

    = TopicRepository(stubNetworkDataSource) val actual = topicRepository.getTopics() assertThat(actual).isEqualTo( listOf( Topic( "id", … ) } テスト用のデータ返却の実装等は別途 やっている前提で進めていきます
  24. 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() }) }
  25. 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を通して変更を受け取る
  26. Flowで変更を通知する実装のテストを書く 31 @Test fun refreshTopic() = runTest { val topicRepository

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

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

    変更を受け取るための箱 (MutableList)を用意する // 2. 変更を監視して、受け取った結果を Listに追加する // 3. テストしたいコードを実行 // 4. Listの中身を検証する }
  29. 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の中身を検証する }
  30. 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する
  31. 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の実行ができる
  32. 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) }
  33. runTest内のlaunchの振る舞い 38 @Test fun launch() = runTest { launch {

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

    println("Launch new Coroutine") } println("End of Test Body") } 先 後
  35. 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) }
  36. 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) } 先 後
  37. TestDispatcher テスト用のCoroutineDisptacherは2種類ある ① StandardTestDispatcher • コルーチンはキューに追加し、テストスレッドが空いているときに実行する • runTestはデフォルトでStandardTestDispatcherを使う ② UnconfinedTestDispatcher

    • コルーチンをすぐに実行する 42
  38. TestDispatcher テスト用のCoroutineDisptacherは2種類ある ① StandardTestDispatcher • コルーチンはキューに追加し、テストスレッドが空いているときに実行する • runTestはデフォルトでStandardTestDispatcherを使う ② UnconfinedTestDispatcher

    • コルーチンをすぐに実行する 43 こっちのDipatcherを使う
  39. 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) }
  40. 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
  41. 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つになるようにする
  42. 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) } 後 先
  43. 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) }
  44. 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() }
  45. 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() }
  46. 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の完了を待つ 完了しない場合はテストがタイムアウトでコケる
  47. delayを入れたテストを書く 72 var isDownloading: Boolean = false suspend fun download(url:

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

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

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

    = 0 override suspend fun download(url: String): Unit { delay(1000) downloadCallCount++ } } 1秒間遅延した状態をエミュレートするため、テスト 用のDataSourceでdelayを入れる
  51. 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) }
  52. 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が実時間ではなく、仮 想時間で実行を管理するため
  53. 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) }
  54. 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
  55. 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つめのコルーチンがキューに積まれる
  56. 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つめのコルーチンがキューに積まれる
  57. 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) } ③仮想時間を進めてキューに積まれたコルーチンを実行
  58. 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ミリ秒の時点 で再開するようにスケジュールされる
  59. 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で中断するのは同様
  60. 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のため、分岐に入ら ず処理が終了する
  61. 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になる
  62. 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が行われる
  63. 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) }
  64. 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で 一時停止
  65. 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) } 一時停止していたコルーチンを再開
  66. 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) }
  67. 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) } スケジューリングされているコルーチンがなく なるまで時間をすすめる
  68. 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) } スケジューリングされているコルーチンがなく なるまで時間をすすめる
  69. withContextでDispatcherが指定されたテストを書く 94 var isDownloading: Boolean = false suspend fun download(url:

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

    String) = withContext(Dispatchers.IO){ if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } }
  71. 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はコルーチンの実行が他のディスパッ チャに移動する場合も動作するため テストは成功する
  72. 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が入っていたら...?
  73. 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) }
  74. 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でないと完了を待つことが できない
  75. withContextでDispatcherが指定されたテストを書く 100 class AssetRepository( val networkDataSource: NetworkDataSource, val ioDispatcher: CoroutineDispatcher,

    ) { var isDownloading: AtomicBoolean = AtomicBoolean(false) suspend fun download(url: String) = withContext(ioDispatcher) { .. } } Dispatcherを引数で受け取れるように する
  76. 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) }
  77. 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) }
  78. 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を渡してあげる
  79. データレイヤのテストで紹介したこと • コルーチンのテストはrunTestで囲む • Flowはfirst()でemitされたデータを検証する • Flowをcollectしてテストするときは、UnconfinedTestDispatcherで新しいコルー チンを起動する • advanceUntilIdleやadvanceTimeByを使うことでコルーチンの実行をテストコー

    ドで管理でき、排他制御などのテストが楽にかける • runTest内(TestDispatcherで実行されるコルーチン)のdelayは仮想時間内で遅 延するだけなので、テストの実行時間に影響しない • Dispatcherは外から渡せるようにすることで、コルーチンの実行がコントロールし やすくなる 104
  80. Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI

    elementsのテストを書く 01 02 03 04 105
  81. UIレイヤ • アプリデータを画面に表示する役割で、ユー ザーの操作やAPIレスポンスなどの外部入 力によるデータの変更をUIに反映する • State holdersはUIに表示する状態(UI State)の保持と更新を管理 •

    UI elementsはユーザーイベントのState holderへの通知と、State holderがもつ Stateの内容のレンダリングを行う 106 UI elements State holders
  82. 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での変更を受け取る
  83. suspend関数を呼び出す実装のテストを書く 108 class TopicViewModel constructor( private val userDataRepository: UserDataRepository, )

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

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

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

    viewModel.followTopic("Id", true) assertThat(userDataRepository.currentUserData.followedTopics).isEqualTo(setOf("Id")) }
  87. 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ディスパッチャを使えない
  88. Local TestでMainディスパッチャを置き換える 113 @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After

    fun tearDown() { Dispatchers.resetMain() }
  89. Local TestでMainディスパッチャを置き換える 114 @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After

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

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

    ) : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } .. } }
  92. 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で実行
  93. Local TestでMainディスパッチャを置き換える 118 class TopicViewModel constructor( private val userDataRepository: UserDataRepository,

    ) : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } .. } } 後 先
  94. 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を使うと 同じ実行順になる
  95. 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")) }
  96. 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) } } }
  97. 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を公開
  98. 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を更新
  99. Flowで公開されたUiStateのテストを書く 124 @Test fun uiState() { assertThat(viewModel.uiState.value).isEqualTo(TopicUiState(isFollowed = false)) viewModel.followTopic(true)

    assertThat(viewModel.uiState.value).isEqualTo(TopicUiState(isFollowed = true)) }
  100. 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() }
  101. 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する
  102. 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を渡さなくていいの?
  103. 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は自動的に置き換えたディスパッチャの スケジューラを共有するので省略可
  104. 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) } }
  105. 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) } }
  106. Stateで公開されたUiStateのテストを書く 131 @Test fun uiState() { assertThat(viewModel.uiState).isEqualTo(TopicUiState(isFollowed = false)) viewModel.followTopic(true)

    assertThat(viewModel.uiState).isEqualTo(TopicUiState(isFollowed = true)) }
  107. 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の順に変わっているか見る ) }
  108. 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の順に変わっているか見る ) }
  109. 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を追加して、変更を箱に入れていく
  110. 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へのキャストをしてあげると安心
  111. 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に流れてこないので、自分で設定してあげる
  112. ComposeのSnapshotについて詳しく 「ComposeのSnapshot」Kenji Abe https://star-zero.medium.com/compose%E3%81%AEsnapshot-17414888b41b 137

  113. 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) } } }
  114. 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
  115. 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() }
  116. 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() }
  117. 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) } } }
  118. 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で流れてこない
  119. UiStateの中間状態を見るテストを書く 144 class TestUserDataRepository : UserDataRepository { override suspend fun

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

    toggleFollowedTopicId(followedTopicId: String, followed: Boolean) { delay(100L) .. } } テスト用のリポジトリでdelayを差し込む これにより、通信完了後のisLoading = falseになる前に一度コルー チンが停止する
  121. 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できる
  122. 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() } 停止していたコルーチンを再開する
  123. UiStateの中間状態を見るテストを書く 148 assertThat(viewModel.uiStateFlow.value).isEqualTo( TopicUiState(isLoading = false)) viewModel.followTopic(true) assertThat(viewModel.uiStateFlow.value).isEqualTo( TopicUiState(isLoading =

    true)) advanceUntilIdle() assertThat(viewModel.uiStateFlow.value).isEqualTo( TopicUiState(isLoading = false))
  124. リポジトリから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) } } }
  125. リポジトリから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) } } } ここでリポジトリに更新をお願 いすると
  126. リポジトリから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) } } } こっちに変更が流れてくる
  127. リポジトリからFlowの変更を受け取る • テスト自体はUiStateの変化を見ればOK • テスト用のリポジトリにモックライブラリを使っている場合は注意 ◦ モックライブラリは差し替えたいクラスの空の実装を作り、任意の値を返すよう に設定したり、呼び出しを記録する用途で使うことが多い ◦ Flowを返す場合は、テスト側でFlowのインスタンスを設定する

    ◦ 保存をしたらFlowに値を流すというのを自分で設定しないと値は流れてこない 154
  128. リポジトリからFlowの変更を受け取る • リポジトリのFake実装を自分で用意し保存時にFlowにemitするようにする ◦ リポジトリのInterfaceがきちんと定義されていれば実装しやすい • リポジトリは実クラスを使い、DataSourceを差し替える ◦ RetrofitのServiceを差し替える ◦

    RoomのIn Memory DB(RobolectricかInstrumentation Testになる) • ViewModelでは保存と更新を分けてテストする(保存 + 更新はリポジトリのテスト で見る) 155
  129. UIレイヤ データホルダーのテストで紹介したこと • メインディスパッチャをUnconfinedTestDispatcherに置き換える • UI Stateは都度値を確認、もしくは変更を継続してみることでテストする • ComposeのStateの変更を継続して見るのにはSnapshotが使える •

    中間状態のUI Stateを見る場合は、delayを差し込むとテストがしやすい • リポジトリからFlowで変更を受け取る場合は、Fakeの実装を用意するか結合範囲 を変更するかを検討する 156
  130. Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI

    elementsのテストを書く 01 02 03 04 157
  131. UIレイヤ 158 UI elements State holders • アプリデータを画面に表示する役割で、ユー ザーの操作やAPIレスポンスなどの外部入 力によってデータの変更をUIに反映する

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

    • State holdersはUIに表示する状態(UI State)の保持と更新を管理 • UI elementsはユーザーイベントのState holderへの通知と、State holderがもつ Stateの内容のレンダリングを行う
  133. ComposeのUIテストを実装する 162 • テストでActivityやFragmentを起動して、Composable関数をテストする • ユーザーイベントの結果、UIがどのように変更されるかを自動テストできる • 開発中画面のレンダリング結果を目視で確認する必要はあるため、Previewやス クリーンショットもあわせて使う •

    セットアップコストはPreviewよりもずっと高い
  134. ComposeのUIテストを実装する 163 • テストでActivityやFragmentを起動して、Composable関数をテストする • ユーザーイベントの結果、UIがどのように変更されるかを自動テストできる • 開発中画面のレンダリング結果を目視で確認する必要はあるため、Previewやス クリーンショットもあわせて使う •

    セットアップコストはPreviewよりもずっと高い UIテストのセットアップについて紹介 • Dagger Hilt • Jetpack Compose
  135. UIテストの結合範囲のパターン 164 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

    Sources データレイヤ
  136. UIテストの結合範囲のパターン 165 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

    Sources データレイヤ
  137. UIテストの結合範囲のパターン 166 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

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

    Sources データレイヤ
  139. UIテストの結合範囲のパターン 168 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data

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

    Sources データレイヤ 実際のActivityやFragmentを起動、もしくは空のActivityに ViewModelを持つComposable関数をセットして起動する テストの速度・安定性のためにデータレイヤーをテストダブルに置き換 えたいケースが多い ➜ Dagger Hiltの出番
  141. 前準備(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" }
  142. テスト時に使えるApplicationの制限(Hilt) 171 • @HiltAndroidAppがついたApplicationは利用できない ◦ つまりプロダクションコードのApplicationは利用できない ◦ 基本的にはHiltTestApplicationを使う ◦ カスタムAppliationを使いたい場合は@CustomTestApplicationで生成する

    • @InjectフィールドがあるApplicationは利用できない ◦ EarlyEntryPointを使う
  143. @CustomTestApplication 172 @CustomTestApplication(BaseApplication::class) interface HiltTestApplication BaseApplicationを継承したHiltTestApplication_Applicationが生 成される

  144. EarlyEntryPoint 173 • InstrumentationTestでHiltを使うと、Componentの生成サイクルが通常のアプ リ起動時とは異なる • InstrumentationTestではApplicationはテスト開始時に1度だけ作られる • ただし、SingletonComponentはテストごとに生成される •

    Application.onCreateの時点ではSingleton Componentがない • EarlyEntryPointで定義したSingleton ComponentはHiltTestApplicationの onCreateのタイミングで生成される
  145. 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 }
  146. 前準備(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) } }
  147. 前準備(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
  148. 前準備(Instrumentation Test) 177 build.gradle android { defaultConfig { testInstrumentationRunner =

    "com.example.CustomTestRunner" } }
  149. テストコードの全体像 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関数をセット } } }
  150. 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でテスト用のリポジトリのセットアップする
  151. 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)
  152. 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を生成する
  153. Dagger hiltでリポジトリを差し替える 182 @Module @InstallIn(SingletonComponent::class) interface DataModule { @Binds fun

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

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

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

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

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

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

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

    val userDataRepository: UserDataRepository = TestUserDataRepository() … }
  161. Dagger hiltでリポジトリを差し替える 190 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @JvmField @BindValue

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

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

    [DataModule::class] ) interface TestDataModule { @Provide fun provide(): UserDataRepository = TestUserDataRepository } トップレベルに定義することで すべてのテストでModuleを差し替える
  164. 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関数をセット } } }
  165. 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関数をセット } } }
  166. 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の後に適用されるようにする
  167. 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
  168. 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である必 要がある
  169. 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に配置する
  170. ComposeをセットするActivityを起動する 199 @AndroidEntryPoint class HiltTestActivity : ComponentActivity()

  171. ComposeをセットするActivityを起動する 200 @AndroidEntryPoint class HiltTestActivity : ComponentActivity() debugのAndroidManifestにも追加する

  172. ComposeをセットするActivityを起動する 201 @AndroidEntryPoint class HiltTestActivity : ComponentActivity() Hiltで依存解決されるFragmentをテストしたいと きにも使える (Fragment

    Scenarioで提供されるActivityは @AndroidEntryPointはついていない)
  173. Composeをセットする 202 @HiltViewModel class TopicViewModel @Inject constructor( .. ): ViewModel

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

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

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

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

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

    fun test() { composeRule.setContent { TopicRoute() } // UIテストを実装していく } デフォルト引数でViewModelのインスタンスも生成 されており、テスト用のリポジトリを参照するように なっている
  179. テストの全体像 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関数をセット } } }
  180. Dispatcherの差し替え 209 • UIテストにおいて非同期処理の待ち合わせは難易度の高いポイントの1つ ◦ 同期的に実行される場合はあまり気にしなくてOK • クラスの中で直接Dispatcherを指定していると、自動で待ち合わせをするのが難し くなる ◦

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

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

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

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

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

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

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

    Compose用のRuleには@AndroidEntryPointがついたActivityを設定する • ComposeTestRule#setContentで任意のComposabel関数を設定する • hiltViewModel()でViewModelにインスタンスを作っていれば、自動的にHiltに よってテスト用の依存に置き換わっている • DispacherはHiltで差し替えられるようになっていると、自動待ち合わせが実現し やすい 216
  188. まとめ Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI

    elementsのテストを書く 01 02 03 04 217
  189. データレイヤのテストで紹介したこと • コルーチンのテストはrunTestで囲む • Flowはfirst()でemitされたデータを検証する • Flowをcollectしてテストするときは、UnconfinedTestDispatcherで新しいコルー チンを起動する • advanceUntilIdleやadvanceTimeByを使うことでコルーチンの実行をテストコー

    ドで管理でき、排他制御などのテストが楽にかける • runTest内(TestDispatcherで実行されるコルーチン)のdelayは仮想時間内で遅 延するだけなので、テストの実行時間に影響しない • Dispatcherは外から渡せるようにすることで、コルーチンの実行がコントロールし やすくなる 218
  190. UIレイヤ データホルダーのテストで紹介したこと • メインディスパッチャをUnconfinedTestDispatcherに置き換える • UI Stateは都度値を確認、もしくは変更を継続してみることでテストする • ComposeのStateの変更を継続して見るのにはSnapshotが使える •

    中間状態のUI Stateを見る場合は、delayを差し込むとテストがしやすい • リポジトリからFlowで変更を受け取る場合は、Fakeの実装があるとテストがしやす い 219
  191. UIレイヤ UI elementsのテストで紹介したこと • Dagger Hiltをつかって一部の依存を差し替えるためには、@HiltAndroidTestと HiltAndroidRuleを使う • 依存の差し替えにはModuleを削除してTest用のModuleを追加する •

    Compose用のRuleには@AndroidEntryPointがついたActivityを設定する • ComposeTestRule#setContentで任意のComposabel関数を設定する • hiltViewModel()でViewModelにインスタンスを作っていれば、自動的にHiltに よってテスト用の依存に置き換わっている • DispacherはHiltで差し替えられるようになっていると、自動待ち合わせが実現し やすい 220
  192. 参考リンク • 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
  193. 222 ご静聴ありがとうございました!