编写 ViewModel 的单元测试

1. 开始之前

本 Codelab 教您编写单元测试以测试 ViewModel 组件。您将为 Unscramble 游戏应用添加单元测试。Unscramble 应用是一款有趣的文字游戏,用户需要猜出一个乱序的单词,猜对即可获得分数。下图显示了应用的预览

bb1e97c357603a27.png

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

您学习了

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

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

先决条件

  • 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 源代码集中,如下图所示

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{} 块。使用 testImplementation 配置为 junit 添加依赖项。
plugins {
    ...
}

android {
    ...
}

dependencies {
    ...
    testImplementation("junit:junit:4.13.2")
}
  1. build.gradle.kts 文件顶部的通知栏中,单击 立即同步 以让导入和构建完成,如下面的屏幕截图所示

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 库版本号。
  • 使用 implementation platform("androidx.compose:compose-bom:2023.06.01") 导入 BOM

这是因为 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 应用中成功路径的一个示例是,当用户输入正确的单词并单击 提交 按钮时,分数、单词计数和乱序单词的正确更新。

  • 错误路径:错误路径测试侧重于测试负向流程的功能,即检查应用如何响应错误条件或无效的用户输入。确定所有可能的错误流程非常具有挑战性,因为当预期行为未实现时,可能会有很多可能的结果。

一条一般的建议是列出所有可能的错误路径,为它们编写测试,并在发现不同的场景时让您的单元测试不断发展。

Unscramble 应用中错误路径的一个示例是,用户输入错误的单词并单击 提交 按钮,这会导致出现错误消息,并且分数和单词计数不会更新。

  • 边界情况:边界情况侧重于测试应用中的边界条件。在 Unscramble 应用中,边界是在应用加载时检查 UI 状态以及用户玩最大数量的单词后的 UI 状态。

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

创建测试

一个好的单元测试通常具有以下四个属性

  • 专注:它应该专注于测试一个单元,例如一段代码。这段代码通常是一个类或一个方法。测试应该范围狭窄,并专注于验证各个代码段的正确性,而不是同时验证多个代码段。
  • 易于理解:代码应简单易懂。开发人员只需一眼就能立即理解测试背后的意图。
  • 确定性:它应该始终通过或失败。在不更改任何代码的情况下,无论运行测试多少次,测试都应产生相同的结果。测试不应该出现不稳定情况,即在一次实例中失败而在另一次实例中通过,尽管代码没有修改。
  • 自包含:它不需要任何人工交互或设置,并且可以独立运行。

成功路径

要为成功路径编写单元测试,您需要断言,假设已初始化 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 可读且可重用,请创建一个伴生对象,并将 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. 运行测试。

测试应该通过,因为所有断言都是有效的,如下面的屏幕截图所示

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 属性的值为 0currentGameUiState.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. 运行测试以确认它通过。

另一个边界情况是测试用户猜测所有单词后的 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 也只初始化一次。此代码片段显示了 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 文件,然后选择 cf4c5adfe69a119f.png 使用覆盖率运行“GameViewModelTest”

73545d5ade3851df.png

  1. 测试执行完成后,在右侧的覆盖率面板中,单击**展开包**选项。

90e2989f8b58d254.png

  1. 注意如下所示的**com.example.android.unscramble.ui** 包。

1c755d17d19c6f65.png

  1. 双击包**com.example.android.unscramble.ui** 的名称,将显示 GameViewModel 的覆盖率,如下所示。

14cf6ca3ffb557c4.png

分析测试报告

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

  • 单元测试覆盖的方法的百分比: 在示例图中,你编写的测试到目前为止覆盖了 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 文件,然后从菜单中选择**使用覆盖率运行“GameViewModelTest”**。
  2. 构建成功后,再次导航到**GameViewModel** 元素,并确认覆盖率百分比为 100%。最终的覆盖率报告如下所示。

145781df2c68f71c.png

  1. 导航到 GameViewModel.kt 文件并向下滚动,检查之前遗漏的路径是否现在已覆盖。

357263bdb9219779.png

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

较高的代码覆盖率百分比是否意味着较高的应用代码质量? 否。代码覆盖率表示单元测试覆盖或执行的代码的百分比。它并不表示代码已得到验证。如果你删除单元测试代码中的所有断言并运行代码覆盖率,它仍然会显示 100% 的覆盖率。

高覆盖率并不表示测试设计正确,也不表示测试验证了应用的行为。你需要确保你编写的测试具有断言,这些断言验证了正在测试的类的行为。你也不必努力编写单元测试以获得整个应用的 100% 测试覆盖率。你应该使用 UI 测试来测试应用代码的某些部分,例如 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都会创建一个新的测试类实例。
  • 代码覆盖率在确定你是否充分测试了构成应用的类、方法和代码行方面发挥着至关重要的作用。

了解更多