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

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

    View Slide

  2. 自己紹介
    ● Nozomi Takuma
    ● 株式会社ディー・エヌ・エー SWET第二グループ所属
    ○ Pococha事業部システム部兼務
    ● Androidとテストが好き
    2

    View Slide

  3. 本セッションで話すこと
    ● Androidアプリ開発において新しく採用されるケースが多い、次の技術のテスト方
    法を紹介
    ○ Kotlin Coroutine / Kotlin Flow(1.6)
    ○ Jetpack Compose
    ○ Dagger Hilt
    ● 各技術のテスト方法をGuide to app architectureで推奨されるレイヤ毎(UIレイヤ
    ・Dataレイヤ)に整理
    ● 各技術を採用したプロダクトでテストを書き始めるときに、テストの実装イメージが
    掴めて、何から始めればいいかがわかるようになっていることがゴール
    3

    View Slide

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

    View Slide

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

    View Slide

  6. Guide to app architectureに登場するレイヤ
    6
    ドメインレイヤ(Optional)
    UI elements
    State holders
    UIレイヤ
    Repositories
    Data Sources
    データレイヤ

    View Slide

  7. 本セッションで登場する技術
    ● Kotlin Coroutine / Kotlin Flow(1.6)
    ● Jetpack Compose
    ● Dagger Hilt
    7

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  16. データレイヤ
    ● アプリデータと、アプリデータの作成・保存・
    変更方法を決定するビジネスロジックを持つ
    ● データソースクラスはファイル・API・ローカ
    ルDB等、1 つのデータソースのみを処理す
    る役割
    ● リポジトリクラスはデータレイヤのエントリー
    ポイントとなり、データソースを抽象化する
    16
    Repositories
    Data Sources
    Retrofitで生成されるServiceやRoomのDaoが含まれる
    それらのテスト方法はセッションの本筋からずれるため除外

    View Slide

  17. 前準備
    17
    build.gradle
    dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
    }

    View Slide

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

    View Slide

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

    View Slide

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

    )
    }

    View Slide

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

    )
    }
    runTestでテストコードを囲む

    View Slide

  22. runTest
    ● テスト用に新しいコルーチンを開始するコルーチンビルダー
    ● テストコードをラップすることで、コルーチンの中でテストコードを実行する
    ● 振る舞いはrunBlockingと似ているが、delayによる実時間の遅延がスキップされ
    る(後述)
    ● コルーチンの実行が他のディスパッチャに移動する場合も動作する(後述)
    22

    View Slide

  23. スライドに出てくるコードの注意点
    24
    @Test
    fun getTopics() = runTest {
    val topicRepository = TopicRepository(stubNetworkDataSource)
    val actual = topicRepository.getTopics()
    assertThat(actual).isEqualTo(
    listOf(
    Topic(
    "id",

    )
    }
    テスト用のデータ返却の実装等は別途
    やっている前提で進めていきます

    View Slide

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

    View Slide

  25. Flowで変更を通知する実装のテストを書く
    30
    private val _topicList = MutableSharedFlow>(replay = 1)
    val topicListFlow: SharedFlow> = _topicList
    suspend fun refreshTopicList() {
    val response: List = networkDataSource.getTopics()
    _topicList.emit(response.map { it.asModel() })
    }
    APIの取得結果をFlowに流す
    呼び出し元はFlowを通して変更を受け取る

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  32. Flowで変更を通知する実装のテストを書く(collect)
    37
    @Test fun refreshTopic() = runTest {
    val topicRepository = TopicRepository(stubNetworkDataSource)
    val result = mutableListOf>()
    launch {
    topicRepository.topicListFlow.collect {
    result.add(it)
    }
    }
    topicRepository.refreshTopicList()
    assertThat(result.size).isEqualTo(1)
    }

    View Slide

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

    View Slide

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


    View Slide

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

    View Slide

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


    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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


    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  46. collectを終わらせてあげる
    51
    @Test fun refreshTopic() = runTest {
    val topicRepository = TopicRepository(stubNetworkDataSource)
    val result = mutableListOf>()
    val job = launch(UnconfinedTestDispatcher(testScheduler)) {
    topicRepository.topicListFlow.collect {
    result.add(it)
    }
    }
    topicRepository.refreshTopicList()
    assertThat(result.size).isEqualTo(1)
    job.cancel()
    }
    runTestはScope内で起動されたCoroutineの完了を待つ
    完了しない場合はテストがタイムアウトでコケる

    View Slide

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

    View Slide

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

    View Slide

  49. delayを入れたテストを書く
    74
    var isDownloading: Boolean = false
    suspend fun download(url: String) {
    if (!isDownloading) {
    isDownloading = true
    networkDataSource.download(url)
    isDownloading = false
    }
    }
    テスト用のDataSourceでこの部分をdelay
    させるようにする

    View Slide

  50. delayを入れたテストを書く
    75
    class SpyNetworkDataSource : NetworkDataSource {
    var downloadCallCount: Int = 0
    override suspend fun download(url: String): Unit {
    delay(1000)
    downloadCallCount++
    }
    }
    1秒間遅延した状態をエミュレートするため、テスト
    用のDataSourceでdelayを入れる

    View Slide

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

    View Slide

  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が実時間ではなく、仮
    想時間で実行を管理するため

    View Slide

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

    View Slide

  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

    View Slide

  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つめのコルーチンがキューに積まれる

    View Slide

  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つめのコルーチンがキューに積まれる

    View Slide

  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)
    }
    ③仮想時間を進めてキューに積まれたコルーチンを実行

    View Slide

  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ミリ秒の時点
    で再開するようにスケジュールされる

    View Slide

  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で中断するのは同様

    View Slide

  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のため、分岐に入ら
    ず処理が終了する

    View Slide

  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になる

    View Slide

  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が行われる

    View Slide

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

    View Slide

  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で
    一時停止

    View Slide

  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)
    }
    一時停止していたコルーチンを再開

    View Slide

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

    View Slide

  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)
    }
    スケジューリングされているコルーチンがなく
    なるまで時間をすすめる

    View Slide

  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)
    }
    スケジューリングされているコルーチンがなく
    なるまで時間をすすめる

    View Slide

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

    View Slide

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

    View Slide

  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はコルーチンの実行が他のディスパッ
    チャに移動する場合も動作するため
    テストは成功する

    View Slide

  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が入っていたら...?

    View Slide

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

    View Slide

  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でないと完了を待つことが
    できない

    View Slide

  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を引数で受け取れるように
    する

    View Slide

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

    View Slide

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

    View Slide

  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を渡してあげる

    View Slide

  79. データレイヤのテストで紹介したこと
    ● コルーチンのテストはrunTestで囲む
    ● Flowはfirst()でemitされたデータを検証する
    ● Flowをcollectしてテストするときは、UnconfinedTestDispatcherで新しいコルー
    チンを起動する
    ● advanceUntilIdleやadvanceTimeByを使うことでコルーチンの実行をテストコー
    ドで管理でき、排他制御などのテストが楽にかける
    ● runTest内(TestDispatcherで実行されるコルーチン)のdelayは仮想時間内で遅
    延するだけなので、テストの実行時間に影響しない
    ● Dispatcherは外から渡せるようにすることで、コルーチンの実行がコントロールし
    やすくなる
    104

    View Slide

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

    View Slide

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

    View Slide

  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での変更を受け取る

    View Slide

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

    View Slide

  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関数を呼び出し

    View Slide

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

    View Slide

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

    View Slide

  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ディスパッチャを使えない

    View Slide

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

    View Slide

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

    View Slide

  90. Local TestでMainディスパッチャを置き換える
    115
    @Before
    fun setUp() {
    Dispatchers.setMain(testDispatcher)
    }
    @After
    fun tearDown() {
    Dispatchers.resetMain()
    }
    TestDispatcherはStandardとUnconfinedの
    どちらを使う?

    View Slide

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

    View Slide

  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で実行

    View Slide

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


    View Slide

  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を使うと
    同じ実行順になる

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  101. Flowで公開されたUiStateのテストを書く(collect)
    126
    @Test fun uiState() = runTest {
    val stateList = mutableListOf()
    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する

    View Slide

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

    View Slide

  103. Flowで公開されたUiStateのテストを書く(collect)
    128
    @Test fun uiState() = runTest {
    val stateList = mutableListOf()
    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は自動的に置き換えたディスパッチャの
    スケジューラを共有するので省略可

    View Slide

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

    View Slide

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

    View Slide

  106. Stateで公開されたUiStateのテストを書く
    131
    @Test
    fun uiState() {
    assertThat(viewModel.uiState).isEqualTo(TopicUiState(isFollowed = false))
    viewModel.followTopic(true)
    assertThat(viewModel.uiState).isEqualTo(TopicUiState(isFollowed = true))
    }

    View Slide

  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の順に変わっているか見る
    )
    }

    View Slide

  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の順に変わっているか見る
    )
    }

    View Slide

  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を追加して、変更を箱に入れていく

    View Slide

  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へのキャストをしてあげると安心

    View Slide

  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に流れてこないので、自分で設定してあげる

    View Slide

  112. ComposeのSnapshotについて詳しく
    「ComposeのSnapshot」Kenji Abe
    https://star-zero.medium.com/compose%E3%81%AEsnapshot-17414888b41b
    137

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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で流れてこない

    View Slide

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

    View Slide

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

    View Slide

  121. UiStateの中間状態を見るテストを書く
    146
    @Test fun uiState() = runTest {
    val stateList = mutableListOf()
    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できる

    View Slide

  122. UiStateの中間状態を見るテストを書く
    147
    @Test fun uiState() = runTest {
    val stateList = mutableListOf()
    val job = launch(UnconfinedTestDispatcher()) {
    viewModel.uiStateFlow.collect { stateList.add(it) }
    }
    viewModel.followTopic(true)
    advanceUntilIdle()
    assertThat(stateList).isEqualTo( .. ) // isLoading = false -> true -> false
    job.cancel()
    }
    停止していたコルーチンを再開する

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  126. リポジトリからFlowの変更を受け取る
    153
    class TopicViewModel @Inject constructor(val userDataRepository: UserDataRepository
    ) : ViewModel() {
    val followedTopicIds: Flow> = userDataRepository.userDataStream.map{ .. }
    fun followTopicToggle(followed: Boolean) {
    viewModelScope.launch {
    userDataRepository.toggleFollowedTopicId(topicId, followed)
    }
    }
    }
    こっちに変更が流れてくる

    View Slide

  127. リポジトリからFlowの変更を受け取る
    ● テスト自体はUiStateの変化を見ればOK
    ● テスト用のリポジトリにモックライブラリを使っている場合は注意
    ○ モックライブラリは差し替えたいクラスの空の実装を作り、任意の値を返すよう
    に設定したり、呼び出しを記録する用途で使うことが多い
    ○ Flowを返す場合は、テスト側でFlowのインスタンスを設定する
    ○ 保存をしたらFlowに値を流すというのを自分で設定しないと値は流れてこない
    154

    View Slide

  128. リポジトリからFlowの変更を受け取る
    ● リポジトリのFake実装を自分で用意し保存時にFlowにemitするようにする
    ○ リポジトリのInterfaceがきちんと定義されていれば実装しやすい
    ● リポジトリは実クラスを使い、DataSourceを差し替える
    ○ RetrofitのServiceを差し替える
    ○ RoomのIn Memory DB(RobolectricかInstrumentation Testになる)
    ● ViewModelでは保存と更新を分けてテストする(保存 + 更新はリポジトリのテスト
    で見る)
    155

    View Slide

  129. UIレイヤ データホルダーのテストで紹介したこと
    ● メインディスパッチャをUnconfinedTestDispatcherに置き換える
    ● UI Stateは都度値を確認、もしくは変更を継続してみることでテストする
    ● ComposeのStateの変更を継続して見るのにはSnapshotが使える
    ● 中間状態のUI Stateを見る場合は、delayを差し込むとテストがしやすい
    ● リポジトリからFlowで変更を受け取る場合は、Fakeの実装を用意するか結合範囲
    を変更するかを検討する
    156

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  142. テスト時に使えるApplicationの制限(Hilt)
    171
    ● @HiltAndroidAppがついたApplicationは利用できない
    ○ つまりプロダクションコードのApplicationは利用できない
    ○ 基本的にはHiltTestApplicationを使う
    ○ カスタムAppliationを使いたい場合は@CustomTestApplicationで生成する
    ● @InjectフィールドがあるApplicationは利用できない
    ○ EarlyEntryPointを使う

    View Slide

  143. @CustomTestApplication
    172
    @CustomTestApplication(BaseApplication::class)
    interface HiltTestApplication
    BaseApplicationを継承したHiltTestApplication_Applicationが生
    成される

    View Slide

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

    View Slide

  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
    }

    View Slide

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

    View Slide

  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

    View Slide

  148. 前準備(Instrumentation Test)
    177
    build.gradle
    android {
    defaultConfig {
    testInstrumentationRunner = "com.example.CustomTestRunner"
    }
    }

    View Slide

  149. テストコードの全体像
    178
    @HiltAndroidTest
    class TopicScreenTest {
    @get:Rule(order = 0) val hiltTestRule = HiltAndroidRule(this)
    @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule()
    @Test
    fun test() {
    composeTestRule.setContent { // composable関数をセット }
    }
    }

    View Slide

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

    View Slide

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

    View Slide

  152. Dagger hiltでリポジトリを差し替える
    181
    @HiltAndroidTest
    class TopicScreenTest {
    @get:Rule(order = 0) val hiltTestRule = HiltAndroidRule(this)
    @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule()
    @Test
    fun test() {
    composeTestRule.setContent { // composable関数をセット }
    }
    }
    テスト実行時にテストクラスごとのComponentを生成する

    View Slide

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

    View Slide

  154. Dagger hiltでリポジトリを差し替える
    183
    @Module
    @InstallIn(SingletonComponent::class)
    interface DataModule {
    @Binds
    fun bindsUserDataRepository(
    userDataRepository: DefaultUserDataRepository
    ): UserDataRepository
    }
    このModuleをテスト用のModuleに置き換える

    View Slide

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

    }

    View Slide

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

    }

    View Slide

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

    }
    DataModuleを削除する

    View Slide

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

    }
    テスト用の実装に置き換えた
    Moduleを定義して追加する

    View Slide

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

    }
    削除したModuleに定義されていた
    ものは全て定義する必要あり

    View Slide

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

    }

    View Slide

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

    }
    @BindValueを使うとテストから参照したいと
    きに便利
    Moduleに定義されていない依存を差し替え
    るときは@BindValueだけでもOK

    View Slide

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

    View Slide

  163. Dagger hiltでリポジトリを差し替える
    192
    @Module
    @TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [DataModule::class]
    )
    interface TestDataModule {
    @Provide
    fun provide(): UserDataRepository = TestUserDataRepository
    }
    トップレベルに定義することで
    すべてのテストでModuleを差し替える

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  169. ComposeをセットするActivityを起動する
    198
    @HiltAndroidTest
    class TopicScreenTest {
    @get:Rule(order = 0) val hiltTestRule = HiltAndroidRule(this)
    @get:Rule(order = 1) val composeRule = createAndroidComposeRule()
    @Test
    fun test() {
    composeRule.setContent { // composable関数をセット }
    }
    }
    実際のActivityを使うか、テスト用のActivityを別
    途用意してsrc/debugに配置する

    View Slide

  170. ComposeをセットするActivityを起動する
    199
    @AndroidEntryPoint
    class HiltTestActivity : ComponentActivity()

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  174. Composeをセットする
    203
    @HiltViewModel
    class TopicViewModel @Inject constructor( .. ): ViewModel
    @Composable
    fun TopicRoute(
    modifier: Modifier = Modifier,
    viewModel: TopicViewModel = hiltViewModel()
    ) {
    ..
    }
    今回テストしたいComposable関数
    自動的にHiltによって依存解決されるので
    テスト用のリポジトリを参照するようになっている

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  178. Composeをセットする
    207
    @get:Rule(order = 1) val composeRule = createAndroidComposeRule()
    @Test
    fun test() {
    composeRule.setContent {
    TopicRoute()
    }
    // UIテストを実装していく
    }
    デフォルト引数でViewModelのインスタンスも生成
    されており、テスト用のリポジトリを参照するように
    なっている

    View Slide

  179. テストの全体像
    208
    @HiltAndroidTest
    class TopicScreenTest {
    @get:Rule(order = 0) val hiltTestRule = HiltAndroidRule(this)
    @get:Rule(order = 1) val composeRule = createAndroidComposeRule()
    @Test
    fun test() {
    composeRule.setContent { // composable関数をセット }
    }
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  183. Dispatcherの差し替え
    212
    @Qualifier
    @Retention(RUNTIME)
    annotation class IODispatcher
    class DefaultTopicsRepository @Inject constructor(
    @IODispatcher private val ioDispatcher: CoroutineDispatcher,
    ) : TopicsRepository {
    コンストラクタでIO Dispatcherを差し替えら
    れるようにする

    View Slide

  184. Dispatcherの差し替え
    213
    @Module
    @InstallIn(SingletonComponent::class)
    object DispatchersModule {
    @Provides
    @IODispatcher
    fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
    }
    IO DispatcherをProvideするModule

    View Slide

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

    View Slide

  186. Dispatcherの差し替え
    215
    @HiltAndroidTest
    @UninstallModules(DispatchersModule::class)
    class Test {
    val testDispatcher = UnconfinedTestDispatcher()
    @BindValue @IODispatcher
    val ioDispatcher: CoroutineDispatcher = testDispatcher
    ..
    }
    テスト側でDispatcherを差し替え
    (@TestInstallInでもOK)

    View Slide

  187. UIレイヤ UI elementsのテストで紹介したこと
    ● Dagger Hiltをつかって一部の依存を差し替えるためには、@HiltAndroidTestと
    HiltAndroidRuleを使う
    ● 依存の差し替えにはModuleを削除してTest用のModuleを追加する
    ● Compose用のRuleには@AndroidEntryPointがついたActivityを設定する
    ● ComposeTestRule#setContentで任意のComposabel関数を設定する
    ● hiltViewModel()でViewModelにインスタンスを作っていれば、自動的にHiltに
    よってテスト用の依存に置き換わっている
    ● DispacherはHiltで差し替えられるようになっていると、自動待ち合わせが実現し
    やすい
    216

    View Slide

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

    View Slide

  189. データレイヤのテストで紹介したこと
    ● コルーチンのテストはrunTestで囲む
    ● Flowはfirst()でemitされたデータを検証する
    ● Flowをcollectしてテストするときは、UnconfinedTestDispatcherで新しいコルー
    チンを起動する
    ● advanceUntilIdleやadvanceTimeByを使うことでコルーチンの実行をテストコー
    ドで管理でき、排他制御などのテストが楽にかける
    ● runTest内(TestDispatcherで実行されるコルーチン)のdelayは仮想時間内で遅
    延するだけなので、テストの実行時間に影響しない
    ● Dispatcherは外から渡せるようにすることで、コルーチンの実行がコントロールし
    やすくなる
    218

    View Slide

  190. UIレイヤ データホルダーのテストで紹介したこと
    ● メインディスパッチャをUnconfinedTestDispatcherに置き換える
    ● UI Stateは都度値を確認、もしくは変更を継続してみることでテストする
    ● ComposeのStateの変更を継続して見るのにはSnapshotが使える
    ● 中間状態のUI Stateを見る場合は、delayを差し込むとテストがしやすい
    ● リポジトリからFlowで変更を受け取る場合は、Fakeの実装があるとテストがしやす

    219

    View Slide

  191. UIレイヤ UI elementsのテストで紹介したこと
    ● Dagger Hiltをつかって一部の依存を差し替えるためには、@HiltAndroidTestと
    HiltAndroidRuleを使う
    ● 依存の差し替えにはModuleを削除してTest用のModuleを追加する
    ● Compose用のRuleには@AndroidEntryPointがついたActivityを設定する
    ● ComposeTestRule#setContentで任意のComposabel関数を設定する
    ● hiltViewModel()でViewModelにインスタンスを作っていれば、自動的にHiltに
    よってテスト用の依存に置き換わっている
    ● DispacherはHiltで差し替えられるようになっていると、自動待ち合わせが実現し
    やすい
    220

    View Slide

  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

    View Slide

  193. 222
    ご静聴ありがとうございました!

    View Slide