1. 开始之前
本 Codelab 将教您如何为 ViewModel
组件编写单元测试。您将为 Unscramble 游戏应用添加单元测试。Unscramble 应用是一款有趣的文字游戏,用户必须猜测一个打乱顺序的单词,猜对即可赚取积分。下图显示了应用的预览
在编写自动化测试 Codelab 中,您学习了什么是自动化测试以及为什么它们很重要。您还学习了如何实现单元测试。
您学到的知识
- 自动化测试是验证另一段代码准确性的代码。
- 测试是应用开发过程的重要组成部分。通过持续针对应用运行测试,您可以在公开发布应用之前验证其功能行为和可用性。
- 使用单元测试,您可以测试函数、类和属性。
- 本地单元测试在您的工作站上执行,这意味着它们在开发环境中运行,无需 Android 设备或模拟器。换句话说,本地测试在您的计算机上运行。
在继续之前,请确保您完成了编写自动化测试和Compose 中的 ViewModel 和 State Codelab。
前提条件
- 了解 Kotlin,包括函数、lambda 表达式和无状态可组合项
- 了解如何在 Jetpack Compose 中构建布局的基础知识
- 了解 Material Design 的基础知识
- 了解如何实现 ViewModel 的基础知识
您将学到的知识
- 如何在应用模块的
build.gradle.kts
文件中添加单元测试依赖项 - 如何创建测试策略来实施单元测试
- 如何使用 JUnit4 编写单元测试并了解测试实例生命周期
- 如何运行、分析和改进代码覆盖率
您将构建的内容
- 为 Unscramble 游戏应用编写单元测试
您需要准备的工具
- 最新版本的 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)中,如下图所示
起始代码包含以下文件
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 块中指定依赖项配置(例如 implementation
或 testImplementation
)。每种依赖项配置都为 Gradle 提供了关于如何使用该依赖项的不同说明。
添加依赖项
- 打开
app
模块的build.gradle.kts
文件,该文件位于 Project 窗格的app
目录中。
- 在文件中,向下滚动直到找到
dependencies{}
块。使用testImplementation
配置为junit
添加依赖项。
plugins {
...
}
android {
...
}
dependencies {
...
testImplementation("junit:junit:4.13.2")
}
- 在 build.gradle.kts 文件顶部的通知栏中,点击 Sync Now 以完成导入和构建,如下图所示
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")
。您可以创建一个变量,并在 implementation
和 androidTestImplementation
中重复使用它,如所示。
// 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
状态正确更新。
完成以下步骤创建测试
- 在测试源集下创建一个新包
com.example.android.unscramble.ui.test
,并添加文件,如下图所示
要为 GameViewModel
类编写单元测试,您需要该类的一个实例,以便可以调用该类的方法并验证状态。
- 在
GameViewModelTest
类的主体中,声明一个viewModel
属性,并将GameViewModel
类的一个实例赋给它。
class GameViewModelTest {
private val viewModel = GameViewModel()
}
- 要为成功路径编写单元测试,请创建一个
gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset()
函数,并用@Test
注解进行标注。
class GameViewModelTest {
private val viewModel = GameViewModel()
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
}
}
- 导入以下内容
import org.junit.Test
要将正确的玩家单词传递给 viewModel.updateUserGuess()
方法,您需要从 GameUiState
中的打乱顺序的单词获取正确的未打乱顺序的单词。为此,首先获取当前游戏 UI 状态。
- 在函数主体中,创建一个
currentGameUiState
变量,并将viewModel.uiState.value
赋给它。
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
var currentGameUiState = viewModel.uiState.value
}
- 要获取正确的玩家猜测,请使用
getUnscrambledWord()
函数,该函数接受currentGameUiState.currentScrambledWord
作为参数并返回未打乱顺序的单词。将此返回值存储在一个名为correctPlayerWord
的新只读变量中,并将getUnscrambledWord()
函数返回的值赋给它。
@Test
fun gameViewModel_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset() {
var currentGameUiState = viewModel.uiState.value
val correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
}
- 要验证猜测的单词是否正确,请添加对
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()
}
现在您已准备好断言游戏状态符合您的预期。
- 从
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
}
- 要检查猜测的单词是否正确以及分数是否更新,请使用
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)
}
- 导入以下内容
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
- 为了使值
20
更具可读性和可重用性,创建一个伴生对象,并将20
赋给名为SCORE_AFTER_FIRST_CORRECT_ANSWER
的private
常量。使用新创建的常量更新测试。
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
}
}
- 运行测试。
测试应该通过,因为所有断言都有效,如以下屏幕截图所示
错误路径
要为错误路径编写单元测试,您需要断言:当将错误的单词作为参数传递给 viewModel.updateUserGuess()
方法并调用 viewModel.checkUserGuess()
方法时,会发生以下情况
currentGameUiState.score
属性的值保持不变。currentGameUiState.isGuessedWordWrong
属性的值设置为true
,因为猜测错误。
完成以下步骤创建测试
- 在
GameViewModelTest
类的主体中,创建一个gameViewModel_IncorrectGuess_ErrorFlagSet()
函数,并用@Test
注解进行标注。
@Test
fun gameViewModel_IncorrectGuess_ErrorFlagSet() {
}
- 定义一个
incorrectPlayerWord
变量,并将值"and"
赋给它,这个值不应该存在于单词列表中。
@Test
fun gameViewModel_IncorrectGuess_ErrorFlagSet() {
// Given an incorrect word as input
val incorrectPlayerWord = "and"
}
- 添加对
viewModel.updateUserGuess()
方法的调用,并将incorrectPlayerWord
变量作为参数传递。 - 添加对
viewModel.checkUserGuess()
方法的调用来验证猜测。
@Test
fun gameViewModel_IncorrectGuess_ErrorFlagSet() {
// Given an incorrect word as input
val incorrectPlayerWord = "and"
viewModel.updateUserGuess(incorrectPlayerWord)
viewModel.checkUserGuess()
}
- 添加一个
currentGameUiState
变量,并将viewModel.uiState.value
状态的值赋给它。 - 使用断言函数断言
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)
}
- 导入以下内容
import org.junit.Assert.assertTrue
- 运行测试以确认其通过。
边界情况
要测试 UI 的初始状态,您需要为 GameViewModel
类编写单元测试。测试必须断言在初始化 GameViewModel
时,以下情况为真
currentWordCount
属性设置为1
。score
属性设置为0
。isGuessedWordWrong
属性设置为false
。isGameOver
属性设置为false
。
完成以下步骤添加测试
- 创建一个
gameViewModel_Initialization_FirstWordLoaded()
方法,并用@Test
注解进行标注。
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
}
- 访问
viewModel.uiState.value
属性以获取GameUiState
类的初始实例。将其赋给一个新的gameUiState
只读变量。
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
val gameUiState = viewModel.uiState.value
}
- 要获取正确的玩家单词,请使用
getUnscrambledWord()
函数,该函数接受gameUiState.currentScrambledWord
单词并返回未打乱顺序的单词。将返回值赋给一个名为unScrambledWord
的新只读变量。
@Test
fun gameViewModel_Initialization_FirstWordLoaded() {
val gameUiState = viewModel.uiState.value
val unScrambledWord = getUnscrambledWord(gameUiState.currentScrambledWord)
}
- 要验证状态是否正确,请添加
assertTrue()
函数,断言currentWordCount
属性设置为1
,并且score
属性设置为0
。 - 添加
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)
}
- 导入以下内容
import org.junit.Assert.assertNotEquals
- 运行测试以确认其通过。
另一个边界情况是测试用户猜完所有单词后的 UI 状态。您需要断言,当用户正确猜出所有单词时,则以下情况为真
- 分数是最新的;
currentGameUiState.currentWordCount
属性等于MAX_NO_OF_WORDS
常量的值;currentGameUiState.isGameOver
属性设置为true
。
完成以下步骤添加测试
- 创建一个
gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly()
方法,并用@Test
注解进行标注。在该方法中,创建一个expectedScore
变量,并将0
赋给它。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
var expectedScore = 0
}
- 要获取初始状态,添加一个
currentGameUiState
变量,并将viewModel.uiState.value
属性的值赋给该变量。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
var expectedScore = 0
var currentGameUiState = viewModel.uiState.value
}
- 要获取正确的玩家单词,请使用
getUnscrambledWord()
函数,该函数接受currentGameUiState.currentScrambledWord
单词并返回未打乱顺序的单词。将此返回值存储在一个名为correctPlayerWord
的新只读变量中,并将getUnscrambledWord()
函数返回的值赋给它。
@Test
fun gameViewModel_AllWordsGuessed_UiStateUpdatedCorrectly() {
var expectedScore = 0
var currentGameUiState = viewModel.uiState.value
var correctPlayerWord = getUnscrambledWord(currentGameUiState.currentScrambledWord)
}
- 要测试用户是否猜对了所有答案,请使用
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) {
}
}
- 在
repeat
块中,将SCORE_INCREASE
常量的值添加到expectedScore
变量中,以断言每次正确回答后分数都会增加。 - 添加对
viewModel.updateUserGuess()
方法的调用,并将correctPlayerWord
变量作为参数传递。 - 添加对
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()
}
}
- 更新当前玩家单词,使用
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)
}
}
- 添加一个
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)
}
- 导入以下内容
import com.example.unscramble.data.MAX_NO_OF_WORDS
- 运行测试以确认其通过。
测试实例生命周期概览
当您仔细查看测试中 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 运行带覆盖率的测试
运行带覆盖率的测试
- 在项目窗格中右键点击
GameViewModelTest.kt
文件,然后选择Run ‘GameViewModelTest' with Coverage。
- 测试执行完成后,在右侧的覆盖率面板中,点击 Flatten Packages 选项。
- 注意
com.example.android.unscramble.ui
包,如以下图片所示。
- 双击包名
com.example.android.unscramble.ui
,会显示GameViewModel
的覆盖率,如以下图片所示
分析测试报告
下图所示的报告分为两个方面
- 单元测试覆盖的方法百分比:在示例图中,您目前编写的测试覆盖了 8 个方法中的 7 个。即总方法的 87%。
- 单元测试覆盖的代码行百分比:在示例图中,您编写的测试覆盖了 41 行代码中的 39 行。即代码行的 95%。
报告表明您目前编写的单元测试遗漏了代码的某些部分。要确定遗漏了哪些部分,请完成以下步骤
- 双击 GameViewModel。
Android Studio 会显示 GameViewModel.kt
文件,并在窗口左侧添加额外的颜色编码。亮绿色表示这些代码行已被覆盖。
当您在 GameViewModel
中向下滚动时,您可能会注意到有几行标记为浅粉色。此颜色表示这些代码行未被单元测试覆盖。
提高覆盖率
为了提高覆盖率,您需要编写一个测试来覆盖遗漏的路径。您需要添加一个测试来断言当用户跳过一个单词时,则以下情况为真
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)
}
完成以下步骤重新运行覆盖率
- 右键点击
GameViewModelTest.kt
文件,然后从菜单中选择 Run ‘GameViewModelTest' with Coverage。 - 构建成功后,再次导航到 GameViewModel 元素,确认覆盖率百分比为 100%。最终的覆盖率报告如下图所示。
- 导航到
GameViewModel.kt
文件并向下滚动,检查之前遗漏的路径现在是否已覆盖。
您学习了如何运行、分析和提高应用代码的代码覆盖率。
代码覆盖率高是否意味着应用代码质量高?否。代码覆盖率表示单元测试覆盖或执行的代码百分比。它并不表示代码已通过验证。如果您从单元测试代码中删除所有断言并运行代码覆盖率,它仍然会显示 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 应用中的 ViewModel
和 StateFlow
。在继续构建 Android 应用时,请确保与应用功能一起编写测试,以确认应用在整个开发过程中正常工作。
总结
- 使用
testImplementation
配置来表明依赖项适用于本地测试源代码,而不是应用代码。 - 目标是将测试分为三种场景:成功路径、错误路径和边界情况。
- 一个好的单元测试至少有四个特点:聚焦、易于理解、确定性和自包含。
- 测试方法独立执行,以避免可变测试实例状态产生意外的副作用。
- 默认情况下,在执行每个测试方法之前,JUnit 会创建一个新的测试类实例。
- 代码覆盖率在确定您是否充分测试了构成应用的类、方法和代码行方面起着至关重要的作用。