为 ViewModel 编写单元测试

1. 准备工作

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

bb1e97c357603a27.png

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

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

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

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

前提条件

  • 了解 Kotlin,包括函数、lambda 和无状态可组合项
  • 具备在 Jetpack Compose 中构建布局的基础知识
  • 具备有关 Material Design 的基础知识
  • 具备有关如何实现 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 源代码集内,如下图所示:

Android Studio“Project”窗格中的 test 文件夹

起始代码包含以下文件:

  • 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 文件的依赖项代码块中指定依赖项配置(例如 implementationtestImplementation)。每种依赖项配置都向 Gradle 提供了有关如何使用相应依赖项的不同说明。

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

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

“Project”窗格中的 build.gradle.kts 文件

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

android {
    ...
}

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

1c20fc10750ca60c.png

Compose 物料清单 (BoM)

建议使用 Compose 物料清单 (BoM) 管理 Compose 库版本。借助 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. 测试策略

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

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

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

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

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

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

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

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

创建测试

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

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

成功路径

如需为成功路径编写单元测试,您需要做出以下断言 - 在 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 中的乱序词中获取字母顺序正确的相应单词。为此,请先获取当前的游戏界面状态。

  1. 在函数主体中,创建一个 currentGameUiState 变量并为其分配 viewModel.uiState.value
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
    var currentGameUiState = viewModel.uiState.value
}
  1. 如需获取正确的玩家猜测词,请使用 getUnscrambledWord() 函数,该函数接受 currentGameUiState.currentScrambledWord 作为参数并返回字母顺序正确的单词。将此返回值存储在名称为 unScrambledWord 的新只读变量中,并分配 getUnscrambledWord() 函数返回的值。
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
    var currentGameUiState = viewModel.uiState.value
    val unScrambledWord = 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 可读且可重复使用,请创建一个伴生对象,并将 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. 导入以下代码:
import org.junit.Assert.assertTrue
  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 类的初始实例。将其分配给新的 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 属性设置为 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. 导入以下代码:
import org.junit.Assert.assertNotEquals
  1. 运行测试并确认测试通过。

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

  • 得分是最新的;
  • 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 仅初始化了一次,尽管所有测试都使用它。此代码段显示了 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. 右键点击“Project”窗格中的 GameViewModelTest.kt 文件,然后选择 28f58fea5649f4d5.png Run 'GameViewModelTest' with Coverage

已选中“Run 'GameViewModelTest' with Coverage”选项的“Project”窗格

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

突出显示“Flatten Packages”选项

  1. 请注意 com.example.android.unscramble.ui 软件包,如下图所示。

a4408d8870366144.png

  1. 双击软件包 com.example.android.unscramble.ui 名称,以显示 GameViewModel 的覆盖率,如下图所示:

3ec7ea7896b52f3a.png

分析测试报告

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

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

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

  • 双击 GameViewModel

d78155448e2b9304.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%。最终覆盖率报告如下图所示。

e91469b284854b8c.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
$ cd basic-android-kotlin-compose-training-unscramble
$ git checkout main

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

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

7. 总结

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

总结

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

了解详情