为 ViewModel 编写单元测试

1. 开始之前

本 Codelab 将教您如何为 ViewModel 组件编写单元测试。您将为 Unscramble 游戏应用添加单元测试。Unscramble 应用是一款有趣的文字游戏,用户必须猜测一个打乱顺序的单词,猜对即可赚取积分。下图显示了应用的预览

bb1e97c357603a27.png

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

您学到的知识

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

在继续之前,请确保您完成了编写自动化测试Compose 中的 ViewModel 和 State 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 源集(source set)中,如下图所示

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. 打开 app 模块的 build.gradle.kts 文件,该文件位于 Project 窗格的 app 目录中。

bc235c0754e4e0f2.png

  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 库版本号。
  • 使用 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() 方法。
  • score 的值和 isGuessedWordWrong 状态正确更新。

完成以下步骤创建测试

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

另一个边界情况是测试用户猜完所有单词后的 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 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

分析测试报告

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

  • 单元测试覆盖的方法百分比:在示例图中,您目前编写的测试覆盖了 8 个方法中的 7 个。即总方法的 87%。
  • 单元测试覆盖的代码行百分比:在示例图中,您编写的测试覆盖了 41 行代码中的 39 行。即代码行的 95%。

报告表明您目前编写的单元测试遗漏了代码的某些部分。要确定遗漏了哪些部分,请完成以下步骤

  • 双击 GameViewModel

c934ba14e096bddd.png

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

edc4e5faf352119b.png

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

6df985f713337a0c.png

提高覆盖率

为了提高覆盖率,您需要编写一个测试来覆盖遗漏的路径。您需要添加一个测试来断言当用户跳过一个单词时,则以下情况为真

  • currentGameUiState.score 属性保持不变。
  • currentGameUiState.currentWordCount 属性加一,如以下代码片段所示。

为了准备提高覆盖率,将以下测试方法添加到 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 测试来测试应用代码的某些部分,例如 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 会创建一个新的测试类实例。
  • 代码覆盖率在确定您是否充分测试了构成应用的类、方法和代码行方面起着至关重要的作用。

了解更多