Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

シンプルな非同期処理のテストを書く 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() } } }

Slide 19

Slide 19 text

シンプルな非同期処理のテストを書く 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関数

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

スライドに出てくるコードの注意点 24 @Test fun getTopics() = runTest { val topicRepository = TopicRepository(stubNetworkDataSource) val actual = topicRepository.getTopics() assertThat(actual).isEqualTo( listOf( Topic( "id", … ) } テスト用のデータ返却の実装等は別途 やっている前提で進めていきます

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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を通して変更を受け取る

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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の中身を検証する }

Slide 30

Slide 30 text

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する

Slide 31

Slide 31 text

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の実行ができる

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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つになるようにする

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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の完了を待つ 完了しない場合はテストがタイムアウトでコケる

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

withContextでDispatcherが指定されたテストを書く 100 class AssetRepository( val networkDataSource: NetworkDataSource, val ioDispatcher: CoroutineDispatcher, ) { var isDownloading: AtomicBoolean = AtomicBoolean(false) suspend fun download(url: String) = withContext(ioDispatcher) { .. } } Dispatcherを引数で受け取れるように する

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

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

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

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

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

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

Slide 94

Slide 94 text

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

Slide 95

Slide 95 text

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

Slide 96

Slide 96 text

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

Slide 97

Slide 97 text

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を公開

Slide 98

Slide 98 text

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を更新

Slide 99

Slide 99 text

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

Slide 100

Slide 100 text

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

Slide 101

Slide 101 text

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する

Slide 102

Slide 102 text

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を渡さなくていいの?

Slide 103

Slide 103 text

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は自動的に置き換えたディスパッチャの スケジューラを共有するので省略可

Slide 104

Slide 104 text

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

Slide 105

Slide 105 text

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

Slide 106

Slide 106 text

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

Slide 107

Slide 107 text

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

Slide 108

Slide 108 text

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

Slide 109

Slide 109 text

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

Slide 110

Slide 110 text

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

Slide 111

Slide 111 text

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

Slide 112

Slide 112 text

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

Slide 113

Slide 113 text

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

Slide 114

Slide 114 text

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

Slide 115

Slide 115 text

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

Slide 116

Slide 116 text

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

Slide 117

Slide 117 text

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

Slide 118

Slide 118 text

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

Slide 119

Slide 119 text

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

Slide 120

Slide 120 text

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

Slide 121

Slide 121 text

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できる

Slide 122

Slide 122 text

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

Slide 123

Slide 123 text

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

Slide 124

Slide 124 text

リポジトリから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) } } }

Slide 125

Slide 125 text

リポジトリから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) } } } ここでリポジトリに更新をお願 いすると

Slide 126

Slide 126 text

リポジトリから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) } } } こっちに変更が流れてくる

Slide 127

Slide 127 text

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

Slide 128

Slide 128 text

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

Slide 129

Slide 129 text

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

Slide 130

Slide 130 text

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

Slide 131

Slide 131 text

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

Slide 132

Slide 132 text

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

Slide 133

Slide 133 text

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

Slide 134

Slide 134 text

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

Slide 135

Slide 135 text

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

Slide 136

Slide 136 text

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

Slide 137

Slide 137 text

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

Slide 138

Slide 138 text

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

Slide 139

Slide 139 text

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

Slide 140

Slide 140 text

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

Slide 141

Slide 141 text

前準備(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" }

Slide 142

Slide 142 text

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

Slide 143

Slide 143 text

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

Slide 144

Slide 144 text

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

Slide 145

Slide 145 text

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 }

Slide 146

Slide 146 text

前準備(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) } }

Slide 147

Slide 147 text

前準備(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

Slide 148

Slide 148 text

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

Slide 149

Slide 149 text

テストコードの全体像 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関数をセット } } }

Slide 150

Slide 150 text

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でテスト用のリポジトリのセットアップする

Slide 151

Slide 151 text

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)

Slide 152

Slide 152 text

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を生成する

Slide 153

Slide 153 text

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

Slide 154

Slide 154 text

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

Slide 155

Slide 155 text

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

Slide 156

Slide 156 text

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

Slide 157

Slide 157 text

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

Slide 158

Slide 158 text

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

Slide 159

Slide 159 text

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

Slide 160

Slide 160 text

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

Slide 161

Slide 161 text

Dagger hiltでリポジトリを差し替える 190 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @JvmField @BindValue val userDataRepository: UserDataRepository = TestUserDataRepository() … } @BindValueを使うとテストから参照したいと きに便利 Moduleに定義されていない依存を差し替え るときは@BindValueだけでもOK

Slide 162

Slide 162 text

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

Slide 163

Slide 163 text

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

Slide 164

Slide 164 text

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関数をセット } } }

Slide 165

Slide 165 text

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関数をセット } } }

Slide 166

Slide 166 text

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の後に適用されるようにする

Slide 167

Slide 167 text

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

Slide 168

Slide 168 text

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である必 要がある

Slide 169

Slide 169 text

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に配置する

Slide 170

Slide 170 text

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

Slide 171

Slide 171 text

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

Slide 172

Slide 172 text

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

Slide 173

Slide 173 text

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

Slide 174

Slide 174 text

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

Slide 175

Slide 175 text

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

Slide 176

Slide 176 text

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

Slide 177

Slide 177 text

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

Slide 178

Slide 178 text

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

Slide 179

Slide 179 text

テストの全体像 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関数をセット } } }

Slide 180

Slide 180 text

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

Slide 181

Slide 181 text

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

Slide 182

Slide 182 text

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

Slide 183

Slide 183 text

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

Slide 184

Slide 184 text

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

Slide 185

Slide 185 text

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

Slide 186

Slide 186 text

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

Slide 187

Slide 187 text

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

Slide 188

Slide 188 text

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

Slide 189

Slide 189 text

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

Slide 190

Slide 190 text

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

Slide 191

Slide 191 text

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

Slide 192

Slide 192 text

参考リンク ● 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

Slide 193

Slide 193 text

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