編寫 ViewModel 的單元測試

透過集合功能整理內容 你可以依據偏好儲存及分類內容。

1. 事前準備

本程式碼研究室會指導您編寫單元測試,以測試 ViewModel 元件。您將為 Unscramble 遊戲應用程式新增單元測試。Unscramble 應用程式是一款有趣的文字遊戲,讓使用者必須猜測打散的字詞,才能正確獲得猜測結果。下圖為應用程式的預覽畫面:

ecb509065f9993b1.gif

編寫自動測試程式碼研究室中,您已瞭解自動測試的相關資訊和重要性。此外,您也已瞭解如何實作單元測試。

你學到:

  • 自動測試是指可驗證另一段程式碼的準確性的程式碼。
  • 測試是應用程式開發流程中相當重要的一環。藉由對應用程式持續執行測試,您可以在公開發布應用程式前,驗證應用程式的功能行為和可用性。
  • 您可透過單元測試來測試函式、類別和屬性。
  • 本機單元測試會在工作站上執行,也就是說不需要在 Android 裝置或模擬器即可在開發環境中執行。也就是說,在電腦上執行本機測試。

在進行之前,請務必完成撰寫自動測試,以及 Compose 中的 ViewModel 和狀態程式碼研究室。

必要條件

  • 對 Kotlin 的瞭解,包括函式、lambda 和無狀態可組合項。
  • 對如何在 Jetpack Compose 中建構版面配置有基本瞭解
  • 對質感設計有基本瞭解
  • 實作 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. 新增測試依附元件

在本程式碼研究室中,您將使用 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 檔案,位於 專案 窗格中的 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 應用程式中的成功路徑範例就是使用者輸入正確的字詞,然後按一下提交按鈕,就會產生分數、字詞計數和打散字詞的正確更新。

  • 錯誤路徑: 錯誤路徑測試的重點是測試負流程中的功能,也就是檢查應用程式如何回應錯誤條件或使用者輸入內容無效。要找出所有可能的錯誤流程並不容易,因為未達成預期的行為時,可能會產生許多可能的結果。

其中一個通用建議是列出所有可能的錯誤路徑、撰寫測試測試,並在發現不同情境時,讓單元測試持續演進。

Unscramble 應用程式中的錯誤路徑就是使用者輸入錯誤字詞並點選「提交」 按鈕的錯誤訊息,導致系統顯示錯誤訊息,且分數和字詞計數不會更新。

  • 界線用途: 界線的用途著重於測試應用程式中的邊界條件。在 Unscramble 應用程式中,邊界會檢查應用程式載入時的 UI 狀態,以及使用者播放字詞數量上限後的 UI 狀態。

針對上述類別建立測試情境可以做為測試計畫的準則。

建立測試

良好的單元測試通常具備下列四種屬性:

  • 聚焦: 將焦點放在測試單元,例如程式碼片段。這段程式碼通常是類別或方法。測試應聚焦於較小的程式碼,且應著重驗證個別程式碼的正確性,而非同時執行多段程式碼。
  • 清楚易懂: 閱讀程式碼時,文字應簡單易懂。開發人員一眼就能瞭解測試背後的意圖。
  • 確定性: 應一律通過或失敗。執行測試幾次時,無須對程式碼進行任何變更,測試應該會產生相同的結果。測試不應凌亂,在某個執行個體中失敗,卻在另一個執行個體中成功,儘管沒有修改程式碼。
  • 獨立模式: 不需要人為操作或設定,就能獨立執行。

Success path

如要為成功路徑撰寫單元測試,您必須宣告,如果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 取得打散字詞中的正確打散字詞。如要這麼做,請先取得目前的遊戲 UI 狀態。

  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 屬性為 falseassertEquals() 函式來驗證 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 屬性的值為 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. 執行測試以確認通過。

界線用途

如要測試 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 類別的初始執行個體。請將其指派給新的 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 屬性已設為 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. 執行測試以確認通過。

另一個界線用途就是在使用者猜出所有字詞後測試 UI 狀態。您必須斷言,當使用者正確猜出所有字詞時,結果如下:

  • 分數已更新。
  • 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_CorrectWordGuessed_ScoreUpdatedAndErrorFlagUnset 測試方法之後執行 gameViewModel_Initialization_FirstWordLoaded 測試方法會怎麼樣?初始化測試會失敗嗎?

兩個問題的回答均是否。系統會獨立執行測試方法,避免可變動的測試執行個體狀態產生非預期的副作用。根據預設,在執行每個測試方法之前,JUnit 會建立測試類別的新執行個體。

您目前在 GameViewModelTest 類別中有四種測試方法,因此 GameViewModelTest 會執行個體化四次。每個執行個體都有自己的 viewModel 屬性副本。因此,測試執行作業的順序無關緊要。

5. 程式碼涵蓋率簡介

程式碼涵蓋率扮演著重要角色,會決定您必須對應用程式組成類別、方法和程式碼是否充分測試。

Android Studio 提供了本機單元測試工具的測試涵蓋範圍工具,可用於追蹤單元測試所涵蓋應用程式程式碼的百分比和範圍。

使用 Android Studio 在涵蓋範圍內執行測試

如何執行涵蓋率的測試:

  1. 用滑鼠右鍵按一下在專案窗格中的 GameViewModelTest.kt 檔案,然後選取 28f58fea5649f4d5.png「Run 'GameViewModelTest' with Coverage」(執行 'ViewViewModelTest 搭配涵蓋率)

8e76992459e6419e.png

  1. 測試執行完成後,在右側的涵蓋率面板中,按一下 扁平套件 選項。

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 使用涵蓋率執行「GameViewModelTest」
  2. 如要在重新執行後重新編譯結果,請在看到下圖所示的提示時,按一下 重新編譯 按鈕。

4b938d2efe289fbc.png

  1. 建構成功後,請再次前往 GameViewModel 元素,並確認涵蓋率百分比為 100%。最終涵蓋率報表如下圖所示。

c24fe215d806b3fe.png

  1. 前往 GameViewModel.kt 檔案,然後向下捲動並檢查先前是否涵蓋了之前遺漏的路徑。

5b96c0b7300e6f06.png

您已瞭解如何執行、分析及改善應用程式程式碼的程式碼涵蓋率。

程式碼涵蓋率的高百分比是否意味著應用程式有高品質程式碼? 否。程式碼涵蓋率代表單元測試所涵蓋或執行的程式碼百分比。但不表示程式碼是否已通過驗證。如果從單元測試程式碼中移除所有宣告,並執行程式碼涵蓋率,但仍會顯示 100% 的涵蓋率。

高涵蓋率不代表測試設計正確,且測試可驗證應用程式行為。您必須確保編寫的測試具有宣告,驗證測試類別的行為。您不必費心撰寫單元測試,也能讓整個應用程式取得 100% 的測試。請改用 UI 測試來測試應用程式程式碼的某些部分,例如「活動」。

但涵蓋率低表示程式碼的大部分尚未經過測試。使用程式碼涵蓋率做為工具,找出測試未執行的程式碼部分,而非評估程式碼品質的工具。

6. 取得解決方案程式碼

完成程式碼研究室後,如要下載當中用到的程式碼,您可以使用以下 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 會建立測試類別的新執行個體。
  • 程式碼涵蓋率扮演著重要角色,會確定您是否正確測試了構成應用程式的類別、方法和程式碼行。

瞭解詳情