Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
Androidのモダンな技術選択にあわせて自動テストも アップデートしよう / Update...
Search
tkmnzm
October 05, 2022
Programming
3
2.1k
Androidのモダンな技術選択にあわせて自動テストも アップデートしよう / Update your automated tests to match Android's modern technology choices
DroidKaigi 2022
「Androidのモダンな技術選択にあわせて自動テストも アップデートしよう
」の発表資料です。
tkmnzm
October 05, 2022
Tweet
Share
More Decks by tkmnzm
See All by tkmnzm
AndroidアプリのUIバリエーションをあの手この手で確認する / Check UI variations of Android apps by various means
tkmnzm
1
790
Androidアプリの良いユニットテストを考える / Thinking about good unit tests for Android apps
tkmnzm
5
7.6k
Google I:O 2023 Androidの自動テストアップデートまとめ / Google I:O 2023 Android Testing Update Recap
tkmnzm
0
570
コルーチンのエラーをテストするためのTips / Tips for testing Kotlin Coroutine errors
tkmnzm
0
910
SWET dev-vitalチームによるプロジェクトの健康状態可視化の取り組み / SWET dev-vital team's efforts to visualize the health of the project
tkmnzm
1
1.2k
モバイルアプリテスト入門 / Getting Started with Mobile App Testing
tkmnzm
1
490
25分で作るAndroid Lint / Android Lint made in 25 minutes
tkmnzm
0
850
2年半ぶりのプロダクト開発であらためて感じた自動テストの大切さ / realized the importance of automatic testing with product development for the first time in two and a half years
tkmnzm
1
760
Android スクリーンショットテスト 3つのプロダクトに導入する中で倒してきた課題 / Android Screenshot Test Problems solved by introducing into 3 products
tkmnzm
2
1.2k
Other Decks in Programming
See All in Programming
型付き API リクエストを実現するいくつかの手法とその選択 / Typed API Request
euxn23
8
2.2k
Creating a Free Video Ad Network on the Edge
mizoguchicoji
0
120
Laravel や Symfony で手っ取り早く OpenAPI のドキュメントを作成する
azuki
2
120
PHP でアセンブリ言語のように書く技術
memory1994
PRO
1
170
AI時代におけるSRE、 あるいはエンジニアの生存戦略
pyama86
6
1.1k
「今のプロジェクトいろいろ大変なんですよ、app/services とかもあって……」/After Kaigi on Rails 2024 LT Night
junk0612
5
2.2k
3 Effective Rules for Using Signals in Angular
manfredsteyer
PRO
0
100
Amazon Qを使ってIaCを触ろう!
maruto
0
410
Pinia Colada が実現するスマートな非同期処理
naokihaba
4
220
ヤプリ新卒SREの オンボーディング
masaki12
0
130
Generative AI Use Cases JP (略称:GenU)奮闘記
hideg
1
290
CSC509 Lecture 12
javiergs
PRO
0
160
Featured
See All Featured
Build The Right Thing And Hit Your Dates
maggiecrowley
33
2.4k
BBQ
matthewcrist
85
9.3k
What's in a price? How to price your products and services
michaelherold
243
12k
Adopting Sorbet at Scale
ufuk
73
9.1k
Product Roadmaps are Hard
iamctodd
PRO
49
11k
Fantastic passwords and where to find them - at NoRuKo
philnash
50
2.9k
The Success of Rails: Ensuring Growth for the Next 100 Years
eileencodes
44
6.8k
Designing Experiences People Love
moore
138
23k
Designing for humans not robots
tammielis
250
25k
Embracing the Ebb and Flow
colly
84
4.5k
YesSQL, Process and Tooling at Scale
rocio
169
14k
Fontdeck: Realign not Redesign
paulrobertlloyd
82
5.2k
Transcript
Androidのモダンな技術選択 にあわせて自動テストも アップデートしよう Nozomi Takuma 1
自己紹介 • Nozomi Takuma • 株式会社ディー・エヌ・エー SWET第二グループ所属 ◦ Pococha事業部システム部兼務 •
Androidとテストが好き 2
本セッションで話すこと • Androidアプリ開発において新しく採用されるケースが多い、次の技術のテスト方 法を紹介 ◦ Kotlin Coroutine / Kotlin Flow(1.6)
◦ Jetpack Compose ◦ Dagger Hilt • 各技術のテスト方法をGuide to app architectureで推奨されるレイヤ毎(UIレイヤ ・Dataレイヤ)に整理 • 各技術を採用したプロダクトでテストを書き始めるときに、テストの実装イメージが 掴めて、何から始めればいいかがわかるようになっていることがゴール 3
Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI
elementsのテストを書く 01 02 03 04 4
Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI
elementsのテストを書く 01 02 03 04 5
Guide to app architectureに登場するレイヤ 6 ドメインレイヤ(Optional) UI elements State holders
UIレイヤ Repositories Data Sources データレイヤ
本セッションで登場する技術 • Kotlin Coroutine / Kotlin Flow(1.6) • Jetpack Compose
• Dagger Hilt 7
レイヤと各技術の関係 8 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ Kotlin Coroutine /Flow
レイヤと各技術の関係 9 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ Jetpack Compose
レイヤと各技術の関係 10 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ Dagger Hilt
本セッションでテストの書き方を紹介するレイヤ 11 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ
Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI
elementsのテストを書く 01 02 03 04 12
データレイヤ • アプリデータと、アプリデータの作成・保存・ 変更方法を決定するビジネスロジックを持つ • データソースクラスはファイル・API・ローカ ルDB等、1 つのデータソースのみを処理す る役割 •
リポジトリクラスはデータレイヤのエントリー ポイントとなり、データソースを抽象化する 13 Repositories Data Sources
データレイヤ • アプリデータと、アプリデータの作成・保存・ 変更方法を決定するビジネスロジックを持つ • データソースクラスはファイル・API・ローカ ルDB等、1 つのデータソースのみを処理す る役割 •
リポジトリクラスはデータレイヤのエントリー ポイントとなり、データソースを抽象化する 14 Repositories Data Sources Kotlin Coroutine/ Flowで実装していること を想定してテスト方法を紹介
データレイヤ • アプリデータと、アプリデータの作成・保存・ 変更方法を決定するビジネスロジックを持つ • データソースクラスはファイル・API・ローカ ルDB等、1 つのデータソースのみを処理す る役割 •
リポジトリクラスはデータレイヤのエントリー ポイントとなり、データソースを抽象化する 15 Repositories Data Sources • シンプルなsuspend関数のテスト • Flowの変更をテストする • Flowの変更をcollectしてテストする • delayを入れる • withContextでDisptacherを切り替える
データレイヤ • アプリデータと、アプリデータの作成・保存・ 変更方法を決定するビジネスロジックを持つ • データソースクラスはファイル・API・ローカ ルDB等、1 つのデータソースのみを処理す る役割 •
リポジトリクラスはデータレイヤのエントリー ポイントとなり、データソースを抽象化する 16 Repositories Data Sources Retrofitで生成されるServiceやRoomのDaoが含まれる それらのテスト方法はセッションの本筋からずれるため除外
前準備 17 build.gradle dependencies { testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" }
シンプルな非同期処理のテストを書く 18 interface NetworkDataSource { suspend fun getTopics(): List<TopicResponse> }
class TopicRepository(val networkDataSource: NetworkDataSource) { suspend fun getTopics(): List<Topic> { val response: List<TopicResponse> = networkDataSource.getTopics() return response.map { it.asModel() } } }
シンプルな非同期処理のテストを書く 19 interface NetworkDataSource { suspend fun getTopics(): List<TopicResponse> }
class TopicRepository(val networkDataSource: NetworkDataSource) { suspend fun getTopics(): List<Topic> { val response: List<TopicResponse> = networkDataSource.getTopics() return response.map { it.asModel() } } } APIレスポンスをアプリ内で使いやすいデー タに変換するsuspend関数
シンプルな非同期処理のテストを書く 20 @Test fun getTopics() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val actual = topicRepository.getTopics() assertThat(actual).isEqualTo( listOf( Topic( "id", … ) }
シンプルな非同期処理のテストを書く 21 @Test fun getTopics() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val actual = topicRepository.getTopics() assertThat(actual).isEqualTo( listOf( Topic( "id", … ) } runTestでテストコードを囲む
runTest • テスト用に新しいコルーチンを開始するコルーチンビルダー • テストコードをラップすることで、コルーチンの中でテストコードを実行する • 振る舞いはrunBlockingと似ているが、delayによる実時間の遅延がスキップされ る(後述) • コルーチンの実行が他のディスパッチャに移動する場合も動作する(後述)
22
スライドに出てくるコードの注意点 24 @Test fun getTopics() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val actual = topicRepository.getTopics() assertThat(actual).isEqualTo( listOf( Topic( "id", … ) } テスト用のデータ返却の実装等は別途 やっている前提で進めていきます
Flowで変更を通知する実装のテストを書く 29 private val _topicList = MutableSharedFlow<List<Topic>>(replay = 1) val
topicListFlow: SharedFlow<List<Topic>> = _topicList suspend fun refreshTopicList() { val response: List<TopicResponse> = networkDataSource.getTopics() _topicList.emit(response.map { it.asModel() }) }
Flowで変更を通知する実装のテストを書く 30 private val _topicList = MutableSharedFlow<List<Topic>>(replay = 1) val
topicListFlow: SharedFlow<List<Topic>> = _topicList suspend fun refreshTopicList() { val response: List<TopicResponse> = networkDataSource.getTopics() _topicList.emit(response.map { it.asModel() }) } APIの取得結果をFlowに流す 呼び出し元はFlowを通して変更を受け取る
Flowで変更を通知する実装のテストを書く 31 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource, FakeTopicDao()) topicRepository.refreshTopicList() assertThat(topicRepository.topicListFlow.first().size).isEqualTo(1) }
Flowで変更を通知する実装のテストを書く 32 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource, FakeTopicDao()) topicRepository.refreshTopicList() assertThat(topicRepository.topicListFlow.first().size).isEqualTo(1) } first()でemitされたデータを取得する
Flowで変更を通知する実装のテストを書く(collect) 33 @Test fun refreshTopic() = runTest { // 1.
変更を受け取るための箱 (MutableList)を用意する // 2. 変更を監視して、受け取った結果を Listに追加する // 3. テストしたいコードを実行 // 4. Listの中身を検証する }
Flowで変更を通知する実装のテストを書く(collect) 34 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() // 1. 変更を保存するMutable List launch { topicRepository.topicListFlow.collect { // 2. 変更を監視して、受け取った結果を Listに追加する result.add(it) } } topicRepository.refreshTopicList() // 3. テストしたいコードを実行 assertThat(result.size).isEqualTo(1) // 4. Listの中身を検証する }
Flowで変更を通知する実装のテストを書く(collect) 35 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } collectはsuspend関数なので完了する まで次の処理が進まない launchで別のコルーチンを開始して collectする
Flowで変更を通知する実装のテストを書く(collect) 36 @Test fun refreshTopic() = runTest { // This
: TestScope val topicRepository = TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } runTestのレシーバはTestScope テスト用のCoroutine Scope実装なので、中 でlaunchやasyncの実行ができる
Flowで変更を通知する実装のテストを書く(collect) 37 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) }
runTest内のlaunchの振る舞い 38 @Test fun launch() = runTest { launch {
println("Launch new Coroutine") } println("End of Test Body") }
runTest内のlaunchの振る舞い 39 @Test fun launch() = runTest { launch {
println("Launch new Coroutine") } println("End of Test Body") } 先 後
runTest内のlaunchの振る舞い 40 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) }
runTest内のlaunchの振る舞い 41 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } 先 後
TestDispatcher テスト用のCoroutineDisptacherは2種類ある ① StandardTestDispatcher • コルーチンはキューに追加し、テストスレッドが空いているときに実行する • runTestはデフォルトでStandardTestDispatcherを使う ② UnconfinedTestDispatcher
• コルーチンをすぐに実行する 42
TestDispatcher テスト用のCoroutineDisptacherは2種類ある ① StandardTestDispatcher • コルーチンはキューに追加し、テストスレッドが空いているときに実行する • runTestはデフォルトでStandardTestDispatcherを使う ② UnconfinedTestDispatcher
• コルーチンをすぐに実行する 43 こっちのDipatcherを使う
UnconfinedTestDispatcherを使ってlaunchする 44 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) }
UnconfinedTestDispatcherを使ってlaunchする 45 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } TestScopeのプロパティのTestScheduler
UnconfinedTestDispatcherを使ってlaunchする 46 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } TestSchedulerはコルーチンの実行タイミン グを管理している TestDispatcherはテストの中で複数作ること ができるが、TestSchedulerは1つのテストの 中で1つになるようにする
UnconfinedTestDispatcherを使ってlaunchする 47 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) } 後 先
UnconfinedTestDispatcherを使ってlaunchする 48 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) }
collectを終わらせてあげる 49 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() val job = launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) job.cancel() }
collectを終わらせてあげる 50 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() val job = launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) job.cancel() }
collectを終わらせてあげる 51 @Test fun refreshTopic() = runTest { val topicRepository
= TopicRepository(stubNetworkDataSource) val result = mutableListOf<List<Topic>>() val job = launch(UnconfinedTestDispatcher(testScheduler)) { topicRepository.topicListFlow.collect { result.add(it) } } topicRepository.refreshTopicList() assertThat(result.size).isEqualTo(1) job.cancel() } runTestはScope内で起動されたCoroutineの完了を待つ 完了しない場合はテストがタイムアウトでコケる
delayを入れたテストを書く 72 var isDownloading: Boolean = false suspend fun download(url:
String) { if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } }
delayを入れたテストを書く 73 var isDownloading: Boolean = false suspend fun download(url:
String) { if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } } isDownloadingがtrueだったら処理をスキップ ➝ スキップされることをテストする
delayを入れたテストを書く 74 var isDownloading: Boolean = false suspend fun download(url:
String) { if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } } テスト用のDataSourceでこの部分をdelay させるようにする
delayを入れたテストを書く 75 class SpyNetworkDataSource : NetworkDataSource { var downloadCallCount: Int
= 0 override suspend fun download(url: String): Unit { delay(1000) downloadCallCount++ } } 1秒間遅延した状態をエミュレートするため、テスト 用のDataSourceでdelayを入れる
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) }
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が実時間ではなく、仮 想時間で実行を管理するため
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) }
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
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つめのコルーチンがキューに積まれる
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つめのコルーチンがキューに積まれる
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) } ③仮想時間を進めてキューに積まれたコルーチンを実行
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ミリ秒の時点 で再開するようにスケジュールされる
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で中断するのは同様
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のため、分岐に入ら ず処理が終了する
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になる
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が行われる
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) }
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で 一時停止
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) } 一時停止していたコルーチンを再開
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) }
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) } スケジューリングされているコルーチンがなく なるまで時間をすすめる
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) } スケジューリングされているコルーチンがなく なるまで時間をすすめる
withContextでDispatcherが指定されたテストを書く 94 var isDownloading: Boolean = false suspend fun download(url:
String) = withContext(Dispatchers.IO){ if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } }
withContextでDispatcherが指定されたテストを書く 95 var isDownloading: Boolean = false suspend fun download(url:
String) = withContext(Dispatchers.IO){ if (!isDownloading) { isDownloading = true networkDataSource.download(url) isDownloading = false } }
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はコルーチンの実行が他のディスパッ チャに移動する場合も動作するため テストは成功する
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が入っていたら...?
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) }
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でないと完了を待つことが できない
withContextでDispatcherが指定されたテストを書く 100 class AssetRepository( val networkDataSource: NetworkDataSource, val ioDispatcher: CoroutineDispatcher,
) { var isDownloading: AtomicBoolean = AtomicBoolean(false) suspend fun download(url: String) = withContext(ioDispatcher) { .. } } Dispatcherを引数で受け取れるように する
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) }
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) }
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を渡してあげる
データレイヤのテストで紹介したこと • コルーチンのテストはrunTestで囲む • Flowはfirst()でemitされたデータを検証する • Flowをcollectしてテストするときは、UnconfinedTestDispatcherで新しいコルー チンを起動する • advanceUntilIdleやadvanceTimeByを使うことでコルーチンの実行をテストコー
ドで管理でき、排他制御などのテストが楽にかける • runTest内(TestDispatcherで実行されるコルーチン)のdelayは仮想時間内で遅 延するだけなので、テストの実行時間に影響しない • Dispatcherは外から渡せるようにすることで、コルーチンの実行がコントロールし やすくなる 104
Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI
elementsのテストを書く 01 02 03 04 105
UIレイヤ • アプリデータを画面に表示する役割で、ユー ザーの操作やAPIレスポンスなどの外部入 力によるデータの変更をUIに反映する • State holdersはUIに表示する状態(UI State)の保持と更新を管理 •
UI elementsはユーザーイベントのState holderへの通知と、State holderがもつ Stateの内容のレンダリングを行う 106 UI elements State holders
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での変更を受け取る
suspend関数を呼び出す実装のテストを書く 108 class TopicViewModel constructor( private val userDataRepository: UserDataRepository, )
: ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } }
suspend関数を呼び出す実装のテストを書く 109 class TopicViewModel constructor( private val userDataRepository: UserDataRepository, )
: ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } } viewModelScopeでコルーチンを起動して、リ ポジトリのsuspend関数を呼び出し
suspend関数を呼び出す実装のテストを書く 110 @Test fun followTopicToggle() { val viewModel = TopicViewModel(userDataRepository)
viewModel.followTopic("Id", true) assertThat(userDataRepository.currentUserData.followedTopics).isEqualTo(setOf("Id")) }
suspend関数を呼び出す実装のテストを書く 111 @Test fun followTopicToggle() { val viewModel = TopicViewModel(userDataRepository)
viewModel.followTopic("Id", true) assertThat(userDataRepository.currentUserData.followedTopics).isEqualTo(setOf("Id")) }
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ディスパッチャを使えない
Local TestでMainディスパッチャを置き換える 113 @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After
fun tearDown() { Dispatchers.resetMain() }
Local TestでMainディスパッチャを置き換える 114 @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After
fun tearDown() { Dispatchers.resetMain() } Mainディスパッチャをテストディスパッチャに置 き換えてくれる その他のディスパッチャと異なり、Mainディス パッチャはコンストラクタでの差し替えが難しい
Local TestでMainディスパッチャを置き換える 115 @Before fun setUp() { Dispatchers.setMain(testDispatcher) } @After
fun tearDown() { Dispatchers.resetMain() } TestDispatcherはStandardとUnconfinedの どちらを使う?
Local TestでMainディスパッチャを置き換える 116 class TopicViewModel constructor( private val userDataRepository: UserDataRepository,
) : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } .. } }
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で実行
Local TestでMainディスパッチャを置き換える 118 class TopicViewModel constructor( private val userDataRepository: UserDataRepository,
) : ViewModel() { fun followTopicToggle(topicId: String, followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } .. } } 後 先
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を使うと 同じ実行順になる
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")) }
Flowで公開されたUiStateのテストを書く 121 data class TopicUiState(val isFollowed: Boolean = false) val
_uiState = MutableStateFlow(TopicUiState()) val uiState: StateFlow<TopicUiState> = _uiState.asStateFlow() fun followTopic(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(currentTopicId, followed) _uiState.update { it.copy(isFollowed = followed) } } }
Flowで公開されたUiStateのテストを書く 122 data class TopicUiState(val isFollowed: Boolean = false) val
_uiState = MutableStateFlow(TopicUiState()) val uiState: StateFlow<TopicUiState> = _uiState.asStateFlow() fun followTopic(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(currentTopicId, followed) _uiState.update { it.copy(isFollowed = followed) } } } StateFlowでUIStateを公開
Flowで公開されたUiStateのテストを書く 123 data class TopicUiState(val isFollowed: Boolean = false) val
_uiState = MutableStateFlow(TopicUiState()) val uiState: StateFlow<TopicUiState> = _uiState.asStateFlow() fun followTopic(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(currentTopicId, followed) _uiState.update { it.copy(isFollowed = followed) } } } ユーザーアクションにあわせて UiStateを更新
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)) }
Flowで公開されたUiStateのテストを書く(collect) 125 @Test fun uiState() = runTest { val stateList
= mutableListOf<TopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( // isFollowed = false, isFollowed = true の順にUIStateが変化している ) job.cancel() }
Flowで公開されたUiStateのテストを書く(collect) 126 @Test fun uiState() = runTest { val stateList
= mutableListOf<TopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( // isFollowed = false, isFollowed = true の順にUIStateが変化している ) job.cancel() } runTestでコルーチンを起動できるようにして、 UnconfinedTestDispatcherでcollectする
Flowで公開されたUiStateのテストを書く(collect) 127 @Test fun uiState() = runTest { val stateList
= mutableListOf<TopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( // isFollowed = false, isFollowed = true の順にUIStateが変化している ) job.cancel() } TestSchedulerを渡さなくていいの?
Flowで公開されたUiStateのテストを書く(collect) 128 @Test fun uiState() = runTest { val stateList
= mutableListOf<TopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( // isFollowed = false, isFollowed = true の順にUIStateが変化している ) job.cancel() } Mainディスパッチャを置き換えると、それ以降の TestDispatchersは自動的に置き換えたディスパッチャの スケジューラを共有するので省略可
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) } }
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) } }
Stateで公開されたUiStateのテストを書く 131 @Test fun uiState() { assertThat(viewModel.uiState).isEqualTo(TopicUiState(isFollowed = false)) viewModel.followTopic(true)
assertThat(viewModel.uiState).isEqualTo(TopicUiState(isFollowed = true)) }
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の順に変わっているか見る ) }
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の順に変わっているか見る ) }
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を追加して、変更を箱に入れていく
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へのキャストをしてあげると安心
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に流れてこないので、自分で設定してあげる
ComposeのSnapshotについて詳しく 「ComposeのSnapshot」Kenji Abe https://star-zero.medium.com/compose%E3%81%AEsnapshot-17414888b41b 137
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) } } }
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
UiStateの中間状態を見るテストを書く 140 @Test fun uiState() = runTest { val stateList
= mutableListOf<NewTopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiStateFlow.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( .. ) // isLoading = false -> true -> false job.cancel() }
UiStateの中間状態を見るテストを書く 141 @Test fun uiState() = runTest { val stateList
= mutableListOf<NewTopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiStateFlow.collect { stateList.add(it) } } viewModel.followTopic(true) assertThat(stateList).isEqualTo( .. ) // isLoading = false -> true -> false job.cancel() }
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) } } }
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で流れてこない
UiStateの中間状態を見るテストを書く 144 class TestUserDataRepository : UserDataRepository { override suspend fun
toggleFollowedTopicId(followedTopicId: String, followed: Boolean) { delay(100L) .. } } テスト用のリポジトリでdelayを差し込む これにより、通信完了後のisLoading = falseになる前に一度コルー チンが停止する
UiStateの中間状態を見るテストを書く 145 class TestUserDataRepository : UserDataRepository { override suspend fun
toggleFollowedTopicId(followedTopicId: String, followed: Boolean) { delay(100L) .. } } テスト用のリポジトリでdelayを差し込む これにより、通信完了後のisLoading = falseになる前に一度コルー チンが停止する
UiStateの中間状態を見るテストを書く 146 @Test fun uiState() = runTest { val stateList
= mutableListOf<NewTopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiStateFlow.collect { stateList.add(it) } } viewModel.followTopic(true) advanceUntilIdle() assertThat(stateList).isEqualTo( .. ) // isLoading = false -> true -> false job.cancel() } テストしたいメソッドを呼び出した時に isLoading = false -> isLoading = trueま でcollectできる
UiStateの中間状態を見るテストを書く 147 @Test fun uiState() = runTest { val stateList
= mutableListOf<NewTopicUiState>() val job = launch(UnconfinedTestDispatcher()) { viewModel.uiStateFlow.collect { stateList.add(it) } } viewModel.followTopic(true) advanceUntilIdle() assertThat(stateList).isEqualTo( .. ) // isLoading = false -> true -> false job.cancel() } 停止していたコルーチンを再開する
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))
リポジトリからFlowの変更を受け取る 151 class TopicViewModel @Inject constructor(val userDataRepository: UserDataRepository ) :
ViewModel() { val followedTopicIds: Flow<Set<String>> = userDataRepository.userDataStream.map{ .. } fun followTopicToggle(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } }
リポジトリからFlowの変更を受け取る 152 class TopicViewModel @Inject constructor(val userDataRepository: UserDataRepository ) :
ViewModel() { val followedTopicIds: Flow<Set<String>> = userDataRepository.userDataStream.map{ .. } fun followTopicToggle(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } } ここでリポジトリに更新をお願 いすると
リポジトリからFlowの変更を受け取る 153 class TopicViewModel @Inject constructor(val userDataRepository: UserDataRepository ) :
ViewModel() { val followedTopicIds: Flow<Set<String>> = userDataRepository.userDataStream.map{ .. } fun followTopicToggle(followed: Boolean) { viewModelScope.launch { userDataRepository.toggleFollowedTopicId(topicId, followed) } } } こっちに変更が流れてくる
リポジトリからFlowの変更を受け取る • テスト自体はUiStateの変化を見ればOK • テスト用のリポジトリにモックライブラリを使っている場合は注意 ◦ モックライブラリは差し替えたいクラスの空の実装を作り、任意の値を返すよう に設定したり、呼び出しを記録する用途で使うことが多い ◦ Flowを返す場合は、テスト側でFlowのインスタンスを設定する
◦ 保存をしたらFlowに値を流すというのを自分で設定しないと値は流れてこない 154
リポジトリからFlowの変更を受け取る • リポジトリのFake実装を自分で用意し保存時にFlowにemitするようにする ◦ リポジトリのInterfaceがきちんと定義されていれば実装しやすい • リポジトリは実クラスを使い、DataSourceを差し替える ◦ RetrofitのServiceを差し替える ◦
RoomのIn Memory DB(RobolectricかInstrumentation Testになる) • ViewModelでは保存と更新を分けてテストする(保存 + 更新はリポジトリのテスト で見る) 155
UIレイヤ データホルダーのテストで紹介したこと • メインディスパッチャをUnconfinedTestDispatcherに置き換える • UI Stateは都度値を確認、もしくは変更を継続してみることでテストする • ComposeのStateの変更を継続して見るのにはSnapshotが使える •
中間状態のUI Stateを見る場合は、delayを差し込むとテストがしやすい • リポジトリからFlowで変更を受け取る場合は、Fakeの実装を用意するか結合範囲 を変更するかを検討する 156
Agenda Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI
elementsのテストを書く 01 02 03 04 157
UIレイヤ 158 UI elements State holders • アプリデータを画面に表示する役割で、ユー ザーの操作やAPIレスポンスなどの外部入 力によってデータの変更をUIに反映する
• State holdersはUIに表示する状態(UI State)の保持と更新を管理 • UI elementsはユーザーイベントのState holderへの通知と、State holderがもつ Stateの内容のレンダリングを行う
UIレイヤ 159 UI elements State holders • アプリデータを画面に表示する役割で、ユー ザーの操作やAPIレスポンスなどの外部入 力によってデータの変更をUIに反映する
• State holdersはUIに表示する状態(UI State)の保持と更新を管理 • UI elementsはユーザーイベントのState holderへの通知と、State holderがもつ Stateの内容のレンダリングを行う
ComposeのUIテストを実装する 162 • テストでActivityやFragmentを起動して、Composable関数をテストする • ユーザーイベントの結果、UIがどのように変更されるかを自動テストできる • 開発中画面のレンダリング結果を目視で確認する必要はあるため、Previewやス クリーンショットもあわせて使う •
セットアップコストはPreviewよりもずっと高い
ComposeのUIテストを実装する 163 • テストでActivityやFragmentを起動して、Composable関数をテストする • ユーザーイベントの結果、UIがどのように変更されるかを自動テストできる • 開発中画面のレンダリング結果を目視で確認する必要はあるため、Previewやス クリーンショットもあわせて使う •
セットアップコストはPreviewよりもずっと高い UIテストのセットアップについて紹介 • Dagger Hilt • Jetpack Compose
UIテストの結合範囲のパターン 164 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ
UIテストの結合範囲のパターン 165 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ
UIテストの結合範囲のパターン 166 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ 空のActivityに、ViewModelに依存しないComposable関数を セットして起動する UI StateとUI elementsに着目したテストになり、レイヤをまたいで 処理されるユーザーイベントのテストはできない
UIテストの結合範囲のパターン 167 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ
UIテストの結合範囲のパターン 168 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ 実際のActivityやFragmentを起動、もしくは空のActivityに ViewModelを持つComposable関数をセットして起動する テストの速度・安定性のためにデータレイヤーをテストダブルに置き換 えたいケースが多い
UIテストの結合範囲のパターン 169 ドメインレイヤ(Optional) UI elements State holders UIレイヤ Repositories Data
Sources データレイヤ 実際のActivityやFragmentを起動、もしくは空のActivityに ViewModelを持つComposable関数をセットして起動する テストの速度・安定性のためにデータレイヤーをテストダブルに置き換 えたいケースが多い ➜ Dagger Hiltの出番
前準備(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" }
テスト時に使えるApplicationの制限(Hilt) 171 • @HiltAndroidAppがついたApplicationは利用できない ◦ つまりプロダクションコードのApplicationは利用できない ◦ 基本的にはHiltTestApplicationを使う ◦ カスタムAppliationを使いたい場合は@CustomTestApplicationで生成する
• @InjectフィールドがあるApplicationは利用できない ◦ EarlyEntryPointを使う
@CustomTestApplication 172 @CustomTestApplication(BaseApplication::class) interface HiltTestApplication BaseApplicationを継承したHiltTestApplication_Applicationが生 成される
EarlyEntryPoint 173 • InstrumentationTestでHiltを使うと、Componentの生成サイクルが通常のアプ リ起動時とは異なる • InstrumentationTestではApplicationはテスト開始時に1度だけ作られる • ただし、SingletonComponentはテストごとに生成される •
Application.onCreateの時点ではSingleton Componentがない • EarlyEntryPointで定義したSingleton ComponentはHiltTestApplicationの onCreateのタイミングで生成される
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 }
前準備(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) } }
前準備(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
前準備(Instrumentation Test) 177 build.gradle android { defaultConfig { testInstrumentationRunner =
"com.example.CustomTestRunner" } }
テストコードの全体像 178 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val
hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } }
Dagger hiltでリポジトリを差し替える 179 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } } まずはHiltでテスト用のリポジトリのセットアップする
Dagger hiltでリポジトリを差し替える 180 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } } テストクラスごとのComponentの定義ファイルが生成される (XXXTest_HiltsComponents)
Dagger hiltでリポジトリを差し替える 181 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } } テスト実行時にテストクラスごとのComponentを生成する
Dagger hiltでリポジトリを差し替える 182 @Module @InstallIn(SingletonComponent::class) interface DataModule { @Binds fun
bindsUserDataRepository( userDataRepository: DefaultUserDataRepository ): UserDataRepository }
Dagger hiltでリポジトリを差し替える 183 @Module @InstallIn(SingletonComponent::class) interface DataModule { @Binds fun
bindsUserDataRepository( userDataRepository: DefaultUserDataRepository ): UserDataRepository } このModuleをテスト用のModuleに置き換える
Dagger hiltでリポジトリを差し替える 184 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @Module @InstallIn(SingletonComponent::class)
class TestDataModule { @Provides fun provide(): UserDataRepository = TestUserDataRepository() } … }
Dagger hiltでリポジトリを差し替える 185 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @Module @InstallIn(SingletonComponent::class)
class TestDataModule { @Provides fun provide(): UserDataRepository = TestUserDataRepository() } … }
Dagger hiltでリポジトリを差し替える 186 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @Module @InstallIn(SingletonComponent::class)
class TestDataModule { @Provides fun provide(): UserDataRepository = TestUserDataRepository() } … } DataModuleを削除する
Dagger hiltでリポジトリを差し替える 187 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @Module @InstallIn(SingletonComponent::class)
class TestDataModule { @Provides fun provide(): UserDataRepository = TestUserDataRepository() } … } テスト用の実装に置き換えた Moduleを定義して追加する
Dagger hiltでリポジトリを差し替える 188 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @Module @InstallIn(SingletonComponent::class)
class TestDataModule { @Provides fun provide(): UserDataRepository = TestUserDataRepository() } … } 削除したModuleに定義されていた ものは全て定義する必要あり
Dagger hiltでリポジトリを差し替える 189 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @JvmField @BindValue
val userDataRepository: UserDataRepository = TestUserDataRepository() … }
Dagger hiltでリポジトリを差し替える 190 @HiltAndroidTest @UninstallModules(DataModule::class) class TopicScreenTest { @JvmField @BindValue
val userDataRepository: UserDataRepository = TestUserDataRepository() … } @BindValueを使うとテストから参照したいと きに便利 Moduleに定義されていない依存を差し替え るときは@BindValueだけでもOK
Dagger hiltでリポジトリを差し替える 191 @Module @TestInstallIn( components = [SingletonComponent::class], replaces =
[DataModule::class] ) interface TestDataModule { @Provide fun provide(): UserDataRepository = TestUserDataRepository }
Dagger hiltでリポジトリを差し替える 192 @Module @TestInstallIn( components = [SingletonComponent::class], replaces =
[DataModule::class] ) interface TestDataModule { @Provide fun provide(): UserDataRepository = TestUserDataRepository } トップレベルに定義することで すべてのテストでModuleを差し替える
Dagger hiltでリポジトリを差し替える 193 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeTestRule.setContent { // composable関数をセット } } }
ComposeをセットするActivityを起動する 194 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val
hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } }
ComposeをセットするActivityを起動する 195 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val
hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } } HiltAndroidRuleの後に適用されるようにする
ComposeをセットするActivityを起動する 196 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val
hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } } ComposeのテストをしやすくするためのRule
ComposeをセットするActivityを起動する 197 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val
hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } } 画面起動時にHiltで依存解決させるためには @AndroidEntryPointがついたActivityである必 要がある
ComposeをセットするActivityを起動する 198 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val
hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } } 実際のActivityを使うか、テスト用のActivityを別 途用意してsrc/debugに配置する
ComposeをセットするActivityを起動する 199 @AndroidEntryPoint class HiltTestActivity : ComponentActivity()
ComposeをセットするActivityを起動する 200 @AndroidEntryPoint class HiltTestActivity : ComponentActivity() debugのAndroidManifestにも追加する
ComposeをセットするActivityを起動する 201 @AndroidEntryPoint class HiltTestActivity : ComponentActivity() Hiltで依存解決されるFragmentをテストしたいと きにも使える (Fragment
Scenarioで提供されるActivityは @AndroidEntryPointはついていない)
Composeをセットする 202 @HiltViewModel class TopicViewModel @Inject constructor( .. ): ViewModel
@Composable fun TopicRoute( modifier: Modifier = Modifier, viewModel: TopicViewModel = hiltViewModel() ) { .. }
Composeをセットする 203 @HiltViewModel class TopicViewModel @Inject constructor( .. ): ViewModel
@Composable fun TopicRoute( modifier: Modifier = Modifier, viewModel: TopicViewModel = hiltViewModel() ) { .. } 今回テストしたいComposable関数 自動的にHiltによって依存解決されるので テスト用のリポジトリを参照するようになっている
Composeをセットする 204 @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test
fun test() { composeRule.setContent { TopicRoute() } // UIテストを実装していく }
Composeをセットする 205 @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test
fun test() { composeRule.setContent { TopicRoute() } // UIテストを実装していく } ComposeTestRuleによって、テスト開始時には Activityが起動している
Composeをセットする 206 @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test
fun test() { composeRule.setContent { TopicRoute() } // UIテストを実装していく } 任意のComposable関数を設定する
Composeをセットする 207 @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test
fun test() { composeRule.setContent { TopicRoute() } // UIテストを実装していく } デフォルト引数でViewModelのインスタンスも生成 されており、テスト用のリポジトリを参照するように なっている
テストの全体像 208 @HiltAndroidTest class TopicScreenTest { @get:Rule(order = 0) val
hiltTestRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeRule = createAndroidComposeRule<TestHiltActivity>() @Test fun test() { composeRule.setContent { // composable関数をセット } } }
Dispatcherの差し替え 209 • UIテストにおいて非同期処理の待ち合わせは難易度の高いポイントの1つ ◦ 同期的に実行される場合はあまり気にしなくてOK • クラスの中で直接Dispatcherを指定していると、自動で待ち合わせをするのが難し くなる ◦
ComposeTestRule#waitUntilの待ち合わせが必要になる • 自動でコルーチンを待ち合わせする手段 ◦ DispatcherをUnconfinedDispatcherに置き換える ◦ 実行を監視できるExecutorからDispatcherを作りIdlingResourceで待ち合 わせ
Dispatcherの差し替え 210 • UIテストにおいて非同期処理の待ち合わせは難易度の高いポイントの1つ ◦ 同期的に実行される場合はあまり気にしなくてOK • クラスの中で直接Dispatcherを指定していると、自動で待ち合わせをするのが難し くなる ◦
ComposeTestRule#waitUntilの待ち合わせが必要になる • 自動でコルーチンを待ち合わせする手段 ◦ DispatcherをUnconfinedDispatcherに置き換える ◦ 実行を監視できるExecutorからDispatcherを作りIdlingResourceで待ち合 わせ
Dispatcherの差し替え 211 @Qualifier @Retention(RUNTIME) annotation class IODispatcher class DefaultTopicsRepository @Inject
constructor( @IODispatcher private val ioDispatcher: CoroutineDispatcher, ) : TopicsRepository {
Dispatcherの差し替え 212 @Qualifier @Retention(RUNTIME) annotation class IODispatcher class DefaultTopicsRepository @Inject
constructor( @IODispatcher private val ioDispatcher: CoroutineDispatcher, ) : TopicsRepository { コンストラクタでIO Dispatcherを差し替えら れるようにする
Dispatcherの差し替え 213 @Module @InstallIn(SingletonComponent::class) object DispatchersModule { @Provides @IODispatcher fun
providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO } IO DispatcherをProvideするModule
Dispatcherの差し替え 214 @HiltAndroidTest @UninstallModules(DispatchersModule::class) class Test { val testDispatcher =
UnconfinedTestDispatcher() @BindValue @IODispatcher val ioDispatcher: CoroutineDispatcher = testDispatcher .. }
Dispatcherの差し替え 215 @HiltAndroidTest @UninstallModules(DispatchersModule::class) class Test { val testDispatcher =
UnconfinedTestDispatcher() @BindValue @IODispatcher val ioDispatcher: CoroutineDispatcher = testDispatcher .. } テスト側でDispatcherを差し替え (@TestInstallInでもOK)
UIレイヤ UI elementsのテストで紹介したこと • Dagger Hiltをつかって一部の依存を差し替えるためには、@HiltAndroidTestと HiltAndroidRuleを使う • 依存の差し替えにはModuleを削除してTest用のModuleを追加する •
Compose用のRuleには@AndroidEntryPointがついたActivityを設定する • ComposeTestRule#setContentで任意のComposabel関数を設定する • hiltViewModel()でViewModelにインスタンスを作っていれば、自動的にHiltに よってテスト用の依存に置き換わっている • DispacherはHiltで差し替えられるようになっていると、自動待ち合わせが実現し やすい 216
まとめ Guide to app architectureに登場するレイヤ データレイヤのテストを書く UIレイヤのテスト ステートホルダーのテストを書く UIレイヤのテスト UI
elementsのテストを書く 01 02 03 04 217
データレイヤのテストで紹介したこと • コルーチンのテストはrunTestで囲む • Flowはfirst()でemitされたデータを検証する • Flowをcollectしてテストするときは、UnconfinedTestDispatcherで新しいコルー チンを起動する • advanceUntilIdleやadvanceTimeByを使うことでコルーチンの実行をテストコー
ドで管理でき、排他制御などのテストが楽にかける • runTest内(TestDispatcherで実行されるコルーチン)のdelayは仮想時間内で遅 延するだけなので、テストの実行時間に影響しない • Dispatcherは外から渡せるようにすることで、コルーチンの実行がコントロールし やすくなる 218
UIレイヤ データホルダーのテストで紹介したこと • メインディスパッチャをUnconfinedTestDispatcherに置き換える • UI Stateは都度値を確認、もしくは変更を継続してみることでテストする • ComposeのStateの変更を継続して見るのにはSnapshotが使える •
中間状態のUI Stateを見る場合は、delayを差し込むとテストがしやすい • リポジトリからFlowで変更を受け取る場合は、Fakeの実装があるとテストがしやす い 219
UIレイヤ UI elementsのテストで紹介したこと • Dagger Hiltをつかって一部の依存を差し替えるためには、@HiltAndroidTestと HiltAndroidRuleを使う • 依存の差し替えにはModuleを削除してTest用のModuleを追加する •
Compose用のRuleには@AndroidEntryPointがついたActivityを設定する • ComposeTestRule#setContentで任意のComposabel関数を設定する • hiltViewModel()でViewModelにインスタンスを作っていれば、自動的にHiltに よってテスト用の依存に置き換わっている • DispacherはHiltで差し替えられるようになっていると、自動待ち合わせが実現し やすい 220
参考リンク • 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
222 ご静聴ありがとうございました!