为 ViewModel 编写单元测试

使用集合让一切井井有条 根据您的偏好保存内容并对其进行分类。

1. 准备工作

在此 Codelab 中,您将学习如何编写单元测试来测试 ViewModel 组件。您将为 Unscramble 游戏应用添加单元测试。Unscramble 应用是一款有趣的文字游戏,玩家需要猜测一个乱序词,猜对将获得积分。下图显示了应用的预览:

ecb509065f9993b1.gif

编写自动化测试 Codelab 中,您了解了什么是自动化测试以及自动化测试为何如此重要。您还学习了如何实现单元测试。

您已完成以下内容的学习:

  • 自动化测试是用于验证另一段代码的准确性的代码。
  • 测试是应用开发流程的一个重要环节。通过持续对应用运行测试,您可以在公开发布应用之前验证其功能行为和易用性。
  • 借助单元测试,您可以测试函数、类和属性。
  • 本地单元测试在您的工作站上执行,这意味着它们可以在开发环境中运行,而无需 Android 设备或模拟器。换句话说,本地测试是在您的计算机上运行的。

在继续之前,请确保您已完成以下 Codelab:编写自动化测试Compose 中的 ViewModel 和状态

前提条件

  • 了解 Kotlin,包括函数、lambda 和无状态可组合项
  • 具备有关如何在 Jetpack Compose 中构建布局的基础知识
  • 具备有关 Material Design 的基础知识
  • 具备有关如何实现 ViewModel 的基础知识

学习内容

  • 如何在应用模块的 build.gradle 文件中为单元测试添加依赖项
  • 如何创建测试策略以实现单元测试
  • 如何使用 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 源代码集中,如下图所示。

86aead386aae572a.png

起始代码包含两个文件:

  • WordsData.kt:此文件包含要用于测试的字词列表以及用于从乱序词中获取字母顺序正确的相应单词的 getUnscrambledWord() 辅助函数。您无需修改此文件。

3.添加测试依赖项

在此 Codelab 中,您将使用 JUnit 框架来编写单元测试。如需使用该框架,您需要将其作为依赖项添加到应用模块的 build.gradle 文件中。

您可以使用 implementation 配置来指定应用所需的依赖项。例如,如需在应用中使用 ViewModel 库,您必须向 androidx.lifecycle:lifecycle-viewmodel-compose 添加依赖项,如以下代码段所示:

dependencies {

    ...
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.0"
}

现在,您可以在应用的源代码中使用该库,Android Studio 会帮助您将其添加到生成的应用软件包 (APK) 文件中。不过,您不希望单元测试代码包含在 APK 文件中。测试代码不会添加用户会使用的任何功能,并且还会对 APK 大小产生影响。测试代码所需的依赖项也是如此;您应将它们分开。为此,您可以使用 testImplementation 配置,这表示该配置适用于本地测试源代码,而非应用代码。

如需向您的项目添加依赖项,请在 build.gradle 文件的依赖项代码块中指定依赖项配置(例如 implementationtestImplementation)。每种依赖项配置都向 Gradle 提供了有关如何使用相应依赖项的不同说明。

如需添加依赖项,请执行以下操作:

  1. 打开 app 模块的 build.gradle 文件,该文件位于 Project 窗格的 app 目录中。

4a06ab2a560a096.png

  1. 在文件内,向下滚动,直到找到 dependencies {} 代码块。使用 testImplementation 配置将依赖项添加到 junit
plugins {
    ...
}

android {
    ...
}

dependencies {
    ...
    testImplementation 'junit:junit:4.13.2'
}
  1. build.gradle 文件顶部的通知栏中,点击 Sync Now,这将完成导入和构建,如以下屏幕截图所示:

def0a9820607a08b.png

太好了!您已成功将测试依赖项添加到应用中,现在可以添加一些单元测试了。

4. 测试策略

良好的测试策略应围绕代码的不同路径和边界。在最基本的层面上,您可以将测试分类为三种场景:成功路径、错误路径和边界情况。

  • 成功路径:成功路径测试(也称为满意路径测试)侧重于测试正向流的功能。正向流是指不存在任何异常或错误情况的流。与错误路径和边界情况场景相比,创建成功路径场景的详尽列表是很容易的,因为它们关注的是应用的预期行为。

Unscramble 应用中的成功路径示例如下:当用户输入正确的单词并点击 Submit 按钮时,系统会正确更新得分、单词数和乱序词。

  • 错误路径:错误路径测试侧重于测试负向流的功能,即检查应用如何响应错误情况或无效用户输入。确定所有可能的错误流是一项极具挑战性的任务,因为如果未实现预期行为,则会有许多可能的结果。

一项一般性建议是列出所有可能的错误路径,针对这些错误编写测试,并随着您发现不同的场景而不断改进单元测试。

Unscramble 应用中的错误路径示例如下:当用户输入错误的单词并点击 Submit 按钮时,这会导致系统显示错误消息,并且不会更新得分和单词数。

  • 边界情况:边界情况侧重于测试应用中的边界条件。在 Unscramble 应用中,边界会检查应用加载时的界面状态,以及用户达到单词数量上限后的界面状态。

围绕这些类别创建测试场景,可以作为测试计划的准则。

创建测试

有效的单元测试通常具有以下四个属性:

  • 有针对性:测试应侧重于某个单元,例如一段代码,通常是某个类或方法。测试应有针对性并侧重于验证单段代码的正确性,而不是同时验证多段代码。
  • 易于理解:当您阅读代码时,代码应当简单且易于理解。开发者应当能够立即一目了然地了解测试背后的意图。
  • 确定性:应保持一致的通过或失败结果。如果您运行测试多次且没有更改任何代码,测试应得到相同的结果。测试应避免不可靠性,也就是在未修改代码的情况下,不得在一个实例中测试失败,而在另一个实例中测试通过。
  • 独立性:测试不需要任何人为互动或设置,即可独立运行。

成功路径

如需为成功路径编写单元测试,您需要做出以下断言 - 在 GameViewModel 的实例已初始化的情况下,如果用正确的猜测词调用 updateUserGuess(),随后调用 checkUserGuess() 方法,则:

  • 系统会将正确的猜测结果传递给 updateUserGuess() 方法。
  • 调用 checkUserGuess() 方法。
  • 系统正确更新 scoreisGuessedWordWrong 状态的值。

完成下列步骤来创建测试:

  1. 在测试源代码集下创建一个新的软件包 com.example.android.unscramble.ui.test 并添加 GameViewModelTest.kt 文件,如以下屏幕截图所示:

fee23bfea18e890a.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()  {
   }
}

如需将正确的玩家单词传递给 viewModel.updateUserGuess() 方法,您需要从 GameUiState 中的乱序词中获取字母顺序正确的相应单词。为此,请先获取当前的游戏界面状态。

  1. 在函数主体中,创建一个 currentGameUiState 变量并为其分配 viewModel.uiState.value
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
    var currentGameUiState = viewModel.uiState.value
}
  1. 如需获取正确的玩家猜测词,请使用 getUnscrambleWord() 函数,该函数接受 currentGameUiState.currentScrambledWord 作为参数并返回字母顺序正确的单词。将此返回值存储在名称为 unScrambledWord 的新只读变量中,并分配 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. 若要使值 20 可读且可重复使用,请创建一个伴生对象,并将 20 分配给名称为 SCORE_AFTER_FIRST_CORRECT_ANSWERprivate 常量。使用新创建的常量更新测试。
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. 运行测试。

测试应当通过,因为所有断言均有效,如以下屏幕截图所示:

c6bd246467737a32.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. 运行测试并确认测试通过。

边界情况

如需测试界面的初始状态,您需要为 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 类的初始实例。将其分配给新的 currentGameUiState 只读变量。
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
    val currentGameUiState = viewModel.uiState.value
}
  1. 如需获取正确的玩家单词,请使用 getUnscrambleWord() 函数,该函数会接受 currentGameUiState.currentScrambledWord 单词并返回字母顺序正确的相应单词。将返回值分配给名称为 unScrambledWord 的新只读变量。
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
    val currentGameUiState = viewModel.uiState.value
    val unScrambledWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)

}
  1. 如需验证状态是否正确,请添加 assertTrue() 函数以断言 currentWordCount 属性设置为 1score 属性设置为 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. 运行测试并确认测试通过。

另一种边界情况是,在用户猜测所有单词后测试界面状态。您需要做出以下断言 - 当用户猜对所有单词时,以下情况是正确的:

  • 得分是最新的,
  • currentGameUiState.currentWordCount 属性等于 MAX_NO_OF_WORDS 常量的值,并且
  • 属性 currentGameUiState.isGameOver 设置为 true

如需添加测试,请完成以下步骤:

  1. 创建 gameViewModel_Initialization_FirstWordLoaded() 方法,并为其添加 @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. 如需获取正确的玩家单词,请使用 getUnscrambleWord() 函数,该函数会接受 currentGameUiState.currentScrambledWord 单词并返回字母顺序正确的相应单词。将此返回值存储在名称为 unScrambledWord 的新只读变量中,并分配 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. 如需验证状态是否正确,请添加 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. 运行测试并确认测试通过。

测试实例生命周期概览

如果仔细观察 viewModel 在测试中的初始化方式,您可能会注意到 viewModel 仅初始化了一次,尽管所有测试都使用它。此代码段显示了 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 类中有四种测试方法,因此 GameViewModelTest 会实例化四次。每个实例都有自己的 viewModel 属性副本。因此,测试执行的顺序无关紧要。

5. 代码覆盖率简介

代码覆盖率对于确定您是否对组成应用的类、方法和代码行进行了充分测试至关重要。

Android Studio 为本地单元测试提供了测试覆盖率工具,用于跟踪单元测试所覆盖的应用代码的百分比和区域。

使用 Android Studio 运行覆盖率测试

如需运行覆盖率测试,请执行以下操作:

  1. 右键点击项目窗格中的 GameViewModelTest.kt 文件,然后选择 28f58fea5649f4d5.png Run 'GameViewModelTest' with Coverage

8e76992459e6419e.png

  1. 测试执行完成后,点击右侧覆盖率面板中的 Flatten Packages 选项。

84f0624dbbb402ee.png

  1. 双击 com.example.android.unscramble.ui 软件包,如下图所示。

b064d50424763a7d.png

覆盖率面板会显示 GameViewModel 的覆盖率,如下图所示。

e0230eece97a81dc.png

分析测试报告

下图所示的报告分为两个方面:

  • 单元测试覆盖的方法百分比:在示例图中,到目前为止所编写的测试覆盖了 8 种方法中的 7 种。这占方法总数的 87%。
  • 单元测试覆盖的行数百分比:在示例图中,您编写的测试覆盖了 44 行代码中的 41 行。这占代码行的 93%。

e0230eece97a81dc.png

报告显示,到目前为止,您编写的单元测试遗漏了某些代码部分。如需确定遗漏的部分,请完成以下步骤:

  • 双击 GameViewModel

b4e3169a805497e9.png

Android Studio 会在窗口左侧显示带有额外颜色编码的 GameViewModel.kt 文件。亮绿色表示已覆盖这些代码行。

9348d72ff2737009.png

GameViewModel 中向下滚动时,您可能会注意到有几条线标为浅粉色。此颜色表示单元测试未覆盖这些代码行。

dd2419cd8af3a486.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 文件,然后从菜单中选择 28f58fea5649f4d5.png Run 'GameViewModelTest' with Coverage
  2. 如需在重新运行后重新编译结果,看到如下图所示的提示时,点击 Recompile 按钮。

4b938d2efe289fbc.png

  1. 构建成功后,再次找到 GameViewModel 元素,并确认覆盖率百分比为 100%。最终覆盖率报告如下图所示。

c24fe215d806b3fe.png

  1. 找到 GameViewModel.kt 文件并向下滚动,看看之前遗漏的路径是否已被覆盖。

5b96c0b7300e6f06.png

您学习了如何运行、分析和改进应用代码的代码覆盖率。

代码覆盖率较高是否意味着应用代码的质量较高?否。代码覆盖率表示单元测试所覆盖或执行的代码占全部代码的百分比。这并不表示该代码已通过验证。如果您从单元测试代码中移除所有断言并运行代码覆盖率,它仍会显示 100% 覆盖率。

覆盖率较高并不表示测试设计正确,也不表示测试可以验证应用的行为。您需要确保在所编写的测试中包含一些断言,并通过这些断言来验证待测试类的行为。您也不必费力编写单元测试以让整个应用实现 100% 的测试覆盖率,而应改用界面测试来测试应用代码的某些部分(例如 activity)。

不过,覆盖率较低意味着代码的大部分内容都未经测试。请利用代码覆盖率来查找测试未覆盖的代码部分,而不是利用代码覆盖率来衡量代码质量。

6. 获取解决方案代码

如需下载完成后的 Codelab 代码,您可以使用以下 Git 命令:

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

或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。

如果您想查看解决方案代码,请前往 GitHub 查看

7. 总结

恭喜!您学习了如何定义测试策略并实现了在 Unscramble 应用中测试 ViewModelStateFlow 的单元测试。在继续构建 Android 应用的过程中,务必在编写应用功能的同时编写测试,以确保您的应用在整个开发过程中都能正常运行。

总结

  • 使用 testImplementation 配置来指示依赖项适用于本地测试源代码,而不是应用代码。
  • 根据以下三种场景中对测试进行分类:成功路径、错误路径和边界情况。
  • 有效的单元测试应至少具备四个特征:有针对性、易于理解、确定性和独立性。
  • 测试方法会单独执行,以避免可变的测试实例状态产生意外的副作用。
  • 默认情况下,在执行每个测试方法之前,JUnit 都会创建测试类的一个新实例。
  • 代码覆盖率对于确定您是否对组成应用的类、方法和代码行进行了充分测试至关重要。

了解详情