Slide 1

Slide 1 text

Androidアプリの 良いユニットテストを考える Nozomi Takuma DroidKaigi 2023

Slide 2

Slide 2 text

自己紹介 ● Nozomi Takuma ● DeNA SWETグループ ● Androidとテストが好き

Slide 3

Slide 3 text

今日話すことのゴール

Slide 4

Slide 4 text

今日話すことのゴール ● 良いユニットテストは何か?を考えることで、どのような 自動テストを書くのが適しているか悩む場面に遭遇した際 にメリット・デメリットを踏まえて方針決定ができるよう になる ● 既にある自動テストをより良いテストに改善できるように なる

Slide 5

Slide 5 text

目次 ● 良いユニットテストとは何か ● Androidアプリの自動テスト環境 ● テストダブルのトレードオフ ● 良いユニットテストになるように改善する

Slide 6

Slide 6 text

良いユニットテストとは何か

Slide 7

Slide 7 text

なぜテストを自動化するのか ● アプリの変更が容易な状態を保つため ○ 自動化されたテストによるセーフティーネットがリグレッション から守ってくれる状態にすることでアプリの成長を支える ○ アプリが成長するに従って複雑化しても、リファクタリング等 内部構造の改善を安心してできるようにする

Slide 8

Slide 8 text

なぜユニットテストをするのか ● テストしたいすべてのパターンをE2Eテストでカバーするのは難しい ○ 安定性 ○ フィードバックループの長さ ○ 検証したいパスに到達する難しさ ● アプリのコードを区切ってテストすることで、これらの問題を解決 したい

Slide 9

Slide 9 text

フィードバックループの長さ ● テストの実行時間 ● 書いたコードを動作確認ができるまでの時間 ● テストが失敗したときにどこに問題があるのかを見つけるまでの時間

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

検証したいパスに到達するための難しさ 関数A 関数B 関数C 関数D 関数E 関数F 関数G 関数H 関数H 関数Eからテストが始められれば簡単 処理の分岐

Slide 13

Slide 13 text

処理の分岐 検証したいパスに到達するための難しさ 関数A 関数B 関数C 関数D 関数E 関数F 関数G 関数H 関数H 関数Aからテストを始める場合、 その前の分岐も考慮する必要がある

Slide 14

Slide 14 text

ユニットテストが目指すもの ● 結合範囲が広いテストが持つ課題をクリアする ○ 安定性 ○ フィードバックループの長さ ○ 検証したいパスに到達する難しさ ● その上でソフトウェアの変更が容易な状態を保つのに貢献する

Slide 15

Slide 15 text

ソフトウェアの変更が容易な状態にテストがどう貢献する? ● テストが開発者から信頼されている ● テストをグリーンに保つコストが低い ● テストを保守するコストが低い

Slide 16

Slide 16 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 17

Slide 17 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードが保守しやすい 結合範囲が広いテストが持つ課題から 考えたもの

Slide 18

Slide 18 text

良いユニットテストとは何か ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードが保守しやすい ソフトウェアの変更が容易な状態を 作るために必要なことから考えたもの

Slide 19

Slide 19 text

再現性と独立性がありテストが安定している ● 再現性を下げるものから切り離されている ● テストが他のテストや環境の影響を受けずに独立している

Slide 20

Slide 20 text

迅速なフィードバックを得られる ● テストの実行時間が短い ● 書いたコードを動作確認ができるまでの時間が短い ● テストが失敗したときにどこに問題があるのかを見つけるまでの 時間が短い

Slide 21

Slide 21 text

テストしたい箇所のテストを実行するのが容易 ● テストを実行するためのセットアップが容易 ● テストしたいパターンを網羅するのが容易

Slide 22

Slide 22 text

リグレッションを検知できる ● 実装に問題があったときにテストが検知できる ○ 保護すべき箇所に自動テストがなく、問題のあるコードがリリース されてしまう状況が少ない ○ 保護すべき箇所に自動テストがあったにも関わらず、テストの 書き方の問題でテストは成功している状況が少ない

Slide 23

Slide 23 text

テストによる誤検知が少ない ● 実装には問題がないにも関わらず、テストだけが失敗してしまう頻度 が少ない ○ リファクタリングによって振る舞いが変わらないにも関わらず テストが失敗する頻度が少ない ○ 新機能追加によって既存のテストが失敗する頻度が少ない

Slide 24

Slide 24 text

テストコードが保守しやすい ● テストコードが読みやすく理解しやすい ● テストコードの修正・追加がしやすい

Slide 25

Slide 25 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 26

Slide 26 text

良いユニットテストの特徴を整理する上で参考にした書籍 Vladimir Khorikov (著), 須田智之 (翻訳) 「単体テストの考え方/使い方 」 マイナビ出版, 2022

Slide 27

Slide 27 text

良いユニットテストの特徴を整理する上で参考にした書籍 竹辺 靖昭 (監修), Titus Winters (編集), Tom Manshreck (編集), Hyrum Wright (編集), 久富木 隆一 (翻訳) 「Googleのソフトウェアエンジニアリング ―持続可能なプログラミングを支える技術、文化、プロセス」 オライリージャパン, 2021

Slide 28

Slide 28 text

良いユニットテストの特徴を整理する上で参考にした書籍 Gerard Meszaros (著) 「xUnit Test Patterns: Refactoring Test Code 」 Addison-Wesley Professional, 2007

Slide 29

Slide 29 text

Androidアプリの自動テスト環境

Slide 30

Slide 30 text

Instrumentation Test (androidTest/src) 実機 Emulator Local Test (test/src) Robolectric RobolectricではないLocal Test 自動テストの実行環境

Slide 31

Slide 31 text

Robolectric ● JVM上でAndroidフレームワークをエミュレートしてくれるテスト フレームワーク ● Instrumentation TestでなくてもAndroidフレームワークに依存した テストを実装できる

Slide 32

Slide 32 text

自動テストの実行環境 同じマシン上で実行 Emulator 実機 Instrumentation Test Robolectric Robolectric ではない Local Test Local Test

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

自動テストの実行環境 Emulator 実機 Robolectric Robolectric ではない Local Test Instrumentation Test 同じマシン上で実行 Local Testの2つは分けて考える 以降、RobolectricではないLocal Testは Pure Local Testとする

Slide 35

Slide 35 text

実行環境ごとの特徴 Instrumentation Test Robolectric Pure Local Test 速い 遅い 実行速度 実環境への 近さ 遠い 近い

Slide 36

Slide 36 text

実環境への近さ ● Pure Local TestではAndroidフレームワークのコードを呼び出すこと はできないが、RobolectricとInstrumentation Testはできる ● Robolectricはフレームワークのコードが動かない箇所を独自の実装 に置き換わるため、実環境への近さはInstrumentaion Testのほうが 近い ● 以前UIテストはInstrumentaion Testでしかできなかったが、 RobolectricでもInstrumentaion Testと同じテスト実装で同じテスト 結果を得られるような実装が進められてきた

Slide 37

Slide 37 text

実環境への近さ Androidフレームワークに 依存するコード ● View、Compose ● Activity、Fragment ● Context、Resources ● SQLite、SharedPreferences 等を使ったコード Androidフレームワークに 依存しないコード その他アプリのロジックや 通信処理等

Slide 38

Slide 38 text

実行速度の違い ● 実行環境の特徴によりAndroidフレームワークに依存したコードの テストは、依存しないコードと比べるとテストの実行時間が伸びがち ● Robolectricはテストクラスの中で最初に実行されたテストにセット アップのオーバヘッドがかかり、それ以降は高速に実行される ● Instrumentation Testはテストケース1件1件が遅くなりやすい

Slide 39

Slide 39 text

実行速度の違い @Test fun test(){ assertEquals(4, 2 + 2) } Local TestとRobolectricで 同じテストを手元で実行する

Slide 40

Slide 40 text

実行速度の違い @Test fun test(){ assertEquals(4, 2 + 2) } テストクラス全体の実行時間 (テストメソッドは1件) Pure Local Test 2ms Robolectric 1sec200ms

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

実行速度の違い @get:Rule val composeRule = createComposeRule() @Test fun test() { composeRule.setContent { Greeting("hello") } composeRule.onNodeWithText("hello") } テストクラスの中で同じテストを3回実行すると Robolectric 1sec858ms (2回目以降は数十msで実行) Instrumentation Test 5sec

Slide 45

Slide 45 text

実行環境ごとの特徴 Instrumentation Test Robolectric Pure Local Test 速い 遅い 実行速度 実環境への 近さ 遠い 近い

Slide 46

Slide 46 text

実行環境ごとの特徴 Instrumentation Test Robolectric Pure Local Test 速い 遅い 実行速度 実環境への 近さ 遠い 近い CI環境では実行速度に加えて端末と接続するコストが 追加され、他2つの環境との実行時間の差が更に広がる

Slide 47

Slide 47 text

自動テスト全体の実行速度を最大化する ● Androidフレームワークに依存したコードと依存していないアプリの ロジックを切り離してPure Local Testで実行できるようにする ● Androidフレームワークに依存したコードはRobolecticで実行する ● Robolectricでは忠実度が不足している箇所をInstrumentation Test で実行する

Slide 48

Slide 48 text

目次 ● 良いユニットテストとは何か ● Androidアプリの自動テスト環境 ● テストダブル(後述)のトレードオフ ● 良いユニットテストになるように改善する

Slide 49

Slide 49 text

テストダブルのトレードオフ

Slide 50

Slide 50 text

テストダブル ● テスト対象が依存しているオブジェクトの代役になるテスト用の実装 ● Androidアプリ開発ではMoccKやMockitoといったライブラリが使われ ることが多い ● 今回の話はテストダブルのうち、スタブ・スパイ・フェイクがメイン

Slide 51

Slide 51 text

テストダブル スタブ 依存オブジェクトからテスト対象への入力 を置き換える スパイ テスト対象から依存オブジェクトへの出力 を検証できるようにする フェイク 依存オブジェクトと同じように振る舞う テスト用の実装

Slide 52

Slide 52 text

テストダブル スタブ 依存オブジェクトからテスト対象への入力 を置き換える スパイ テスト対象から依存オブジェクトへの出力 を検証できるようにする フェイク 依存オブジェクトと同じように振る舞う テスト用の実装 モックライブラリでは 1つのモックオブジェクトで両方の 機能を使えるようになっている

Slide 53

Slide 53 text

テストダブルのメリット ● テストの決定性をあげて常に同じ結果になるようにする ● テストの実行速度をあげる ● 再現の難しい状態を再現できる

Slide 54

Slide 54 text

テストダブルのデメリット ● 実オブジェクトの振る舞いが変わったときに、テストダブルもそれを 模倣するように修正する必要がある。修正が漏れて振る舞いがズレた ままだと、テストがリグレッションを検知できない可能性がある ● 依存オブジェクトへの出力の検証をやりすぎると、テスト対象の実装 とテストコードが密接に結びついてしまい、少しの修正やリファクタ リングであってもテストの失敗につながる可能性がある

Slide 55

Slide 55 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 56

Slide 56 text

良いユニットテストとテストダブルの関係 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードが保守しやすい テストダブルはこの3つを実現するための手段 の1つ

Slide 57

Slide 57 text

良いユニットテストとテストダブルの関係 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードが保守しやすい テストダブルがこの2つに悪影響を 与える可能性がある

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

依存オブジェクトへの出力の検証でテストが失敗する例 // リポジトリを使っているクラスのテスト // 依存オブジェクトへの出力(スパイ)を検証 verify { searchRepository.saveSearchKeywordHistory(keyword) searchRepository.search(keyword) }

Slide 66

Slide 66 text

依存オブジェクトへの出力の検証でテストが失敗する例 interface SearchRepository { fun search(keyword: String): Result> fun saveSearchKeywordHistory(keyword: String) } Repositoryのsearchメソッドの中で 検索ワードの保存までやったほうがいいのでは?

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

依存オブジェクトへの出力の検証でテストが失敗する例 // リポジトリを使っているクラスのテスト // 依存オブジェクトへの出力(スパイ) verify { searchRepository.saveSearchKeywordHistory(keyword) searchRepository.search(keyword) } テストは失敗する 今回はコンパイルエラーでわかるパターン だが、実行時に失敗するケースもある

Slide 69

Slide 69 text

テストダブルを使いすぎることの落とし穴 ● テストダブルは実オブジェクトとのI/Fとテストで利用される振る舞 いとの互換性を保つ必要があり、維持するコストがかかる ● テストダブルを使わなくてもいい場面では、実オブジェクトを使った ほうが互換性を気にしなくてもよくなる ● MockKやMockitoはfinalクラスであってもテストダブルが作れてしま うため、テストを書く簡単さを優先しすぎてテストダブルを多用しす ぎないように注意が必要

Slide 70

Slide 70 text

スタブを使うべき箇所 ● 現在時刻や乱数に依存する箇所 ● APIや外部サービス等アプリの外部から入力を受け取る箇所 ● BuildConfigなどappモジュールに実体があるものを、他モジュールで も参照している箇所 ● 発生させるのが難しいエラーをエミュレートしたいとき ● 依存オブジェクトからの非同期処理に対して遅延している状態を エミュレートしたいとき

Slide 71

Slide 71 text

スパイを使うべき箇所 ● APIや外部サービスなどアプリの外部への出力をする箇所 ● 依存オブジェクトのメソッドを呼び出す順序や呼び出す回数が 重要な箇所

Slide 72

Slide 72 text

テストダブルを使うか使わないかを検討する箇所 ● SQLite・SharedPreferences・DataStore等端末内への永続化 ● Androidフレームワークに依存している箇所 ● アプリのコード内の設計上の境界

Slide 73

Slide 73 text

端末内への永続化 ● 永続化の処理はInterface化されていることが多いが、必ずしもテス トダブルに差し替える必要はない ● Robolectricを使えばLocal TestでSQLiteやPreferenceが使用できる ○ DataStoreはRobolectricも不要

Slide 74

Slide 74 text

端末内への永続化 ● 例えばRepositoryのテストでは、Robolectricを使ってSQLiteや Preferencesと結合させたテストにすることができる ○ 発生させるのが難しいエラーのテストをしたいときのみ テストダブルを使うこともできる ● 一方でRepositoryに依存している実装のテストでは、テストしたい ロジックに集中するためにRepositoryをテストダブルに置き換える こともできる

Slide 75

Slide 75 text

端末内への永続化 ● 例えばRepositoryのテストでは、Robolectricを使ってSQLiteや Preferencesと結合させたテストにすることができる ○ 発生させるのが難しいエラーのテストをしたいときのみ テストダブルを使うこともできる ● 一方でRepositoryに依存している実装のテストでは、テストしたい ロジックに集中するためにRepositoryをテストダブルに置き換える こともできる テストダブルを使ったときと比べると、 テストの実行速度は伸びるがテストダブルの互換性 を維持するコストは減る

Slide 76

Slide 76 text

端末内への永続化 ● 例えばRepositoryのテストでは、Robolectricを使ってDaoや Preferencesと結合させたテストにすることができる ○ 発生させるのが難しいエラーのテストをしたいときのみ テストダブルを使うこともできる ● 一方でRepositoryに依存している実装のテストでは、テストしたい ロジックに集中するためにRepositoryをテストダブルに置き換える こともできる 実オブジェクトを使ったときと比べると、テストを書き始める コストが下がる&実行時間が短くなる 一方でテストダブルの振る舞いの互換性を維持するコストが増える

Slide 77

Slide 77 text

Androidフレームワークに依存している箇所 ● まずはテストしたい箇所がRobolectricもテストダブルも使わずに テストできないかを検討する ○ 例: Serviceクラス内に実装されたロジックをServiceから切り離 してテストできるようにする ● Robolectricを使えばAndroidフレームワークに依存している実装の Local Testも書けるが、テスト全体の実行速度を最大化するためには Robolectricを使う箇所も部分的であるほうが良い

Slide 78

Slide 78 text

フェイク ● 依存オブジェクトと同じように振る舞うテスト用の実装 ● 例えばAPI通信をするデータソースをオンメモリ上のデータを返す フェイクオブジェクトに置き換えることができれば、テストの実行 速度を下げずにテストダブルの設定を減らすことができる ● スタブ・スパイを比較するとテストがリファクタリングに強くなる ● 必ずしも自分が欲しいフェイクが既に用意されているとは限らない

Slide 79

Slide 79 text

フェイク 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() }

Slide 80

Slide 80 text

フェイク // スタブ・スパイを使ったテストのイメージ val user = User.filledUser() val userDataSource = mockk { coEvery { getUser() } returns user } val updatedUser = sut.updateUser(user) coVerify { userDataSource.saveUser(user) } assertEquals(User.filledUser(), updatedUser)

Slide 81

Slide 81 text

フェイク // 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 } }

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

フェイク // スタブ・スパイを使ったテストのイメージ val user = User.filledUser() val userDataSource = mockk { coEvery { getUser() } returns user } val updatedUser = sut.updateUser(user) coVerify { userDataSource.saveUser(user) } assertEquals(user, updatedUser) スタブとスパイを使ったテストコードは テスト対象の実装の内部を見ている状態

Slide 85

Slide 85 text

フェイクを実装する場合の注意点 ● 作成のコストがかかる ● 実オブジェクトと振る舞いの互換性を維持するコストは必要 ● 実オブジェクトと同じように振る舞うことをテストで確認するのが 望ましい ● テストしたい条件によってはフェイクでは再現が難しく、スタブや スパイと組み合わせる必要がある

Slide 86

Slide 86 text

フェイクを実装する場合の注意点 ● フェイクの実装・維持コストが割に合うか ○ 複数のテスト対象から使われる箇所 ○ 振る舞いの変更が少ない箇所 ○ 複雑なロジックがない箇所

Slide 87

Slide 87 text

テストダブルのトレードオフ ● 依存オブジェクトをテストダブルに置き換えることで、良いユニット テストの特徴のいくつかを実現できる ● テストダブルのメリット・デメリットを理解した上で、どこを置き換 えるべきかを判断する必要がある ● テストダブルの使い過ぎはリグレッションの検知が失敗したり、 テストが失敗しやすくなる原因につながる

Slide 88

Slide 88 text

良いユニットテストになるように改善する

Slide 89

Slide 89 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 90

Slide 90 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 91

Slide 91 text

再現性を改善する ● 再現性を下げる主なパターン ○ APIや外部サービスとのネットワーク通信 ○ 時刻やランダム値の生成 ○ 非同期処理

Slide 92

Slide 92 text

再現性を改善する ● 再現性を下げる主なパターン ○ APIや外部サービスとのネットワーク通信 ○ 時刻やランダム値の生成 ○ 非同期処理 テストダブルを使って解決する

Slide 93

Slide 93 text

再現性を改善する ● 再現性を下げる主なパターン ○ APIや外部サービスとのネットワーク通信 ○ 時刻やランダム値の生成 ○ 非同期処理 非同期処理のコントロール方法を知る

Slide 94

Slide 94 text

再現性を改善する Androidアプリのアーキテクチャにそってテストの書き方を学ぼう github.com/DeNA/android-modern-architecture-test-handson ● データレイヤをテストする ○ API通信をするコードのテストを実装しながらCoroutineの テストについて学ぶ ○ オンメモリキャッシュのテストを書く

Slide 95

Slide 95 text

独立性を改善する ● テストの独立性が下がる主なパターン ○ 永続化された情報 ○ mutableなStatic変数 ○ 実行環境

Slide 96

Slide 96 text

独立性を改善する ● テストの独立性が下がる主なパターン ○ 永続化された情報 ○ mutableなStatic変数 ○ 実行環境 テストダブルを使うことでも解決できるが、 RobolectricではSQLiteやPreferencesに保存 したデータやContext配下に保存したファイル はテスト間で共有されず独立した状態 (※Instrumentaion Testでは共有される)

Slide 97

Slide 97 text

● テストの独立性が下がる主なパターン ○ 永続化された情報 ○ mutableなStatic変数 ○ 実行環境 独立性を改善する

Slide 98

Slide 98 text

● テストの独立性が下がる主なパターン ○ 永続化された情報 ○ mutableなStatic変数 ○ 実行環境 独立性を改善する Static変数はどのテスト実行環境でも テスト間をまたいで共有される 設計を見直すか、テスト終了時にクリー ンアップする手段を用意する

Slide 99

Slide 99 text

● テストの独立性が下がる主なパターン ○ 永続化された情報 ○ mutableなStatic変数 ○ 実行環境 独立性を改善する よくあるのはタイムゾーンやロケール 開発環境とCIのマシンで異なることが多い

Slide 100

Slide 100 text

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

Slide 101

Slide 101 text

独立性を改善する // 端末のロケールに合わせて値段の表記を決める関数 fun formatPrice(price: BigDecimal, locale: Locale = ..): String { val numberFormat = NumberFormat.getCurrencyInstance(locale) … } Localeを引数に渡せるようにすることで、 テストではLocaleを指定すれば他のテストや実行環境 の影響を受けずに同じ結果を返すようになる

Slide 102

Slide 102 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 103

Slide 103 text

迅速なフィードバックを阻むもの ● APIや外部サービスとのネットワーク通信等、実行速度に大きな影響 を与えるもの ● テストしにくい箇所と密結合になっている設計

Slide 104

Slide 104 text

迅速なフィードバックを阻むもの ● APIや外部サービスとのネットワーク通信等、実行速度に大きな影響 を与えるもの ● テストしにくい箇所と密結合になっている設計 極端な例だが、全ての実装がUIやコントローラにある場合、 それらを通してでしかテストが書けなくなる テストしたい箇所をすぐにテストできるよう切り分けるのが 基本的なアプローチになる

Slide 105

Slide 105 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 106

Slide 106 text

テストしたい箇所のテストを実行するのが容易 ● テストを実行するためのセットアップが容易 ● テストしたいパターンを網羅するのが容易

Slide 107

Slide 107 text

テストしたい箇所のテストを実行するのが容易 ● テストを実行するためのセットアップが容易 ● テストしたいパターンを網羅するのが容易 テストダブルに差し替える以外の手段を紹介する

Slide 108

Slide 108 text

テスト実行を容易にする工夫 ● テストしにくい箇所をHumble Objectにし、テストしたい箇所を 切り出す ● 生成をまとめる ○ テストデータ ○ テスト対象や依存オブジェクト

Slide 109

Slide 109 text

Humble Objectパターン ● テストを難しくする依存を持つオブジェクトからロジックをできるだ け切り離してロジックをほとんど持たないクラスにする(Humble = つ つましい、質素) ● ロジックはテストを難しくする依存から切り離されて、テストがしや すくなる ● http://xunitpatterns.com/Humble Object.html

Slide 110

Slide 110 text

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

Slide 111

Slide 111 text

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() ) } } } このメソッドをつつましくする

Slide 112

Slide 112 text

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

Slide 113

Slide 113 text

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文字を超えて 更新できないように制御しちゃう

Slide 114

Slide 114 text

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のほうで 判断してもらおう

Slide 115

Slide 115 text

Humble Objectパターン class UserViewModel(..) { fun onNameChanged(name: String) { _uiState.update { it.copy( user = it.user.updateName(name = name) ) } } }

Slide 116

Slide 116 text

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

Slide 117

Slide 117 text

Humble Objectパターン class UserViewModel(..) { fun onNameChanged(name: String) { _uiState.update { it.copy( user = it.user.updateName(name = name) ) } } } ViewModel経由ではなく、UserとUI Stateの単体テストで 確認できる実装が増えた

Slide 118

Slide 118 text

生成をまとめる ● Creation Methodパターン ○ テスト対象や依存オブジェクトのインスタンス生成の複雑さを カプセル化する ○ http://xunitpatterns.com/Creation Method.html

Slide 119

Slide 119 text

テストデータの生成をまとめる data class User( val lastName: String, val firstName: String, val birthDate: LocalDate?, val height: Float, val weight: Float, )

Slide 120

Slide 120 text

テストデータの生成をまとめる // 必要なフィールドが埋まっているUserクラスをテスト間で共有する val emptyUser = User( lastName = "", firstName = "", birthDate = null, height = 0f, weight = 0f )

Slide 121

Slide 121 text

テストデータの生成をまとめる // 保存ボタンが活性化する条件をテストしたい data class UiState( val user: User ) { val isSaveButtonEnable = user.lastName.isNotEmpty() && user.firstName.isNotEmpty() }

Slide 122

Slide 122 text

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

Slide 123

Slide 123 text

テストデータの生成をまとめる val sut = UiState( user = User( // 各テストで直接Userのインスタンスを作っている場合 lastName = "lastName", firstName = "firstName", birthDate = null, height = 0f, weight = 0f ) ) assertTrue(uiState.isSaveButtonEnable) Userにフィールドが追加されたら、Userを生成し ている全てのテストがコンパイルエラーになる

Slide 124

Slide 124 text

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

Slide 125

Slide 125 text

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

Slide 126

Slide 126 text

テストデータの生成をまとめる val sut = UiState( user = emptyUser.copy( // 共通のユーザー定義を使う場合 lastName = "lastName", firstName = "firstName", ) ) assertTrue(uiState.isSaveButtonEnable) Userにフィールドが増えても修正するのは共通の定義だけで良い

Slide 127

Slide 127 text

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

Slide 128

Slide 128 text

テスト対象や依存オブジェクトの生成をまとめる // テスト対象クラスの生成をまとめたテスト用ヘルパー private fun createViewModel( userRepository: UserRepository = TestUserRepository(), userConfigRepository : UserConfigRepository = .. ) : UserViewModel { return UserViewModel( profileRepository = profileRepository, userConfigRepository = userConfigRepository ) }

Slide 129

Slide 129 text

テスト対象や依存オブジェクトの生成をまとめる // テスト対象クラス class UserViewModel( val profileRepository: UserRepository, val userConfigRepository : UserConfigRepository, val logTracker : LogTracker, // New )

Slide 130

Slide 130 text

テスト対象や依存オブジェクトの生成をまとめる // テスト対象クラス class UserViewModel( val profileRepository: UserRepository, val userConfigRepository : UserConfigRepository, val logTracker : LogTracker, // New ) テストケース毎にコンストラクタの指定をして いるとそれぞれ修正する必要があるが、生成を 1つにまとめていれば1箇所修正すればよい

Slide 131

Slide 131 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● テスト対象をリグレッションから保護している ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 132

Slide 132 text

リグレッションを検知できる ● 実装に問題があったときにテストが検知できる ○ 保護すべき箇所に自動テストがなく、問題のあるコードがリリー スされてしまう状況が少ない ○ 保護すべき箇所に自動テストがあったにも関わらず、テストの書 き方の問題でテストは成功している状況が少ない

Slide 133

Slide 133 text

リグレッション検知失敗の例 ● テストダブルと実オブジェクトの振る舞いの互換性がなく、失敗する べきテストが成功してしまう ● @Testをつけ忘れたり、Assertがなく検証ができていない ● Assertionするべき項目が間違っている ● 境界値チェックができておらず、境界値に実装ミスがあった ● 非同期処理でテストでは1つのスレッドで確認していたが、複数ス レッドで呼び出すと問題になる実装があった

Slide 134

Slide 134 text

● テストダブルと実オブジェクトの振る舞いの互換性がなく、失敗する べきテストが成功してしまう ● @Testをつけ忘れたり、Assertがなく検証ができていない ● Assertionするべき項目が間違っている ● 境界値チェックができておらず、境界値に実装ミスがあった ● 非同期処理でテストでは1つのスレッドで確認していたが、複数ス レッドで呼び出すと問題になる実装があった リグレッション検知失敗の例 残念ながら仕組みで防ぐのが難しいものが多い テスト実装時やレビュー時にチェックする必要がある

Slide 135

Slide 135 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● テスト対象をリグレッションから保護している ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 136

Slide 136 text

実装には問題がないがテストが失敗する例 ● テストコードがテスト対象の実装に密接に結びついていたために、 リファクタリング後にテストが失敗する ● テストにロジックが入っていて、そのロジックが間違っている ● テストダブルやテストデータの設定ミス ● 非同期処理の設定ミス ● テスト対象への引数の追加によるコンパイルエラー

Slide 137

Slide 137 text

実装には問題がないがテストが失敗する例 ● テストコードがテスト対象の実装に密接に結びついていたために、 リファクタリング後にテストが失敗する ● テストにロジックが入っていて、そのロジックが間違っている ● テストダブルやテストデータの設定ミス ● 非同期処理の設定ミス ● テスト対象への引数の追加によるコンパイルエラー できるだけテストコードをシンプルにする

Slide 138

Slide 138 text

実装には問題がないがテストが失敗する例 ● テストコードがテスト対象の実装に密接に結びついていたために、 リファクタリング後にテストが失敗する ● テストにロジックが入っていて、そのロジックが間違っている ● テストダブルやテストデータの設定ミス ● 非同期処理の設定ミス ● テスト対象への引数の追加によるコンパイルエラー 無くすのは難しいが、よく直面するケース 生成をまとめることで発生箇所を限定するような 工夫はできる

Slide 139

Slide 139 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● テスト対象をリグレッションから保護している ● リグレッションを検知できる ● テストコードを保守しやすい

Slide 140

Slide 140 text

保守しやすいテストコード ● プロダクトコードとテストコードの比率は、テストコードのほうが 高くなることもある ● プロダクトコードと同じようにテストコードも保守しやすい状態を 保つ必要がある

Slide 141

Slide 141 text

テストコードの保守性をあげる工夫 ● 何をテストしているか理解しやすくする ● パラメタライズドテストを読みやすくする

Slide 142

Slide 142 text

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

Slide 143

Slide 143 text

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

Slide 144

Slide 144 text

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

Slide 145

Slide 145 text

何をテストしているか理解しやすくする @Test fun getUser_error() { val userRepository = TestUserRepository() val useViewModel = UserViewModel(userRepository) useViewModel.getUser() assertTrue( useViewModel.uiState.value.shouldDisplayRetryButton) } テスト対象のコードが どのような振る舞いになるのか もっと明確にしてみよう

Slide 146

Slide 146 text

何をテストしているか理解しやすくする @Test fun`ユーザーの取得に失敗したときはリトライボタンを表示させる`() { val userRepository = TestUserRepository() val useViewModel = UserViewModel(userRepository) useViewModel.getUser() assertTrue( useViewModel.uiState.value.shouldDisplayRetryButton) }

Slide 147

Slide 147 text

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

Slide 148

Slide 148 text

パラメタライズドテストを読みやすくする fun checkAge(age: Int) : Boolean { if(age in 18..59) { return true } else { logger.trackInvalidUser(OUT_OF_AGE_RANGE) return false } } 機能を利用可能な年齢かを認確する メソッド 年齢が無効なときはログ送信

Slide 149

Slide 149 text

パラメタライズドテストを読みやすくする TestCase( age : Int result : Boolean invalidUserReason: Int?, isLogTracked: Boolean )

Slide 150

Slide 150 text

パラメタライズドテストを読みやすくする 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), )

Slide 151

Slide 151 text

パラメタライズドテストを読みやすくする 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), ) テストでメソッドで どう使っているか?

Slide 152

Slide 152 text

パラメタライズドテストを読みやすくする @Test fun test() { .. if(testCase.isLogTracked) { assertEquals(testCase.invalidUserReason, actual) } }

Slide 153

Slide 153 text

パラメタライズドテストを読みやすくする @Test fun test() { .. if(testCase.isLogTracked) { assertEquals(testCase.invalidUserReason, actual) } } 1つのパラメタライズド テストで頑張りすぎてるかも

Slide 154

Slide 154 text

パラメタライズドテストを読みやすくする listOf( TestCase(18), TestCase(59), ) listOf( TestCase(17), TestCase(60), ) テストをややこしくしていた パラメータをなくして、 有効なケースと無効なケースに 分けちゃう

Slide 155

Slide 155 text

パラメタライズドテストを読みやすくする listOf( TestCase(18), TestCase(59), ) なぜこのパラメータで テストしているんだろう?

Slide 156

Slide 156 text

パラメタライズドテストを読みやすくする TestCase( caseName: String age : Int ) テストケースの意図を 説明できるパラメータを追加

Slide 157

Slide 157 text

パラメタライズドテストを読みやすくする listOf( TestCase("利用可能年齢の範囲内の場合は有効とする(最小値)", 18), TestCase("利用可能年齢の範囲内の場合は有効とする(最大値)", 59), )

Slide 158

Slide 158 text

まとめ

Slide 159

Slide 159 text

ユニットテストが目指すもの ● 結合範囲が広いテストが持つ課題をクリアする ○ 安定性 ○ フィードバックループの長さ ○ 検証したいパスに到達する難しさ ● その上でアプリの変更が容易な状態を保つのに貢献する

Slide 160

Slide 160 text

良いユニットテストの特徴 ● 再現性と独立性がありテストが安定している ● 迅速なフィードバックを得られる ● テストしたい箇所のテストを実行するのが容易 ● リグレッションを検知できる ● テストによる誤検知が少ない ● テストコードを保守しやすい

Slide 161

Slide 161 text

参考書籍

Slide 162

Slide 162 text

参考書籍 Vladimir Khorikov (著), 須田智之 (翻訳) 「単体テストの考え方/使い方 」 マイナビ出版, 2022

Slide 163

Slide 163 text

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

Slide 164

Slide 164 text

参考書籍 Gerard Meszaros (著) 「xUnit Test Patterns: Refactoring Test Code 」 Addison-Wesley Professional, 2007

Slide 165

Slide 165 text

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