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

Androidアプリの良いユニットテストを考える / Thinking about good unit tests for Android apps

tkmnzm
September 15, 2023

Androidアプリの良いユニットテストを考える / Thinking about good unit tests for Android apps

DroidKaigi 2023 セッション「Androidアプリの良いユニットテストを考える」の発表資料です。
https://2023.droidkaigi.jp/timetable/495066/

tkmnzm

September 15, 2023
Tweet

More Decks by tkmnzm

Other Decks in Technology

Transcript

  1. 処理の分岐 検証したいパスに到達するための難しさ 関数A 関数B 関数C 関数D 関数E 関数F 関数G 関数H

    関数H 関数Aからテストを始める場合、 その前の分岐も考慮する必要がある
  2. 良いユニットテストとは何か • 再現性と独立性がありテストが安定している • 迅速なフィードバックを得られる • テストしたい箇所のテストを実行するのが容易 • リグレッションを検知できる •

    テストによる誤検知が少ない • テストコードが保守しやすい ソフトウェアの変更が容易な状態を 作るために必要なことから考えたもの
  3. 良いユニットテストの特徴を整理する上で参考にした書籍 竹辺 靖昭 (監修), Titus Winters (編集), Tom Manshreck (編集),

    Hyrum Wright (編集), 久富木 隆一 (翻訳) 「Googleのソフトウェアエンジニアリング ―持続可能なプログラミングを支える技術、文化、プロセス」 オライリージャパン, 2021
  4. 自動テストの実行環境 Emulator 実機 Robolectric Robolectric ではない Local Test Local Test

    Instrumentation Test 今回は実機とEmulator の区分は重要ではない のでまとめて考える 同じマシン上で実行
  5. 自動テストの実行環境 Emulator 実機 Robolectric Robolectric ではない Local Test Instrumentation Test

    同じマシン上で実行 Local Testの2つは分けて考える 以降、RobolectricではないLocal Testは Pure Local Testとする
  6. 実環境への近さ • Pure Local TestではAndroidフレームワークのコードを呼び出すこと はできないが、RobolectricとInstrumentation Testはできる • Robolectricはフレームワークのコードが動かない箇所を独自の実装 に置き換わるため、実環境への近さはInstrumentaion

    Testのほうが 近い • 以前UIテストはInstrumentaion Testでしかできなかったが、 RobolectricでもInstrumentaion Testと同じテスト実装で同じテスト 結果を得られるような実装が進められてきた
  7. 実環境への近さ Androidフレームワークに 依存するコード • View、Compose • Activity、Fragment • Context、Resources •

    SQLite、SharedPreferences 等を使ったコード Androidフレームワークに 依存しないコード その他アプリのロジックや 通信処理等
  8. 実行速度の違い @Test fun test(){ assertEquals(4, 2 + 2) } Local

    TestとRobolectricで 同じテストを手元で実行する
  9. 実行速度の違い @Test fun test(){ assertEquals(4, 2 + 2) } テストクラス全体の実行時間

    (テストメソッドは1件) Pure Local Test 2ms Robolectric 1sec200ms
  10. 実行速度の違い @get:Rule val composeRule = createComposeRule() @Test fun test() {

    composeRule.setContent { Greeting("hello") } composeRule.onNodeWithText("hello") }
  11. 実行速度の違い @get:Rule val composeRule = createComposeRule() @Test fun test() {

    composeRule.setContent { Greeting("hello") } composeRule.onNodeWithText("hello") } 簡単なUIテストをRobolectricと Instrumentation Testで実行
  12. 実行速度の違い @get:Rule val composeRule = createComposeRule() @Test fun test() {

    composeRule.setContent { Greeting("hello") } composeRule.onNodeWithText("hello") } テストクラス全体の実行時間 (テストメソッドは1件) Robolectric 1sec787ms Instrumentation Test 1sec
  13. 実行速度の違い @get:Rule val composeRule = createComposeRule() @Test fun test() {

    composeRule.setContent { Greeting("hello") } composeRule.onNodeWithText("hello") } テストクラスの中で同じテストを3回実行すると Robolectric 1sec858ms (2回目以降は数十msで実行) Instrumentation Test 5sec
  14. 実行環境ごとの特徴 Instrumentation Test Robolectric Pure Local Test 速い 遅い 実行速度

    実環境への 近さ 遠い 近い CI環境では実行速度に加えて端末と接続するコストが 追加され、他2つの環境との実行時間の差が更に広がる
  15. テストダブルによるリグレッション検知失敗の例 // 検索結果がないときはResult.FailureでItemNotFondExceptionを返す interface SearchRepository { suspend fun search(keyword: String):

    Result<List<Item>> } // リポジトリを使っているクラスのテストコード // 検索結果がない場合のテストでのスタブ(依存オブジェクトからの入力)設定 val searchRepository = mockk<SearchRepository> { coEvery { search(any()) } returns Result.failure(ItemNotFondException()) }
  16. テストダブルによるリグレッション検知失敗の例 // 検索結果がないときはResult.FailureでItemNotFondExceptionを返す interface SearchRepository { suspend fun search(keyword: String):

    Result<List<Item>> } // リポジトリを使っているクラスのテストコード // 検索結果がない場合のテストでのスタブ(依存オブジェクトからの入力)設定 val searchRepository = mockk<SearchRepository> { coEvery { search(any()) } returns Result.failure(ItemNotFondException()) } 検索結果が無いときは空のリストを返す ように振る舞いを変更しよう
  17. テストダブルによるリグレッション検知失敗の例 // New: 検索結果がないときは空のアイテムを返すように変更する interface SearchRepository { suspend fun search(keyword:

    String): Result<List<Item>> } // リポジトリを使っているクラスのテストコード // 検索結果がない場合のテストでのスタブ(依存オブジェクトからの入力)設定 val searchRepository = mockk<SearchRepository> { coEvery { search(any()) } returns Result.failure(ItemNotFondException()) }
  18. テストダブルによるリグレッション検知失敗の例 // New: 検索結果がないときは空のアイテムを返すように変更する interface SearchRepository { suspend fun search(keyword:

    String): Result<List<Item>> } // リポジトリを使っているクラスのテストコード // 検索結果がない場合のテストでのスタブ(依存オブジェクトからの入力)設定 val searchRepository = mockk<SearchRepository> { coEvery { search(any()) } returns Result.failure(ItemNotFondException()) } Repositoryに依存しているクラスではエラーの内容を 見て検索結果がないときの実装をしていたところを 修正する必要があるが、それを忘れていた...
  19. テストダブルによるリグレッション検知失敗の例 // New: 検索結果がないときは空のアイテムを返すように変更する interface SearchRepository { suspend fun search(keyword:

    String): Result<List<Item>> } // リポジトリを使っているクラスのテストコード // 検索結果がない場合のテストでのスタブ(依存オブジェクトからの入力)設定 val searchRepository = mockk<SearchRepository> { coEvery { search(any()) } returns Result.failure(ItemNotFondException()) } スタブは振る舞いをハードコーディングするため、 スタブの設定の更新も必要
  20. テストダブルによるリグレッション検知失敗の例 // New: 検索結果がないときは空のアイテムを返すように変更する interface SearchRepository { suspend fun search(keyword:

    String): Result<List<Item>> } // リポジトリを使っているクラスのテストコード // 検索結果がない場合のテストでのスタブ(依存オブジェクトからの入力)設定 val searchRepository = mockk<SearchRepository> { coEvery { search(any()) } returns Result.failure(ItemNotFondException()) } スタブの更新も忘れてしまうと、検索結果がないときの 挙動が間違っているにも関わらずテストは成功する
  21. 依存オブジェクトへの出力の検証でテストが失敗する例 interface SearchRepository { fun search(keyword: String): Result<List<Item>> fun saveSearchKeywordHistory(keyword:

    String) } // リポジトリを使っているクラスの検索メソッド fun search(keyword: String) { searchRepository.saveSearchKeywordHistory(keyword) searchRepository.search(keyword) }
  22. 依存オブジェクトへの出力の検証でテストが失敗する例 interface SearchRepository { fun search(keyword: String): Result<List<Item>> } //

    リポジトリを使っているクラスの検索メソッド fun search(keyword: String) { searchRepository.search(keyword) }
  23. フェイク interface UserDataSource { suspend fun getUser(): User suspend fun

    saveUser(user: User) } // テスト対象メソッド Userを保存し、最新のUserを取得する関数 suspend fun updateUser(user: User) : User { userDataSource.saveUser(user) return userDataSource.getUser() }
  24. フェイク // スタブ・スパイを使ったテストのイメージ val user = User.filledUser() val userDataSource =

    mockk<UserDataSource> { coEvery { getUser() } returns user } val updatedUser = sut.updateUser(user) coVerify { userDataSource.saveUser(user) } assertEquals(User.filledUser(), updatedUser)
  25. フェイク // UserDataSourceのFake実装 class FakeUserDataSource : UserDataSource { private var

    user: User = User.emptyUser() override suspend fun getUser(): User { return user } override suspend fun saveUser(user: User) { this.user = user } }
  26. フェイク // Fakeを使ったテストのイメージ val userDataSource = FakeUserDataStore() val updatedUser =

    sut.updateUser(User.filledUser()) assertEquals(User.filledUser(), updatedUser)
  27. フェイク // Fakeを使ったテストのイメージ val userDataSource = FakeUserDataStore() val updatedUser =

    sut.updateUser(User.filledUser()) assertEquals(User.filledUser(), updatedUser) テスト対象の公開されたAPIを使ったテストに なっているため、メソッドの中の変更に強い
  28. フェイク // スタブ・スパイを使ったテストのイメージ val user = User.filledUser() val userDataSource =

    mockk<UserDataSource> { coEvery { getUser() } returns user } val updatedUser = sut.updateUser(user) coVerify { userDataSource.saveUser(user) } assertEquals(user, updatedUser) スタブとスパイを使ったテストコードは テスト対象の実装の内部を見ている状態
  29. 独立性を改善する • テストの独立性が下がる主なパターン ◦ 永続化された情報 ◦ mutableなStatic変数 ◦ 実行環境 テストダブルを使うことでも解決できるが、

    RobolectricではSQLiteやPreferencesに保存 したデータやContext配下に保存したファイル はテスト間で共有されず独立した状態 (※Instrumentaion Testでは共有される)
  30. • テストの独立性が下がる主なパターン ◦ 永続化された情報 ◦ mutableなStatic変数 ◦ 実行環境 独立性を改善する Static変数はどのテスト実行環境でも

    テスト間をまたいで共有される 設計を見直すか、テスト終了時にクリー ンアップする手段を用意する
  31. 独立性を改善する // 端末のロケールに合わせて値段の表記を決める関数 fun formatPrice(price: BigDecimal): String { val numberFormat

    = NumberFormat.getCurrencyInstance(Locale.getDefault()) … } mutableなstatic変数かつ実行環境の影響を 受ける状態
  32. 独立性を改善する // 端末のロケールに合わせて値段の表記を決める関数 fun formatPrice(price: BigDecimal, locale: Locale = ..):

    String { val numberFormat = NumberFormat.getCurrencyInstance(locale) … } Localeを引数に渡せるようにすることで、 テストではLocaleを指定すれば他のテストや実行環境 の影響を受けずに同じ結果を返すようになる
  33. Humble Objectパターン class UserViewModel(..) { fun onNameChanged(name: String) { if

    (value.length > 20) return _uiState.update { it.copy( user = it.user.copy(name = name), isSaveEnabled = value.isNotEmpty() ) } } }
  34. Humble Objectパターン class UserViewModel(..) { fun onNameChanged(name: String) { if

    (value.length > 20) return _uiState.update { it.copy( user = it.user.copy(name = name), isSaveEnabled = value.isNotEmpty() ) } } } このメソッドをつつましくする
  35. Humble Objectパターン class UserViewModel(..) { fun onNameChanged(name: String) { if

    (name.length > 20) return _uiState.update { it.copy( user = it.user.copy(name = name), isSaveEnabled = name.isNotEmpty() ) } } }
  36. Humble Objectパターン class UserViewModel(..) { fun onNameChanged(name: String) { if

    (name.length > 20) return _uiState.update { it.copy( user = it.user.copy(name = name), isSaveEnabled = name.isNotEmpty() ) } } } Userクラスで名前が20文字を超えて 更新できないように制御しちゃう
  37. Humble Objectパターン class UserViewModel(..) { fun onNameChanged(name: String) { if

    (name.length > 20) return _uiState.update { it.copy( user = it.user.copy(name = name), isSaveEnabled = name.isNotEmpty() ) } } } Userの情報を見てUI Stateのほうで 判断してもらおう
  38. Humble Objectパターン class UserViewModel(..) { fun onNameChanged(name: String) { _uiState.update

    { it.copy( user = it.user.updateName(name = name) ) } } } ViewModelの中からUIのStateを作るためのロジックや ドメインロジックが切り離された
  39. Humble Objectパターン class UserViewModel(..) { fun onNameChanged(name: String) { _uiState.update

    { it.copy( user = it.user.updateName(name = name) ) } } } ViewModel経由ではなく、UserとUI Stateの単体テストで 確認できる実装が増えた
  40. テストデータの生成をまとめる val sut = UiState( user = User( // 各テストで直接Userのインスタンスを作っている場合

    lastName = "lastName", firstName = "firstName", birthDate = null, height = 0f, weight = 0f ) ) assertTrue(uiState.isSaveButtonEnable)
  41. テストデータの生成をまとめる val sut = UiState( user = User( // 各テストで直接Userのインスタンスを作っている場合

    lastName = "lastName", firstName = "firstName", birthDate = null, height = 0f, weight = 0f ) ) assertTrue(uiState.isSaveButtonEnable) Userにフィールドが追加されたら、Userを生成し ている全てのテストがコンパイルエラーになる
  42. テストデータの生成をまとめる val sut = UiState( user = emptyUser.copy( // 共通のユーザー定義を使う場合

    lastName = "lastName", firstName = "firstName", ) ) assertTrue(uiState.isSaveButtonEnable)
  43. テストデータの生成をまとめる val sut = UiState( user = emptyUser.copy( // 共通のユーザー定義を使う場合

    lastName = "lastName", firstName = "firstName", ) ) assertTrue(uiState.isSaveButtonEnable) テストに関係のあるフィールドだけセットする
  44. テストデータの生成をまとめる val sut = UiState( user = emptyUser.copy( // 共通のユーザー定義を使う場合

    lastName = "lastName", firstName = "firstName", ) ) assertTrue(uiState.isSaveButtonEnable) Userにフィールドが増えても修正するのは共通の定義だけで良い
  45. テスト対象や依存オブジェクトの生成をまとめる // テスト対象クラスの生成をまとめたテスト用ヘルパー private fun createViewModel( userRepository: UserRepository = TestUserRepository(),

    userConfigRepository : UserConfigRepository = .. ) : UserViewModel { return UserViewModel( profileRepository = profileRepository, userConfigRepository = userConfigRepository ) }
  46. テスト対象や依存オブジェクトの生成をまとめる // テスト対象クラス class UserViewModel( val profileRepository: UserRepository, val userConfigRepository

    : UserConfigRepository, val logTracker : LogTracker, // New ) テストケース毎にコンストラクタの指定をして いるとそれぞれ修正する必要があるが、生成を 1つにまとめていれば1箇所修正すればよい
  47. • テストダブルと実オブジェクトの振る舞いの互換性がなく、失敗する べきテストが成功してしまう • @Testをつけ忘れたり、Assertがなく検証ができていない • Assertionするべき項目が間違っている • 境界値チェックができておらず、境界値に実装ミスがあった •

    非同期処理でテストでは1つのスレッドで確認していたが、複数ス レッドで呼び出すと問題になる実装があった リグレッション検知失敗の例 残念ながら仕組みで防ぐのが難しいものが多い テスト実装時やレビュー時にチェックする必要がある
  48. 何をテストしているか理解しやすくする @Test fun getUser_error() { val userRepository = TestUserRepository() val

    useViewModel = UserViewModel(userRepository) useViewModel.getUser() assertTrue( useViewModel.uiState.value.shouldDisplayRetryButton) }
  49. 何をテストしているか理解しやすくする @Test fun getUser_error() { val userRepository = TestUserRepository() val

    useViewModel = UserViewModel(userRepository) useViewModel.getUser() assertTrue( useViewModel.uiState.value.shouldDisplayRetryButton) } テストメソッド内を Arrange(準備)/Act(実 行)/Assert(確認)のフェー ズで区切ってみよう
  50. 何をテストしているか理解しやすくする @Test fun getUser_error() { val userRepository = TestUserRepository() val

    useViewModel = UserViewModel(userRepository) useViewModel.getUser() assertTrue( useViewModel.uiState.value.shouldDisplayRetryButton) }
  51. 何をテストしているか理解しやすくする @Test fun getUser_error() { val userRepository = TestUserRepository() val

    useViewModel = UserViewModel(userRepository) useViewModel.getUser() assertTrue( useViewModel.uiState.value.shouldDisplayRetryButton) } テスト対象のコードが どのような振る舞いになるのか もっと明確にしてみよう
  52. パラメタライズドテストを読みやすくする fun checkAge(age: Int) : Boolean { if(age in 18..59)

    { return true } else { logger.trackInvalidUser(OUT_OF_AGE_RANGE) return false } }
  53. パラメタライズドテストを読みやすくする fun checkAge(age: Int) : Boolean { if(age in 18..59)

    { return true } else { logger.trackInvalidUser(OUT_OF_AGE_RANGE) return false } } 機能を利用可能な年齢かを認確する メソッド 年齢が無効なときはログ送信
  54. パラメタライズドテストを読みやすくする listOf( TestCase(17, false, OUT_OF_AGE_RANGE, true), TestCase(18, true, null, false),

    TestCase(59, true, null, false), TestCase(60, false, OUT_OF_AGE_RANGE, true), ) テストでメソッドで どう使っているか?
  55. 参考書籍 竹辺 靖昭 (監修), Titus Winters (編集), Tom Manshreck (編集),

    Hyrum Wright (編集), 久富木 隆一 (翻訳) 「Googleのソフトウェアエンジニアリング ―持続可能なプログラミングを支える技術、文化、プロセス」 オライリージャパン, 2021