ViewModel の単体テストを作成する

1. 始める前に

この Codelab では、ViewModel コンポーネントをテストする単体テストの作成方法について説明します。Unscramble ゲームアプリの単体テストを追加します。Unscramble アプリは、スクランブルされた単語を推測し、正解するとポイントを獲得できる、楽しい単語ゲームです。次の画像は、アプリのプレビューを示しています。

bb1e97c357603a27.png

自動テストを作成する」Codelab では、自動テストの概要と重要性について学習しました。また、単体テストの実装方法についても学習しました。

学習した内容は以下のとおりです。

  • 自動テストは、別のコードの正確さを検証するコードです。
  • テストは、アプリの開発における重要なプロセスです。アプリに対して一貫性のあるテストを実施することで、アプリの公開前に、機能の動作、使いやすさを検証できます。
  • 単体テストでは、関数、クラス、プロパティをテストできます。
  • ローカル単体テストは、ワークステーションで実行されます。つまり、Android デバイスやエミュレータを必要とせずに、開発環境で実行されます。言い換えると、ローカルテストはパソコンで実行できます。

先に進む前に、「自動テストを作成する」と「Compose での ViewModel と状態」の Codelab を完了してください。

前提条件

  • 関数、ラムダ、ステートレス コンポーザブルなど、Kotlin に関する知識
  • Jetpack Compose でレイアウトを作成する方法に関する基本的な知識
  • マテリアル デザインに関する基本的な知識
  • ViewModel を実装する方法に関する基本的な知識

学習内容

  • アプリ モジュールの build.gradle.kts ファイルに単体テストの依存関係を追加する方法
  • 単体テストを実装するためのテスト戦略を作成する方法
  • JUnit4 を使用して単体テストを作成し、テスト インスタンスのライフサイクルを理解する方法
  • コード カバレッジを実行、分析、改善する方法

作成するアプリの概要

必要なもの

  • Android Studio の最新バージョン

スターター コードを取得する

まず、スターター コードをダウンロードします。

または、GitHub リポジトリのクローンを作成してコードを入手することもできます。

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git
$ cd basic-android-kotlin-compose-training-unscramble
$ git checkout viewmodel

コードは Unscramble GitHub リポジトリで確認できます。

2. スターター コードの概要

ユニット 2 では、次の図に示すように、src フォルダの下にある test ソースセットに単体テストコードを配置することについて学習しました。

1a2dceb0dd9c618d.png

スターター コードには次のファイルがあります。

  • WordsData.kt: このファイルには、テストに使用する単語のリストと、スクランブルされた単語からスクランブルされていない単語を取得する getUnscrambledWord() ヘルパー関数が含まれています。このファイルを変更する必要はありません。

3. テストの依存関係を追加する

この Codelab では、JUnit フレームワークを使用して単体テストを作成します。このフレームワークを使用するには、アプリ モジュールの build.gradle.kts ファイルに依存関係として追加する必要があります。

implementation 構成を使用して、アプリに必要な依存関係を指定します。たとえばアプリで ViewModel ライブラリを使用するには、次のコード スニペットに示すように、依存関係を androidx.lifecycle:lifecycle-viewmodel-compose に追加する必要があります。

dependencies {

    ...
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
}

これで、このライブラリをアプリのソースコードで使用できるようになりました。Android Studio で、生成されたアプリ パッケージ(APK)ファイルにライブラリを追加できます。しかし、単体テストコードを APK ファイルの一部にすることは望ましくありません。テストコードはユーザーが使用する機能を追加するものではありませんし、APK のサイズにも影響します。テストコードに必要な依存関係についても同様であり、それぞれ別にしておく必要があります。そのためには、testImplementation 構成を使用します。こうすることで、構成がアプリコードではなくローカルのテスト ソースコードに適用されることを示します。

プロジェクトに依存関係を追加するには、build.gradle.kts ファイルの dependencies ブロックで、implementationtestImplementation などの依存関係構成を指定します。それぞれの依存関係構成は、依存関係の使用方法について Gradle にさまざまな指示を与えます。

依存関係を追加するには:

  1. [Project] ペインの app ディレクトリにある、app モジュールの build.gradle.kts ファイルを開きます。

bc235c0754e4e0f2.png

  1. ファイル内で、dependencies{} ブロックが見つかるまで下にスクロールします。junittestImplementation 構成ファイルを使用して依存関係を追加します。
plugins {
    ...
}

android {
    ...
}

dependencies {
    ...
    testImplementation("junit:junit:4.13.2")
}
  1. 次のスクリーンショットに示すように、build.gradle.kts ファイルの上部にある通知バーで [Sync Now] をクリックし、インポートとビルドを終了します。

1c20fc10750ca60c.png

部品構成表(BOM)の作成

Compose ライブラリのバージョン管理には、Compose BOM の使用をおすすめします。BOM を使用すると、BOM のバージョンのみを指定して、すべての Compose ライブラリ バージョンを管理できます。

app モジュールの build.gradle.kts ファイルの依存関係セクションをご覧ください。

// No need to copy over
// This is part of starter code
dependencies {

   // Import the Compose BOM
    implementation (platform("androidx.compose:compose-bom:2023.06.01"))
    ...
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    ...
}

以下のことを確認してください。

  • Compose ライブラリのバージョン番号が指定されていません。
  • BOM が implementation platform("androidx.compose:compose-bom:2023.06.01") を使用してインポートされています。

これは、BOM 自体に、さまざまな Compose ライブラリが連携して適切に機能するよう、それぞれの最新の安定版ライブラリへのリンクが含まれているためです。アプリで BOM を使用するときに、Compose ライブラリの依存関係自体にバージョンを追加する必要はありません。BOM バージョンを更新すると、使用しているすべてのライブラリが自動的に新しいバージョンに更新されます。

BOM を Compose テスト ライブラリ(インストルメンテーション テスト)で使用するには、androidTestImplementation platform("androidx.compose:compose-bom:xxxx.xx.xx") をインポートする必要があります。次に示すように、変数を作成して implementationandroidTestImplementation に再利用することができます。

// Example, not need to copy over
dependencies {

   // Import the Compose BOM
    implementation(platform("androidx.compose:compose-bom:2023.06.01"))
    implementation("androidx.compose.material:material")
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-tooling-preview")

    // ...
    androidTestImplementation(platform("androidx.compose:compose-bom:2023.06.01"))
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")

}

これでテストの依存関係をアプリに追加し、BOM について学習できたので、単体テストを追加する準備が整いました。

4. テスト戦略

適切なテスト戦略は、コードのさまざまなパスと境界をカバーすることを中心に展開されます。非常に基本的なレベルでは、成功パス、エラーパス、境界ケースという 3 つのシナリオにテストを分類できます。

  • 成功パス: 成功パスのテスト(「ハッピーパス テスト」ともいいます)は、ポジティブ フローの機能のテストに重点を置きます。ポジティブ フローとは、例外またはエラー状態がないフローのことです。エラーパスや境界ケースのシナリオとは異なり、成功パスのシナリオは、アプリの想定どおりの動作に重点を置くため、シナリオの網羅的なリストを簡単に作成できます。

Unscramble アプリの成功パスの例としては、ユーザーが正しい単語を入力して [Submit] ボタンをクリックしたときに、スコア、単語数、スクランブルされた単語が正しく更新されることが挙げられます。

  • エラーパス: エラーパスのテストは、ネガティブ フローの機能のテストに重点を起きます。つまり、エラー状態や無効なユーザー入力にアプリがどのように応答するかをチェックします。想定どおりの動作が達成されなかったときに生じる可能性のある結果は数多くあるため、考えられるすべてのエラーフローを判断することは非常に困難です。

一般的に、考えられるすべてのエラーパスを列挙し、それらに対するテストを作成して、さまざまなシナリオを発見しながら単体テストを進化させ続けることをおすすめします。

Unscramble アプリのエラーパスの例としては、ユーザーが間違った単語を入力して [Submit] ボタンをクリックしたときに、エラー メッセージが表示され、スコアと単語数が更新されないことが挙げられます。

  • 境界ケース: 境界ケースは、アプリの境界条件のテストに重点を置きます。Unscramble アプリの境界では、アプリが読み込むときの UI 状態と、ユーザーが最大数の単語をプレイした後の UI 状態を確認します。

これらのカテゴリに関するテストシナリオを作成し、テスト計画のガイドラインとして使用できます。

テストを作成する

一般的に、適切な単体テストには次の 4 つの特性があります。

  • 範囲が限定的: コードの一部など、特定のユニットをテストすることに重点が置かれています。コードの一部とは多くの場合、クラスまたはメソッドです。テストは範囲を狭くし、コードの複数の部分を同時に検証するのではなく、コードの各部分の正確さを検証することに重点を置くべきです。
  • 理解しやすい: 簡潔で、コードを読んだときに理解しやすいものにします。デベロッパーがひと目で、テストの背後にある意図をすぐに理解できるようにしましょう。
  • 確定的: 合格か不合格かの判定に一貫性があります。コードを変更せずに何度テストを実行しても、同じ結果が得られる必要があります。コードを変更していないにもかかわらず、あるときは不合格になり、またあるときは合格になるなど、テストが不安定であってはなりません。
  • 自己完結: 人間による操作もセットアップも必要とせず、単独で動作します。

成功パス

成功パスの単体テストを作成するには、GameViewModel のインスタンスが初期化されている前提で、updateUserGuess() メソッドが正しい推測単語で呼び出された後に checkUserGuess() メソッドが呼び出されたとき、次のようになることをアサートする必要があります。

  • 正しい推測が updateUserGuess() メソッドに渡される。
  • checkUserGuess() が呼び出される。
  • scoreisGuessedWordWrong ステータスの値が正しく更新される。

次の手順を実施してテストを作成します。

  1. 次のスクリーンショットに示すように、テスト ソースセットの下に新しいパッケージ com.example.android.unscramble.ui.test を作成し、ファイルを追加します。

57d004ccc4d75833.png

f98067499852bdce.png

GameViewModel クラスの単体テストを作成するには、クラスのメソッドを呼び出して状態を検証できるように、クラスのインスタンスが必要となります。

  1. GameViewModelTest クラスの本体で、viewModel プロパティを宣言し、GameViewModel クラスのインスタンスを代入します。
class GameViewModelTest {
    private val viewModel = GameViewModel()
}
  1. 成功パスの単体テストを作成するには、gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() 関数を作成し、@Test アノテーションを付けます。
class GameViewModelTest {
    private val viewModel = GameViewModel()

    @Test
    fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset()  {
    }
}
  1. 以下をインポートします。
import org.junit.Test

プレーヤーの正しい単語を viewModel.updateUserGuess() メソッドに渡すには、GameUiState のスクランブルされた単語からスクランブルされていない正しい単語を取得する必要があります。そのためには、まず現在のゲームの UI 状態を取得します。

  1. 関数本体で currentGameUiState 変数を作成し、viewModel.uiState.value を代入します。
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
    var currentGameUiState = viewModel.uiState.value
}
  1. プレーヤーの正しい推測を取得するには getUnscrambledWord() 関数を使用します。この関数は currentGameUiState.currentScrambledWord を引数として受け取り、スクランブルされていない単語を返します。この戻り値を correctPlayerWord という新しい読み取り専用変数に格納し、getUnscrambledWord() 関数から返された値を代入します。
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
    var currentGameUiState = viewModel.uiState.value
    val correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)

}
  1. 推測した単語が正しいかどうかを検証するには、viewModel.updateUserGuess() メソッドの呼び出しを追加し、引数として correctPlayerWord 変数を渡します。次に、viewModel.checkUserGuess() メソッドの呼び出しを追加して、推測を検証します。
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
    var currentGameUiState = viewModel.uiState.value
    val correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)

    viewModel.updateUserGuess(correctPlayerWord)
    viewModel.checkUserGuess()
}

これで、ゲームの状態が想定どおりであることをアサートする準備が整いました。

  1. viewModel.uiState プロパティの値から GameUiState クラスのインスタンスを取得し、currentGameUiState 変数に格納します。
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
    var currentGameUiState = viewModel.uiState.value
    val correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
    viewModel.updateUserGuess(correctPlayerWord)
    viewModel.checkUserGuess()

    currentGameUiState = viewModel.uiState.value
}
  1. 推測した単語が正しいこととスコアが更新されたことを確認するには、assertFalse() 関数を使用して currentGameUiState.isGuessedWordWrong プロパティが false であることを検証し、assertEquals() 関数を使用して currentGameUiState.score プロパティの値が 20 に等しいことを検証します。
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
    var currentGameUiState = viewModel.uiState.value
    val correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
    viewModel.updateUserGuess(correctPlayerWord)
    viewModel.checkUserGuess()

    currentGameUiState = viewModel.uiState.value
    // Assert that checkUserGuess() method updates isGuessedWordWrong is updated correctly.
    assertFalse(currentGameUiState.isGuessedWordWrong)
    // Assert that score is updated correctly.
    assertEquals(20, currentGameUiState.score)
}
  1. 以下をインポートします。
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
  1. 20 を理解しやすくし、再利用できるようにするには、コンパニオン オブジェクトを作成して、SCORE_AFTER_FIRST_CORRECT_ANSWER という private 定数に 20 を代入します。新しく作成した定数でテストを更新します。
class GameViewModelTest {
    ...
    @Test
    fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
        ...
        // Assert that score is updated correctly.
        assertEquals(SCORE_AFTER_FIRST_CORRECT_ANSWER, currentGameUiState.score)
    }

    companion object {
        private const val SCORE_AFTER_FIRST_CORRECT_ANSWER = SCORE_INCREASE
    }
}
  1. テストを実行します。

次のスクリーンショットに示すように、すべてのアサーションが有効であるため、テストは合格するはずです。

c412a2ac3fbefa57.png

エラーパス

エラーパスの単体テストを作成するには、間違った単語が引数として viewModel.updateUserGuess() メソッドに渡され、viewModel.checkUserGuess() メソッドが呼び出されたら、次のようになることをアサートする必要があります。

  • currentGameUiState.score プロパティの値は変更されない。
  • 推測が間違っているため、currentGameUiState.isGuessedWordWrong プロパティの値は true に設定される。

次の手順を実施してテストを作成します。

  1. GameViewModelTest クラスの本体で、gameViewModel_IncorrectGuess_ErrorFlagSet() 関数を作成し、@Test アノテーションを付けます。
@Test
fun gameViewModel_IncorrectGuess_ErrorFlagSet() {

}
  1. incorrectPlayerWord 変数を定義し、単語のリストに存在しない "and" 値を代入します。
@Test
fun gameViewModel_IncorrectGuess_ErrorFlagSet() {
    // Given an incorrect word as input
    val incorrectPlayerWord = "and"
}
  1. viewModel.updateUserGuess() メソッドの呼び出しを追加し、引数として incorrectPlayerWord 変数を渡します。
  2. viewModel.checkUserGuess() メソッドの呼び出しを追加して、推測を検証します。
@Test
fun gameViewModel_IncorrectGuess_ErrorFlagSet() {
    // Given an incorrect word as input
    val incorrectPlayerWord = "and"

    viewModel.updateUserGuess(incorrectPlayerWord)
    viewModel.checkUserGuess()
}
  1. currentGameUiState 変数を追加し、viewModel.uiState.value 状態の値を代入します。
  2. アサーション関数を使用して、currentGameUiState.score プロパティの値が 0 であり、currentGameUiState.isGuessedWordWrong プロパティの値が true に設定されていることをアサートします。
@Test
fun gameViewModel_IncorrectGuess_ErrorFlagSet() {
    // Given an incorrect word as input
    val incorrectPlayerWord = "and"

    viewModel.updateUserGuess(incorrectPlayerWord)
    viewModel.checkUserGuess()

    val currentGameUiState = viewModel.uiState.value
    // Assert that score is unchanged
    assertEquals(0, currentGameUiState.score)
    // Assert that checkUserGuess() method updates isGuessedWordWrong correctly
    assertTrue(currentGameUiState.isGuessedWordWrong)
}
  1. 以下をインポートします。
import org.junit.Assert.assertTrue
  1. テストを実行して、合格することを確認します。

境界ケース

UI の初期状態をテストするには、GameViewModel クラスの単体テストを作成する必要があります。このテストは、GameViewModel が初期化されたとき、次の事項が真であることをアサートする必要があります。

  • currentWordCount プロパティが 1 に設定されている。
  • score プロパティが 0 に設定されている。
  • isGuessedWordWrong プロパティが false に設定されている。
  • isGameOver プロパティが false に設定されている。

次の手順でテストを追加します。

  1. gameViewModel_Initialization_FirstWordLoaded() メソッドを作成し、@Test アノテーションを付けます。
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {

}
  1. viewModel.uiState.value プロパティにアクセスして GameUiState クラスの初期インスタンスを取得し、新しい読み取り専用変数 gameUiState に代入します。
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
    val gameUiState = viewModel.uiState.value
}
  1. プレーヤーの正しい単語を取得するには getUnscrambledWord() 関数を使用します。この関数は gameUiState.currentScrambledWord の単語を受け取り、スクランブルされていない単語を返します。戻り値を、unScrambledWord という新しい読み取り専用変数に代入します。
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
    val gameUiState = viewModel.uiState.value
    val unScrambledWord = getUnscrambledWord(gameUiState.currentScrambledWord)

}
  1. 状態が正しいことを検証するには、assertTrue() 関数を追加して、currentWordCount プロパティが 1 に設定され、score プロパティが 0 に設定されていることをアサートします。
  2. assertFalse() 関数を追加して、isGuessedWordWrong プロパティが false であることと、isGameOver プロパティが false に設定されていることを検証します。
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
    val gameUiState = viewModel.uiState.value
    val unScrambledWord = getUnscrambledWord(gameUiState.currentScrambledWord)

    // Assert that current word is scrambled.
    assertNotEquals(unScrambledWord, gameUiState.currentScrambledWord)
    // Assert that current word count is set to 1.
    assertTrue(gameUiState.currentWordCount == 1)
    // Assert that initially the score is 0.
    assertTrue(gameUiState.score == 0)
    // Assert that the wrong word guessed is false.
    assertFalse(gameUiState.isGuessedWordWrong)
    // Assert that game is not over.
    assertFalse(gameUiState.isGameOver)
}
  1. 以下をインポートします。
import org.junit.Assert.assertNotEquals
  1. テストを実行して、合格することを確認します。

もう 1 つの境界ケースは、ユーザーがすべての単語を推測した後の UI 状態をテストすることです。ユーザーがすべての単語を正しく推測したとき、次の事項が真であることをアサートする必要があります。

  • スコアが最新である。
  • currentGameUiState.currentWordCount プロパティが MAX_NO_OF_WORDS 定数の値と等しい。
  • currentGameUiState.isGameOver プロパティが true に設定されている。

次の手順でテストを追加します。

  1. gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() メソッドを作成し、@Test アノテーションを付けます。このメソッドで expectedScore 変数を作成し、0 を代入します。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
    var expectedScore = 0
}
  1. 初期状態を取得するには、currentGameUiState 変数を追加し、viewModel.uiState.value プロパティの値を代入します。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
    var expectedScore = 0
    var currentGameUiState = viewModel.uiState.value
}
  1. プレーヤーの正しい単語を取得するには getUnscrambledWord() 関数を使用します。この関数は currentGameUiState.currentScrambledWord の単語を受け取り、スクランブルされていない単語を返します。この戻り値を correctPlayerWord という新しい読み取り専用変数に格納し、getUnscrambledWord() 関数から返された値を代入します。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
    var expectedScore = 0
    var currentGameUiState = viewModel.uiState.value
    var correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
}
  1. ユーザーがすべての答えを推測しているかどうかをテストするには、repeat ブロックを使用して、viewModel.updateUserGuess() メソッドと viewModel.checkUserGuess() メソッドの実行を MAX_NO_OF_WORDS 回繰り返します。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
    var expectedScore = 0
    var currentGameUiState = viewModel.uiState.value
    var correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
    repeat(MAX_NO_OF_WORDS) {

    }
}
  1. repeat ブロックで、SCORE_INCREASE 定数の値を expectedScore 変数に追加して、正解するたびにスコアが増加することをアサートします。
  2. viewModel.updateUserGuess() メソッドの呼び出しを追加し、引数として correctPlayerWord 変数を渡します。
  3. viewModel.checkUserGuess() メソッドの呼び出しを追加して、ユーザーの推測の確認をトリガーします。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
    var expectedScore = 0
    var currentGameUiState = viewModel.uiState.value
    var correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
    repeat(MAX_NO_OF_WORDS) {
        expectedScore += SCORE_INCREASE
        viewModel.updateUserGuess(correctPlayerWord)
        viewModel.checkUserGuess()
    }
}
  1. 現在のプレーヤーの単語を更新し、getUnscrambledWord() 関数を使用します。この関数は、currentGameUiState.currentScrambledWord を引数として受け取り、スクランブルされていない単語を返します。この戻り値を correctPlayerWord. という新しい読み取り専用変数に格納します。状態が正しいことを確認するには、assertEquals() 関数を追加して、currentGameUiState.score プロパティの値が expectedScore 変数の値と等しいことを確認します。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
    var expectedScore = 0
    var currentGameUiState = viewModel.uiState.value
    var correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
    repeat(MAX_NO_OF_WORDS) {
        expectedScore += SCORE_INCREASE
        viewModel.updateUserGuess(correctPlayerWord)
        viewModel.checkUserGuess()
        currentGameUiState = viewModel.uiState.value
        correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
        // Assert that after each correct answer, score is updated correctly.
        assertEquals(expectedScore, currentGameUiState.score)
    }
}
  1. assertEquals() 関数を追加して、currentGameUiState.currentWordCount プロパティの値が MAX_NO_OF_WORDS 定数の値と等しいことと、currentGameUiState.isGameOver プロパティの値が true に設定されていることをアサートします。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
    var expectedScore = 0
    var currentGameUiState = viewModel.uiState.value
    var correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
    repeat(MAX_NO_OF_WORDS) {
        expectedScore += SCORE_INCREASE
        viewModel.updateUserGuess(correctPlayerWord)
        viewModel.checkUserGuess()
        currentGameUiState = viewModel.uiState.value
        correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
        // Assert that after each correct answer, score is updated correctly.
        assertEquals(expectedScore, currentGameUiState.score)
    }
    // Assert that after all questions are answered, the current word count is up-to-date.
    assertEquals(MAX_NO_OF_WORDS, currentGameUiState.currentWordCount)
    // Assert that after 10 questions are answered, the game is over.
    assertTrue(currentGameUiState.isGameOver)
}
  1. 以下をインポートします。
import com.example.unscramble.data.MAX_NO_OF_WORDS
  1. テストを実行して、合格することを確認します。

テスト インスタンスのライフサイクルの概要

テストで viewModel を初期化する方法をよく見ると、すべてのテストで使用されているにもかかわらず、viewModel が 1 回しか初期化されていないことがわかります。次のコード スニペットは、viewModel プロパティの定義を示しています。

class GameViewModelTest {
    private val viewModel = GameViewModel()

    @Test
    fun gameViewModel_Initialization_FirstWordLoaded() {
        val gameUiState = viewModel.uiState.value
        ...
    }
    ...
}

次のような疑問を抱かれるかもしれません。

  • すべてのテストで viewModel の同じインスタンスが再利用されるということなのか。
  • 何か問題が生じるのか。たとえば、gameViewModel_Initialization_FirstWordLoaded テストメソッドが gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset テストメソッドの後に実行された場合はどうなるか。初期化テストは失敗するか。

どちらの疑問に対しても、答えは「いいえ」です。テストメソッドは、テスト インスタンスの状態が可変であることによる予期しない副作用を避けるために、独立して実行されます。デフォルトでは、各テストメソッドが実行される前に JUnit がテストクラスの新しいインスタンスを作成します。

GameViewModelTest クラスにはここまででテストメソッドが 4 つあるため、GameViewModelTest は 4 回インスタンス化します。各インスタンスにはそれぞれ viewModel プロパティのコピーがあります。そのため、テスト実行の順序は重要ではありません。

5. コード カバレッジについて

コード カバレッジは、アプリを構成するクラス、メソッド、コード行を適切にテストしているかどうかを判断するために重要な役割を果たします。

Android Studio にはローカル単体テストのためのテスト カバレッジ ツールが用意されており、単体テストがカバーするアプリコードの割合と領域が追跡されます。

Android Studio でカバレッジを指定してテストを実行する

カバレッジを指定してテストを実行するには:

  1. プロジェクト ペインで GameViewModelTest.kt ファイルを右クリックし、cf4c5adfe69a119f.png [Run 'GameViewModelTest' with Coverage] を選択します。

73545d5ade3851df.png

  1. テスト実行が完了したら、右側のカバレッジ パネルで [Flatten Packages] オプションをクリックします。

90e2989f8b58d254.png

  1. 次の図に示すように、com.example.android.unscramble.ui パッケージがあります。

1c755d17d19c6f65.png

  1. パッケージ名 com.example.android.unscramble.ui をダブルクリックすると、GameViewModel のカバレッジが次の図のように表示されます。

14cf6ca3ffb557c4.png

テストレポートを分析する

次の図に示すレポートは、2 つの部分に分かれています。

  • 単体テストがカバーするメソッドの割合: 図の例では、これまでに作成したテストが 8 つのメソッドのうち 7 つをカバーしています。これはメソッド全体の 87% に相当します。
  • 単体テストがカバーする行の割合: 図の例では、作成したテストが 41 行のうち 39 行をテストしています。これはコード行の 95% に相当します。

このレポートは、これまでに作成した単体テストでコードの特定の部分が見逃されていることを示唆しています。どの部分が見逃されているのかを判断する手順は次のとおりです。

  • [GameViewModel] をダブルクリックします。

c934ba14e096bddd.png

Android Studio で、GameViewModel.kt ファイルがウィンドウの左側で色分けされて表示されます。明るい緑色は、そのコード行がカバーされていることを示します。

edc4e5faf352119b.png

GameViewModel を下にスクロールすると、いくつかの行が薄いピンク色でマークされていることがわかります。この色は、そのコード行が単体テストでカバーされていないことを示します。

6df985f713337a0c.png

カバレッジを改善する

カバレッジを改善するには、見逃しているパスをカバーするテストを作成する必要があります。テストを追加して、ユーザーが単語をスキップしたとき、次の事項が真であることをアサートする必要があります。

  • currentGameUiState.score プロパティは変更されない。
  • 次のコード スニペットに示すように、currentGameUiState.currentWordCount プロパティは 1 ずつ増加する。

カバレッジを改善する準備として、次のテストメソッドを GameViewModelTest クラスに追加します。

@Test
fun gameViewModel_WordSkipped_ScoreUnchangedAndWordCountIncreased() {
    var currentGameUiState = viewModel.uiState.value
    val correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
    viewModel.updateUserGuess(correctPlayerWord)
    viewModel.checkUserGuess()

    currentGameUiState = viewModel.uiState.value
    val lastWordCount = currentGameUiState.currentWordCount
    viewModel.skipWord()
    currentGameUiState = viewModel.uiState.value
    // Assert that score remains unchanged after word is skipped.
    assertEquals(SCORE_AFTER_FIRST_CORRECT_ANSWER, currentGameUiState.score)
    // Assert that word count is increased by 1 after word is skipped.
    assertEquals(lastWordCount + 1, currentGameUiState.currentWordCount)
}

次の手順を実施してカバレッジを再実行します。

  1. GameViewModelTest.kt ファイルを右クリックし、メニューから [Run ‘GameViewModelTest' with Coverage] を選択します。
  2. ビルドが成功したら、もう一度 GameViewModel 要素に移動し、カバレッジの割合が 100% であることを確認します。次の図に、最終的なカバレッジ レポートを示します。

145781df2c68f71c.png

  1. GameViewModel.kt ファイルに移動して下にスクロールし、前に見逃されていたパスがカバーされているかどうかを確認します。

357263bdb9219779.png

アプリコードのコード カバレッジを実行、分析、改善する方法を学習しました。

コード カバレッジの割合が高ければ、アプリコードの質が高いということになるのでしょうか。そうではありません。コード カバレッジは、単体テストでカバー(実行)されたコードの割合を示すもので、コードが検証されたことを示しているわけではありません。単体テストのコードからすべてのアサーションを削除してコード カバレッジを実行しても、カバレッジは 100% と表示されます。

カバレッジが高いことは、テストが正しく設計されていることや、アプリの動作が検証されていることを示すわけではありません。作成したテストに、テストするクラスの動作を検証するアサーションがあることを確認する必要があります。また、アプリ全体でテスト カバレッジ 100% を実現するように単体テストを作成する必要はありません。アクティビティなど、アプリのコードの一部は UI テストでテストする必要があります。

ただし、カバレッジが低いということは、コードの大部分がまったくテストされていないことを意味します。コード カバレッジは、コードの品質を測定するツールではなく、テストで実行されなかったコードの部分を見つけるツールとして使用してください。

6. 解答コードを取得する

この Codelab の完成したコードをダウンロードするには、以下の git コマンドを使用します。

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-unscramble.git
$ cd basic-android-kotlin-compose-training-unscramble
$ git checkout main

または、リポジトリを ZIP ファイルとしてダウンロードし、Android Studio で開くこともできます。

解答コードを確認する場合は、GitHub で表示します

7. まとめ

お疲れさまでした。テスト戦略を定義する方法を学習し、Unscramble アプリの ViewModelStateFlow をテストする単体テストを実装しました。今後も Android アプリを開発する場合は、開発プロセス全体を通してアプリが適切に動作することを確認するために、アプリ機能とともにテストも作成するようにしてください。

まとめ

  • testImplementation 構成を使用して、依存関係がアプリコードではなく、ローカルのテスト ソースコードに適用されることを示します。
  • テストを、成功パス、エラーパス、境界ケースという 3 つのシナリオに分類するようにします。
  • 適切な単体テストには、少なくとも 4 つの特性(範囲が限定的、理解しやすい、確定的、自己完結)があります。
  • テストメソッドは、テスト インスタンスの状態が可変であることによる予期しない副作用を避けるために、独立して実行されます。
  • デフォルトでは、各テストメソッドが実行される前に JUnit がテストクラスの新しいインスタンスを作成します。
  • コード カバレッジは、アプリを構成するクラス、メソッド、コード行を適切にテストしているかどうかを判断するために重要な役割を果たします。

詳細