測試替身和依附元件插入簡介

這個程式碼研究室是「Android Kotlin 進階功能」課程的一部分。如果您按部就班完成每一堂程式碼研究室課程,就能充分體驗到本課程的價值,但這不是強制要求。如要查看所有課程程式碼研究室,請前往 Android Kotlin 進階功能程式碼研究室登陸頁面

簡介

這個第二個測試程式碼研究室的主題是測試替身:何時在 Android 中使用測試替身,以及如何使用依附元件注入、服務定位器模式和程式庫實作測試替身。您將學會如何編寫:

  • 存放區單元測試
  • 片段和 ViewModel 整合測試
  • 片段導覽測試

必備知識

您必須已經熟悉下列項目:

課程內容

  • 如何規劃測試策略
  • 如何建立及使用測試替身,也就是模擬和仿冒物件
  • 如何在 Android 上使用手動依附元件插入進行單元測試和整合測試
  • 如何套用服務定位器模式
  • 如何測試存放區、片段、ViewModel 和 Navigation 元件

您將使用下列程式庫和程式碼概念:

學習內容

  • 使用測試替身和依附元件插入功能,為存放區編寫單元測試。
  • 使用測試替身和依附元件插入功能,編寫檢視區塊模型的單元測試。
  • 使用 Espresso UI 測試架構,為片段及其 ViewModel 編寫整合測試。
  • 使用 Mockito 和 Espresso 編寫導覽測試。

在本系列程式碼研究室中,您將使用 TO-DO Notes 應用程式。這個應用程式可讓您寫下待辦事項,並以清單形式顯示。然後標示為完成或未完成、篩選或刪除。

這個應用程式是以 Kotlin 編寫,有幾個畫面,使用 Jetpack 元件,並遵循《應用程式架構指南》的架構。瞭解如何測試這個應用程式後,您就能測試使用相同程式庫和架構的應用程式。

下載程式碼

如要開始,請先下載程式碼:

下載 ZIP 檔

或者,您也可以複製 GitHub 存放區的程式碼:

$ git clone https://github.com/googlecodelabs/android-testing.git
$ cd android-testing
$ git checkout end_codelab_1

請花一點時間熟悉程式碼,並按照下列指示操作。

步驟 1:執行範例應用程式

下載待辦事項應用程式後,請在 Android Studio 中開啟並執行。應該會編譯。請按照下列步驟探索應用程式:

  • 使用加號浮動動作按鈕建立新工作。先輸入標題,然後輸入工作的其他資訊。按一下綠色勾號 FAB 儲存。
  • 在工作清單中,按一下剛完成的工作標題,然後查看該工作的詳細資料畫面,即可看到其餘說明。
  • 在清單或詳細資料畫面中,勾選該工作的核取方塊,將狀態設為「已完成」
  • 返回工作畫面,開啟篩選選單,然後依「進行中」和「已完成」狀態篩選工作。
  • 開啟導覽匣,然後按一下「統計資料」
  • 返回總覽畫面,然後從導覽匣選單中選取「清除已完成」,刪除所有狀態為「已完成」的工作

步驟 2:探索範例應用程式程式碼

「待辦事項」應用程式是以熱門的架構藍圖測試和架構範例為基礎 (使用範例的反應式架構版本)。應用程式採用的是《應用程式架構指南》中的架構。並搭配片段、存放區和 Room 使用 ViewModel。如果您熟悉下列任一範例,這個應用程式的架構與這些範例類似:

您不必深入瞭解任何一層的邏輯,但請務必瞭解應用程式的整體架構。

以下是您會看到的套裝方案摘要:

Package: com.example.android.architecture.blueprints.todoapp

.addedittask

新增或編輯工作畫面:用於新增或編輯工作的 UI 層程式碼。

.data

資料層:處理工作資料層。其中包含資料庫、網路和存放區程式碼。

.statistics

統計資料畫面:統計資料畫面的 UI 層程式碼。

.taskdetail

工作詳細資料畫面:單一工作的 UI 層程式碼。

.tasks

工作畫面:所有工作的清單 UI 層代碼。

.util

公用程式類別:應用程式各部分使用的共用類別,例如用於多個畫面的滑動重新整理版面配置。

資料層 (.data)

這個應用程式包含 remote 套件中的模擬網路層,以及 local 套件中的資料庫層。為求簡化,這個專案會使用延遲時間的 HashMap 模擬網路層,而不是發出實際的網路要求。

DefaultTasksRepository 會協調或調解網路層和資料庫層之間的關係,並將資料傳回 UI 層。

UI 層 ( .addedittask、.statistics、.taskdetail、.tasks)

每個 UI 層套件都包含片段和檢視模型,以及 UI 所需的任何其他類別 (例如工作清單的配接器)。TaskActivity 是包含所有片段的活動。

導覽

應用程式的導覽是由 Navigation 元件控制。這是在 nav_graph.xml 檔案中定義的。檢視模型會使用 Event 類別觸發導覽,並決定要傳遞哪些引數。片段會觀察 Event,並在畫面之間執行實際導覽。

在本程式碼研究室中,您將瞭解如何使用測試替身和依附元件插入功能,測試存放區、檢視模式和片段。在深入瞭解這些內容之前,請務必先瞭解編寫這些測試的理由,這將有助於您決定測試內容和方式。

本節將說明一般測試的最佳做法,以及這些做法在 Android 上的應用。

測試金字塔

思考測試策略時,有三個相關的測試面向:

  • 範圍:測試涵蓋多少程式碼?測試可以針對單一方法、整個應用程式或介於兩者之間的範圍執行。
  • 速度:測試執行速度有多快?測試速度可能從毫秒到幾分鐘不等。
  • 保真度:測試有多「真實」?舉例來說,如果您測試的程式碼部分需要發出網路要求,測試程式碼是否會實際發出這項要求,還是會模擬結果?如果測試實際上會與網路通訊,表示保真度較高。但缺點是測試時間可能較長、網路中斷時可能會發生錯誤,以及使用成本可能較高。

這些層面之間存在固有的取捨關係。舉例來說,速度和準確度是兩難的選擇,測試速度越快,準確度通常就越低,反之亦然。自動化測試通常可分為以下三類:

  • 單元測試:這類測試的重點非常明確,只會針對單一類別 (通常是該類別中的單一方法) 執行測試。如果單元測試失敗,您就能確切知道程式碼中發生問題的位置。由於在現實世界中,應用程式涉及的不只是執行一個方法或類別,因此單元測試的保真度較低。速度夠快,每次變更程式碼時都能執行。這些測試通常是在本機執行的測試 (位於 test 來源集)。範例: 測試檢視區塊模型和存放區中的單一方法。
  • 整合測試:測試多個類別的互動,確保這些類別一起使用時的行為符合預期。整合測試的結構化方式之一,是測試單一功能,例如儲存工作的功能。整合測試的程式碼範圍比單元測試更大,但仍經過最佳化,可快速執行,而非追求完整保真度。視情況而定,這些測試可以在本機執行,也可以做為檢測設備測試執行。範例: 測試單一片段和檢視模型配對的所有功能。
  • 端對端測試 (E2e):測試多項功能是否能正常運作。這類測試會測試應用程式的大部分內容,並模擬實際使用情況,因此通常速度較慢。這類測試的準確度最高,可確保應用程式整體運作正常。一般來說,這些測試會是已檢測的測試 (位於 androidTest 來源集中)。
    範例: 啟動整個應用程式,並一併測試幾項功能。

這些測試的建議比例通常以金字塔表示,其中絕大多數是單元測試。

架構與測試

您能否在測試金字塔的所有不同層級測試應用程式,本質上與應用程式架構息息相關。舉例來說,架構極度不良的應用程式可能會將所有邏輯放在一個方法中。您或許可以為此編寫端對端測試,因為這類測試通常會測試應用程式的大部分內容,但編寫單元或整合測試呢?所有程式碼都集中在一處,因此很難只測試與單一單元或功能相關的程式碼。

較好的做法是將應用程式邏輯分解為多個方法和類別,讓每個部分都能獨立測試。架構是劃分及整理程式碼的方式,可簡化單元和整合測試。您要測試的待辦事項應用程式採用特定架構:



在本課程中,您將瞭解如何適當隔離並測試上述架構的各個部分:

  1. 首先,您要單元測試 存放區
  2. 接著,您會在檢視模型中使用測試替身,這是檢視模型單元測試整合測試的必要條件。
  3. 接下來,您將學習如何為片段及其檢視畫面模型編寫整合測試
  4. 最後,您將學習如何編寫包含導覽元件整合測試

下一個課程會介紹端對端測試。

為類別的一部分 (方法或一小組方法) 編寫單元測試時,目標是只測試該類別中的程式碼

測試特定類別或類別中的程式碼可能很棘手。讓我們來看看下面這個例子。開啟 main 來源集中的 data.source.DefaultTaskRepository 類別。這是應用程式的存放區,也是您接下來要編寫單元測試的類別。

您的目標是只測試該類別中的程式碼。然而,DefaultTaskRepository 必須依附於 LocalTaskDataSourceRemoteTaskDataSource 等其他類別才能運作。換句話說,LocalTaskDataSourceRemoteTaskDataSourceDefaultTaskRepository依附元件

因此,DefaultTaskRepository 中的每個方法都會呼叫資料來源類別的方法,而這些方法又會呼叫其他類別中的方法,將資訊儲存至資料庫或與網路通訊。



舉例來說,請參閱 DefaultTasksRepo 中的這個方法。

    suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>> {
        if (forceUpdate) {
            try {
                updateTasksFromRemoteDataSource()
            } catch (ex: Exception) {
                return Result.Error(ex)
            }
        }
        return tasksLocalDataSource.getTasks()
    }

getTasks 是您可能對存放區發出的「基本」呼叫之一。這個方法包括從 SQLite 資料庫讀取資料,以及發出網路呼叫 (呼叫 updateTasksFromRemoteDataSource)。這比單純的存放區程式碼涉及更多程式碼。

以下是難以測試存放區的一些具體原因:

  • 您需要思考如何建立及管理資料庫,才能為這個存放區進行最簡單的測試。這會引發「這應該是本機測試還是檢測設備測試?」等問題,以及是否應使用 AndroidX Test 取得模擬的 Android 環境。
  • 部分程式碼 (例如網路程式碼) 可能需要很長時間才能執行,有時甚至會失敗,導致測試時間過長且不穩定。
  • 測試可能會失去診斷能力,無法判斷導致測試失敗的程式碼。測試可能會開始測試非存放區程式碼,因此舉例來說,您預期的「存放區」單元測試可能會因某些相依程式碼 (例如資料庫程式碼) 發生問題而失敗。

測試替身

解決方法是測試存放區時不要使用實際的網路或資料庫程式碼,而是使用測試替身。測試替身是專為測試而設計的類別版本。這項功能旨在取代測試中的類別實際版本。這就像特技替身演員專門負責危險動作,取代真正的演員。

以下列舉幾種測試替身:

Fake

測試替身具有類別的「運作中」實作項目,但實作方式適合測試,不適合用於正式環境。

Mock

追蹤呼叫了哪些方法的測試替身。然後,視方法是否正確呼叫,通過或未通過測試。

票根

測試替身不包含任何邏輯,只會傳回您程式設計要傳回的內容。舉例來說,您可以編寫 StubTaskRepository,從 getTasks 傳回特定工作組合。

範例

傳遞但未使用的測試替身,例如您只需要將其做為參數提供。如果您有 NoOpTaskRepository,則只會在任何方法中實作 TaskRepository,且沒有任何程式碼。

Spy

測試替身也會追蹤一些額外資訊;舉例來說,如果您建立 SpyTaskRepository,測試替身可能會追蹤 addTask 方法的呼叫次數。

如要進一步瞭解測試替身,請參閱「Testing on the Toilet:認識測試替身」。

Android 中最常見的測試替身是「假物件」和「模擬物件」

在這項工作中,您將建立 FakeDataSource 測試替身,以便單元測試 DefaultTasksRepository,並與實際資料來源分離。

步驟 1:建立 FakeDataSource 類別

在這個步驟中,您將建立名為 FakeDataSouce 的類別,這個類別會是 LocalDataSourceRemoteDataSource 的測試替身。

  1. test 來源集中,按一下滑鼠右鍵,然後依序選取「New」->「Package」

  1. 建立包含 source 套件的 data 套件。
  2. data/source 套件中建立名為 FakeDataSource 的新類別。

步驟 2:實作 TasksDataSource 介面

如要將新類別 FakeDataSource 做為測試替身,必須能夠取代其他資料來源。這些資料來源包括 TasksLocalDataSourceTasksRemoteDataSource

  1. 請注意,這兩者都會實作 TasksDataSource 介面。
class TasksLocalDataSource internal constructor(
    private val tasksDao: TasksDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }

object TasksRemoteDataSource : TasksDataSource { ... }
  1. FakeDataSource 實作 TasksDataSource
class FakeDataSource : TasksDataSource {

}

Android Studio 會顯示錯誤訊息,指出您尚未實作 TasksDataSource 的必要方法。

  1. 使用快速修正選單,然後選取「Implement members」


  1. 選取所有方法,然後按一下「確定」

步驟 3:在 FakeDataSource 中實作 getTasks 方法

FakeDataSource 是一種稱為「虛擬物件」的特定型別測試替身。虛擬物件是測試替身,具有類別的「運作中」實作項目,但實作方式適合測試,不適合用於實際工作環境。「可運作」實作是指類別會根據輸入內容產生實際輸出內容。

舉例來說,虛擬資料來源不會連線至網路,也不會將任何內容儲存至資料庫,而是只會使用記憶體內清單。這會「如您所預期般運作」,也就是說,取得或儲存工作的方法會傳回預期結果,但您永遠無法在正式環境中使用這項實作,因為系統不會將工作儲存至伺服器或資料庫。

A FakeDataSource

  • 可讓您測試 DefaultTasksRepository 中的程式碼,不必依賴真實的資料庫或網路。
  • 為測試提供「夠真實」的實作。
  1. 變更 FakeDataSource 建構函式,建立名為 tasksvar,該函式是 MutableList<Task>?,預設值為空白的可變動清單。
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }


這是「偽造」資料庫或伺服器回應的任務清單。目前的目標是測試存放區 getTasks 方法。這會呼叫資料來源的 getTasksdeleteAllTaskssaveTask 方法。

編寫這些方法的虛擬版本:

  1. 寫入 getTasks:如果 tasks 不是 null,請傳回 Success 結果。如果 tasksnull,就會傳回 Error 結果。
  2. 寫入 deleteAllTasks:清除可變動的工作清單。
  3. 撰寫 saveTask:將工作新增至清單。

這些方法是為 FakeDataSource 實作,看起來如下所示。

override suspend fun getTasks(): Result<List<Task>> {
    tasks?.let { return Success(ArrayList(it)) }
    return Error(
        Exception("Tasks not found")
    )
}


override suspend fun deleteAllTasks() {
    tasks?.clear()
}

override suspend fun saveTask(task: Task) {
    tasks?.add(task)
}

如有需要,請使用下列匯入陳述式:

import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task

這與實際的本機和遠端資料來源運作方式類似。

在這個步驟中,您會使用手動依附元件插入技術,以便使用剛才建立的虛擬測試替身。

主要問題是您有 FakeDataSource,但不清楚如何在測試中使用。這項作業需要取代 TasksRemoteDataSourceTasksLocalDataSource,但僅限於測試。TasksRemoteDataSourceTasksLocalDataSource 都是 DefaultTasksRepository 的依附元件,也就是說 DefaultTasksRepositories 需要或「依附」於這些類別才能執行。

目前,依附元件是在 DefaultTasksRepositoryinit 方法中建構。

DefaultTasksRepository.kt

class DefaultTasksRepository private constructor(application: Application) {

    private val tasksRemoteDataSource: TasksDataSource
    private val tasksLocalDataSource: TasksDataSource

   // Some other code

    init {
        val database = Room.databaseBuilder(application.applicationContext,
            ToDoDatabase::class.java, "Tasks.db")
            .build()

        tasksRemoteDataSource = TasksRemoteDataSource
        tasksLocalDataSource = TasksLocalDataSource(database.taskDao())
    }
    // Rest of class
}

因為您是在 DefaultTasksRepository 內建立及指派 taskLocalDataSourcetasksRemoteDataSource,所以基本上是硬式編碼。無法換入測試替身。

您應該提供這些資料來源給類別,而不是將其硬式編碼。提供依附元件的程序稱為「插入依附元件」。提供依附元件的方式有很多種,因此依附元件注入的類型也不同。

建構函式依附元件插入可讓您將測試替身傳遞至建構函式,藉此替換測試替身。

不插入

注入

步驟 1:在 DefaultTasksRepository 中使用建構函式依附元件插入

  1. DefaultTaskRepository 的建構函式從接收 Application 改為接收資料來源和協同程式調度器 (您也需要為測試替換這個調度器,詳情請參閱第三堂課的協同程式部分)。

DefaultTasksRepository.kt

// REPLACE
class DefaultTasksRepository private constructor(application: Application) { // Rest of class }

// WITH

class DefaultTasksRepository(
    private val tasksRemoteDataSource: TasksDataSource,
    private val tasksLocalDataSource: TasksDataSource,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) { // Rest of class }
  1. 由於您已傳遞依附元件,請移除 init 方法。您不再需要建立依附元件。
  2. 同時刪除舊的執行個體變數。您在建構函式中定義這些項目:

DefaultTasksRepository.kt

// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
  1. 最後,更新 getRepository 方法以使用新的建構函式:

DefaultTasksRepository.kt

    companion object {
        @Volatile
        private var INSTANCE: DefaultTasksRepository? = null

        fun getRepository(app: Application): DefaultTasksRepository {
            return INSTANCE ?: synchronized(this) {
                val database = Room.databaseBuilder(app,
                    ToDoDatabase::class.java, "Tasks.db")
                    .build()
                DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
                    INSTANCE = it
                }
            }
        }
    }

您現在使用的是建構函式依附元件插入!

步驟 2:在測試中使用 FakeDataSource

現在程式碼使用建構函式依附元件注入,您可以使用虛擬資料來源測試 DefaultTasksRepository

  1. DefaultTasksRepository 類別名稱上按一下滑鼠右鍵,然後依序選取「產生」和「測試」
  2. 按照提示在「test」來源集中建立 DefaultTasksRepositoryTest
  3. 在新的 DefaultTasksRepositoryTest 類別頂端,新增下列成員變數,代表虛擬資料來源中的資料。

DefaultTasksRepositoryTest.kt

    private val task1 = Task("Title1", "Description1")
    private val task2 = Task("Title2", "Description2")
    private val task3 = Task("Title3", "Description3")
    private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
    private val localTasks = listOf(task3).sortedBy { it.id }
    private val newTasks = listOf(task3).sortedBy { it.id }
  1. 建立三個變數、兩個 FakeDataSource 成員變數 (每個存放區的資料來源各一個),以及要測試的 DefaultTasksRepository 變數。

DefaultTasksRepositoryTest.kt

    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

建立方法,設定並初始化可測試的 DefaultTasksRepository。這個 DefaultTasksRepository 會使用測試替身 FakeDataSource

  1. 建立名為 createRepository 的方法,並加上 @Before 註解。
  2. 使用 remoteTaskslocalTasks 清單,例項化虛擬資料來源。
  3. 使用您剛建立的兩個虛假資料來源和 Dispatchers.Unconfined,例項化 tasksRepository

最終方法應如下所示。

DefaultTasksRepositoryTest.kt

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

步驟 3:撰寫 DefaultTasksRepository getTasks() 測試

現在來撰寫 DefaultTasksRepository 測試!

  1. 為存放區的 getTasks 方法編寫測試。確認使用 true 呼叫 getTasks 時 (表示應從遠端資料來源重新載入),傳回的資料來自遠端資料來源 (而非本機資料來源)。

DefaultTasksRepositoryTest.kt

@Test
    fun getTasks_requestsAllTasksFromRemoteDataSource(){
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Success

        // Then tasks are loaded from the remote data source
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

呼叫 getTasks: 時會發生錯誤

步驟 4:新增 runBlockingTest

由於 getTaskssuspend 函式,因此需要啟動協同程式來呼叫,協同程式錯誤是預期行為。為此,您需要協同程式範圍。如要解決這項錯誤,您需要新增一些 Gradle 依附元件,以便在測試中處理啟動協同程式。

  1. 使用 testImplementation,將測試協同程式所需的依附元件新增至測試來源集。

app/build.gradle

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

別忘了同步處理!

kotlinx-coroutines-test 是協同程式測試程式庫,專門用於測試協同程式。如要執行測試,請使用 runBlockingTest 函式。這是協同程式測試程式庫提供的函式。它會接收程式碼區塊,然後在特殊的協同程式環境中執行這個程式碼區塊,這個環境會同步且立即執行,也就是說,動作會以可預測的順序發生。這項做法基本上會讓協同程式以非協同程式的形式執行,因此適用於測試程式碼。

呼叫 suspend 函式時,請在測試類別中使用 runBlockingTest。在本系列課程的下一個程式碼研究室中,您將進一步瞭解 runBlockingTest 的運作方式,以及如何測試協同程式。

  1. 在類別上方新增 @ExperimentalCoroutinesApi。這表示您知道自己要在類別中使用實驗性協同程式 API (runBlockingTest)。否則會收到警示。
  2. 返回 DefaultTasksRepositoryTest,新增 runBlockingTest,將整個測試視為「程式碼區塊」

最終測試如下所示。

DefaultTasksRepositoryTest.kt

import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.hamcrest.core.IsEqual
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test


@ExperimentalCoroutinesApi
class DefaultTasksRepositoryTest {

    private val task1 = Task("Title1", "Description1")
    private val task2 = Task("Title2", "Description2")
    private val task3 = Task("Title3", "Description3")
    private val remoteTasks = listOf(task1, task2).sortedBy { it.id }
    private val localTasks = listOf(task3).sortedBy { it.id }
    private val newTasks = listOf(task3).sortedBy { it.id }

    private lateinit var tasksRemoteDataSource: FakeDataSource
    private lateinit var tasksLocalDataSource: FakeDataSource

    // Class under test
    private lateinit var tasksRepository: DefaultTasksRepository

    @Before
    fun createRepository() {
        tasksRemoteDataSource = FakeDataSource(remoteTasks.toMutableList())
        tasksLocalDataSource = FakeDataSource(localTasks.toMutableList())
        // Get a reference to the class under test
        tasksRepository = DefaultTasksRepository(
            // TODO Dispatchers.Unconfined should be replaced with Dispatchers.Main
            //  this requires understanding more about coroutines + testing
            //  so we will keep this as Unconfined for now.
            tasksRemoteDataSource, tasksLocalDataSource, Dispatchers.Unconfined
        )
    }

    @Test
    fun getTasks_requestsAllTasksFromRemoteDataSource() = runBlockingTest {
        // When tasks are requested from the tasks repository
        val tasks = tasksRepository.getTasks(true) as Success

        // Then tasks are loaded from the remote data source
        assertThat(tasks.data, IsEqual(remoteTasks))
    }

}
  1. 執行新的 getTasks_requestsAllTasksFromRemoteDataSource 測試,確認測試正常運作,且錯誤已消失!

您剛才已瞭解如何對存放區執行單元測試。在接下來的步驟中,您將再次使用依附元件注入,並建立另一個測試替身,這次是要說明如何為檢視模型編寫單元和整合測試。

單元測試應測試您感興趣的類別或方法。這稱為「獨立」測試,也就是清楚區隔「單元」,只測試該單元中的程式碼。

因此,TasksViewModelTest 應只測試 TasksViewModel 程式碼,不應測試資料庫、網路或存放區類別。因此,對於檢視畫面模型,您會建立虛擬存放區,並套用依附元件插入功能,以便在測試中使用,就像您剛才對存放區所做的一樣。

在這項工作中,您會將依附元件注入檢視區塊模型。

步驟 1:建立 TasksRepository 介面

如要開始使用建構函式依附元件注入,請先建立假類別和實際類別共用的通用介面。

實際情況如何?查看 TasksRemoteDataSourceTasksLocalDataSourceFakeDataSource,你會發現這些介面都相同:TasksDataSource。這樣一來,您就能在 DefaultTasksRepository 的建構函式中表示要接收 TasksDataSource

DefaultTasksRepository.kt

class DefaultTasksRepository(
   private val tasksRemoteDataSource: TasksDataSource,
   private val tasksLocalDataSource: TasksDataSource,
   private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {

這樣我們才能替換您的 FakeDataSource

接著,為 DefaultTasksRepository 建立介面,就像您為資料來源建立介面一樣。其中必須包含 DefaultTasksRepository 的所有公開方法 (公開 API 介面)。

  1. 開啟 DefaultTasksRepository,然後按一下滑鼠右鍵類別名稱。然後依序選取「Refactor」->「Extract」->「Interface」

  1. 選擇「擷取至個別檔案」

  1. 在「Extract Interface」視窗中,將介面名稱變更為 TasksRepository
  2. 在「Members to form interface」部分,勾選除了兩個伴隨成員和私有方法以外的所有成員。


  1. 按一下「重構」,新的 TasksRepository 介面應會顯示在 data/source 檔案包中。

DefaultTasksRepository 現已實作 TasksRepository

  1. 執行應用程式 (而非測試),確認一切正常運作。

步驟 2:建立 FakeTestRepository

現在您有了介面,可以建立 DefaultTaskRepository 測試替身。

  1. test 來源集中,於 data/source 中建立 Kotlin 檔案和類別 FakeTestRepository.kt,並從 TasksRepository 介面擴充。

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

系統會告知您需要實作介面方法。

  1. 將游標懸停在錯誤上,直到看到建議選單,然後按一下並選取「Implement members」
  1. 選取所有方法,然後按一下「確定」

步驟 3:實作 FakeTestRepository 方法

現在您有一個 FakeTestRepository 類別,其中包含「未實作」的方法。與您實作 FakeDataSource 的方式類似,FakeTestRepository 會由資料結構提供支援,而非處理本機和遠端資料來源之間複雜的中介程序。

請注意,FakeTestRepository 不需使用 FakeDataSources 或類似項目,只要根據輸入內容回傳逼真的虛假輸出內容即可。您將使用 LinkedHashMap 儲存工作清單,並使用 MutableLiveData 儲存可觀測的工作。

  1. FakeTestRepository 中,同時新增代表目前工作清單的 LinkedHashMap 變數,以及可觀察工作的 MutableLiveData

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()


    // Rest of class
}

實作下列方法:

  1. getTasks:這個方法應採用 tasksServiceData,並使用 tasksServiceData.values.toList() 將其轉換為清單,然後以 Success 結果的形式傳回。
  2. refreshTasks:將 observableTasks 的值更新為 getTasks() 傳回的值。
  3. observeTasks:使用 runBlocking 建立協同程式並執行 refreshTasks,然後傳回 observableTasks

以下是這些方法的程式碼。

FakeTestRepository.kt

class FakeTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private val observableTasks = MutableLiveData<Result<List<Task>>>()

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        return Result.Success(tasksServiceData.values.toList())
    }

    override suspend fun refreshTasks() {
        observableTasks.value = getTasks()
    }

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        runBlocking { refreshTasks() }
        return observableTasks
    }

    // Rest of class

}

步驟 4:新增測試方法來新增工作

測試時,最好在存放區中已有部分 Tasks。您可以多次呼叫 saveTask,但為了簡化這項作業,請新增專為測試設計的輔助方法,以便新增工作。

  1. 新增 addTasks 方法,該方法會接收工作 vararg,將每個工作新增至 HashMap,然後重新整理工作。

FakeTestRepository.kt

    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            tasksServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }

此時,您已擁有用於測試的虛擬存放區,並實作了幾個重要方法。接著,在測試中使用這個函式!

在這項工作中,您會在 ViewModel 內使用虛擬類別。使用建構函式依附元件插入功能,透過建構函式依附元件插入功能,在 TasksViewModel 的建構函式中新增 TasksRepository 變數,即可納入兩個資料來源。

由於您不會直接建構檢視模型,因此這個程序與檢視模型略有不同。例如:

class TasksFragment : Fragment() {

    private val viewModel by viewModels<TasksViewModel>()
    
    // Rest of class...

}


如上述程式碼所示,您使用的是 viewModel's 屬性委派,可建立檢視區塊模型。如要變更檢視畫面模型的建構方式,您需要新增並使用 ViewModelProvider.Factory。如果您不熟悉 ViewModelProvider.Factory,可以參閱這篇文章瞭解詳情。

步驟 1:在 TasksViewModel 中建立及使用 ViewModelFactory

首先,請更新與 Tasks 畫面相關的類別和測試。

  1. 開啟 TasksViewModel
  2. 變更 TasksViewModel 的建構函式,改為接收 TasksRepository,而不是在類別中建構。

TasksViewModel.kt

// REPLACE
class TasksViewModel(application: Application) : AndroidViewModel(application) {

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TasksViewModel( private val tasksRepository: TasksRepository ) : ViewModel() { 
    // Rest of class 
}

由於您變更了建構函式,現在必須使用 Factory 建構 TasksViewModel。將工廠類別放在與 TasksViewModel 相同檔案中,但也可以放在自己的檔案中。

  1. TasksViewModel 檔案底部的類別外,新增可接受一般 TasksRepositoryTasksViewModelFactory

TasksViewModel.kt

@Suppress("UNCHECKED_CAST")
class TasksViewModelFactory (
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>) =
        (TasksViewModel(tasksRepository) as T)
}


這是變更 ViewModel 建構方式的標準做法。現在您已擁有工廠,可以在建構檢視模型時使用。

  1. 更新 TasksFragment,即可使用工廠。

TasksFragment.kt

// REPLACE
private val viewModel by viewModels<TasksViewModel>()

// WITH

private val viewModel by viewModels<TasksViewModel> {
    TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. 執行應用程式程式碼,確認一切運作正常!

步驟 2:在 TasksViewModelTest 中使用 FakeTestRepository

現在,您可以在檢視模型測試中使用偽造的存放區,不必使用實際存放區。

  1. 開啟 TasksViewModelTest
  2. TasksViewModelTest 中新增 FakeTestRepository 屬性。

TaskViewModelTest.kt

@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {

    // Use a fake repository to be injected into the viewmodel
    private lateinit var tasksRepository: FakeTestRepository
    
    // Rest of class
}
  1. 更新 setupViewModel 方法,建立含有三項工作的 FakeTestRepository,然後使用這個存放區建構 tasksViewModel

TasksViewModelTest.kt

    @Before
    fun setupViewModel() {
        // We initialise the tasks to 3, with one active and two completed
        tasksRepository = FakeTestRepository()
        val task1 = Task("Title1", "Description1")
        val task2 = Task("Title2", "Description2", true)
        val task3 = Task("Title3", "Description3", true)
        tasksRepository.addTasks(task1, task2, task3)

        tasksViewModel = TasksViewModel(tasksRepository)
        
    }
  1. 由於您不再使用 AndroidX Test ApplicationProvider.getApplicationContext 程式碼,因此也可以移除 @RunWith(AndroidJUnit4::class) 註解。
  2. 執行測試,確保一切運作正常!

使用建構函式依附元件插入功能後,您現在已移除 DefaultTasksRepository 做為依附元件,並在測試中將其替換為 FakeTestRepository

步驟 3:同時更新 TaskDetail 片段和 ViewModel

TaskDetailFragmentTaskDetailViewModel 進行完全相同的變更。這樣一來,您之後編寫 TaskDetail 測試時,程式碼就會準備就緒。

  1. 開啟 TaskDetailViewModel
  2. 更新建構函式:

TaskDetailViewModel.kt

// REPLACE
class TaskDetailViewModel(application: Application) : AndroidViewModel(application) {

    private val tasksRepository = DefaultTasksRepository.getRepository(application)

    // Rest of class
}

// WITH

class TaskDetailViewModel(
    private val tasksRepository: TasksRepository
) : ViewModel() { // Rest of class }
  1. 在類別外的 TaskDetailViewModel 檔案底部,新增 TaskDetailViewModelFactory

TaskDetailViewModel.kt

@Suppress("UNCHECKED_CAST")
class TaskDetailViewModelFactory (
    private val tasksRepository: TasksRepository
) : ViewModelProvider.NewInstanceFactory() {
    override fun <T : ViewModel> create(modelClass: Class<T>) =
        (TaskDetailViewModel(tasksRepository) as T)
}
  1. 更新 TasksFragment,即可使用工廠。

TasksFragment.kt

// REPLACE
private val viewModel by viewModels<TaskDetailViewModel>()

// WITH

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}
  1. 執行程式碼,確認一切正常。

您現在可以在 TasksFragmentTasksDetailFragment 中使用 FakeTestRepository,而非實際存放區。

接著,您將編寫整合測試,測試片段和 ViewModel 互動。您會發現檢視區塊模型程式碼是否適當更新了 UI。如要執行這項操作,請使用

  • ServiceLocator 模式
  • Espresso 和 Mockito 程式庫

整合測試 會測試多個類別的互動情形,確保這些類別一起使用時能正常運作。這些測試可以在本機 (test 來源集) 執行,也可以做為檢測設備測試 (androidTest 來源集) 執行。

在您的情況下,您將取得每個片段,並為片段和檢視模型編寫整合測試,以測試片段的主要功能。

步驟 1:新增 Gradle 依附元件

  1. 新增下列 Gradle 依附元件。

app/build.gradle

    // Dependencies for Android instrumented unit tests
    androidTestImplementation "junit:junit:$junitVersion"
    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

    // Testing code should not be included in the main code.
    // Once https://issuetracker.google.com/128612536 is fixed this can be fixed.

    implementation "androidx.fragment:fragment-testing:$fragmentVersion"
    implementation "androidx.test:core:$androidXTestCoreVersion"

這些依附元件包括:

  • junit:junit:JUnit,這是撰寫基本測試陳述式時的必要項目。
  • androidx.test:core:核心 AndroidX 測試程式庫
  • kotlinx-coroutines-test:協同程式測試程式庫
  • androidx.fragment:fragment-testing:AndroidX 測試程式庫,用於在測試中建立片段並變更其狀態。

由於您會在 androidTest 來源集使用這些程式庫,請使用 androidTestImplementation 將這些程式庫新增為依附元件。

步驟 2:建立 TaskDetailFragmentTest 類別

TaskDetailFragment 會顯示單一工作的相關資訊。

您將先為 TaskDetailFragment 編寫片段測試,因為與其他片段相比,這個片段的功能相當基本。

  1. 開啟 taskdetail.TaskDetailFragment
  2. 產生 TaskDetailFragment 的測試,做法與先前相同。接受預設選項,並將其放在 androidTest 來源集 (而非 test 來源集)。

  1. 將下列註解新增至 TaskDetailFragmentTest 類別。

TaskDetailFragmentTest.kt

@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

}

這些註解的用途如下:

步驟 3:從測試啟動片段

在這項工作中,您將使用 AndroidX Testing 程式庫啟動 TaskDetailFragmentFragmentScenario 是 AndroidX Test 中的類別,可包裝片段,讓您直接控制片段的生命週期以進行測試。如要為片段編寫測試,請為要測試的片段 (TaskDetailFragment) 建立 FragmentScenario

  1. 將這項測試複製TaskDetailFragmentTest 中。

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi() {
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

上述程式碼:

這還不是完成的測試,因為尚未判斷任何內容。現在請執行測試,並觀察會發生什麼情況。

  1. 這是檢測設備測試,因此請確認模擬器或裝置可見
  2. 執行測試。

應該會發生下列情況:

  • 首先,由於這是設備測試,因此測試會在實體裝置 (如果已連線) 或模擬器上執行。
  • 系統應會啟動該片段。
  • 請注意,這個片段不會透過任何其他片段導覽,也不會與任何活動相關聯的選單,只有這個片段。

最後,請仔細查看,會發現片段顯示「沒有資料」,因為片段未成功載入工作資料。

測試需要載入 TaskDetailFragment (您已完成),並確認資料載入正確無誤。為什麼沒有資料?這是因為您建立了工作,但未儲存至存放區。

    @Test
    fun activeTaskDetails_DisplayedInUi() {
        // This DOES NOT save the task anywhere
        val activeTask = Task("Active Task", "AndroidX Rocks", false)

        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

您有這個 FakeTestRepository,但需要某種方式,才能為 fragment 將實際存放區替換為虛擬存放區。請在下一頁接受上述條款。

在這項工作中,您將使用 ServiceLocator 將假存放區提供給片段。這樣您就能編寫片段和檢視模型整合測試。

您無法像之前一樣,在需要為檢視區塊模型或存放區提供依附元件時,使用建構函式依附元件插入功能。建構函式依附元件植入需要建構類別。片段和活動是您不會建構的類別範例,通常也無法存取建構函式。

由於您不會建構片段,因此無法使用建構函式依附元件插入,將存放區測試替身 (FakeTestRepository) 換成片段。請改用服務定位器模式。服務定位器模式是依附元件插入的替代方案。這項作業需要建立名為「服務定位器」的單例類別,目的是為一般和測試程式碼提供依附元件。在一般應用程式程式碼 (main 來源集) 中,所有這些依附元件都是一般應用程式依附元件。在測試中,您會修改服務定位器,提供依附元件的測試雙重版本。

未使用服務定位器


使用服務定位器

針對本程式碼研究室應用程式,請執行下列操作:

  1. 建立可建構及儲存存放區的服務定位器類別。根據預設,這會建構「一般」存放區。
  2. 重構程式碼,以便在需要存放區時使用服務定位器。
  3. 在測試類別中,呼叫 Service Locator 的方法,將「一般」存放區換成測試替身。

步驟 1:建立 ServiceLocator

我們來建立 ServiceLocator 類別。由於主要應用程式碼會使用這個類別,因此它會與其餘應用程式碼一起存在於主要來源集中。

注意:ServiceLocator 是單例項,因此請使用Kotlin object 關鍵字

  1. 在主要來源集的頂層建立 ServiceLocator.kt 檔案。
  2. 定義名為 ServiceLocatorobject
  3. 建立 databaserepository 例項變數,並將兩者都設為 null
  4. 使用 @Volatile 註解存放區,因為多個執行緒可能會使用該存放區 (詳情請參閱這篇文章)。@Volatile

程式碼應如下所示。

object ServiceLocator {

    private var database: ToDoDatabase? = null
    @Volatile
    var tasksRepository: TasksRepository? = null

}

目前 ServiceLocator 只需要知道如何傳回 TasksRepository。系統會傳回現有的 DefaultTasksRepository,或視需要建立並傳回新的 DefaultTasksRepository

定義下列函式:

  1. provideTasksRepository:提供現有存放區或建立新存放區。這個方法應為 synchronizedthis 避免在多個執行緒執行的情況下,意外建立兩個存放區執行個體。
  2. createTasksRepository:用於建立新存放區的程式碼。將呼叫 createTaskLocalDataSource 並建立新的 TasksRemoteDataSource
  3. createTaskLocalDataSource:建立新本機資料來源的程式碼。系統將撥打 createDataBase
  4. createDataBase:用於建立新資料庫的程式碼。

完成的程式碼如下。

ServiceLocator.kt

object ServiceLocator {

    private var database: ToDoDatabase? = null
    @Volatile
    var tasksRepository: TasksRepository? = null

    fun provideTasksRepository(context: Context): TasksRepository {
        synchronized(this) {
            return tasksRepository ?: createTasksRepository(context)
        }
    }

    private fun createTasksRepository(context: Context): TasksRepository {
        val newRepo = DefaultTasksRepository(TasksRemoteDataSource, createTaskLocalDataSource(context))
        tasksRepository = newRepo
        return newRepo
    }

    private fun createTaskLocalDataSource(context: Context): TasksDataSource {
        val database = database ?: createDataBase(context)
        return TasksLocalDataSource(database.taskDao())
    }

    private fun createDataBase(context: Context): ToDoDatabase {
        val result = Room.databaseBuilder(
            context.applicationContext,
            ToDoDatabase::class.java, "Tasks.db"
        ).build()
        database = result
        return result
    }
}

步驟 2:在應用程式中使用 ServiceLocator

您將變更主要應用程式碼 (而非測試),以便在一個位置 (即 ServiceLocator) 建立存放區。

請務必只建立一個存放區類別的執行個體。為確保這一點,您會在 Application 類別中使用服務定位器。

  1. 在套件階層的頂層,開啟 TodoApplication 並為存放區建立 val,然後指派使用 ServiceLocator.provideTaskRepository 取得的存放區。

TodoApplication.kt

class TodoApplication : Application() {

    val taskRepository: TasksRepository
        get() = ServiceLocator.provideTasksRepository(this)

    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) Timber.plant(DebugTree())
    }
}

您已在應用程式中建立存放區,現在可以移除 DefaultTasksRepository 中的舊 getRepository 方法。

  1. 開啟 DefaultTasksRepository 並刪除伴生物件。

DefaultTasksRepository.kt

// DELETE THIS COMPANION OBJECT
companion object {
    @Volatile
    private var INSTANCE: DefaultTasksRepository? = null

    fun getRepository(app: Application): DefaultTasksRepository {
        return INSTANCE ?: synchronized(this) {
            val database = Room.databaseBuilder(app,
                ToDoDatabase::class.java, "Tasks.db")
                .build()
            DefaultTasksRepository(TasksRemoteDataSource, TasksLocalDataSource(database.taskDao())).also {
                INSTANCE = it
            }
        }
    }
}

現在,凡是使用 getRepository 的地方,請改用應用程式的 taskRepository。這樣可確保您取得的是 ServiceLocator 提供的存放區,而不是直接建立存放區。

  1. 開啟 TaskDetailFragement,然後在類別頂端找到對 getRepository 的呼叫。
  2. 將這個呼叫替換為從 TodoApplication 取得存放區的呼叫。

TaskDetailFragment.kt

// REPLACE this code
private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}

// WITH this code

private val viewModel by viewModels<TaskDetailViewModel> {
    TaskDetailViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
}
  1. TasksFragment 執行相同操作。

TasksFragment.kt

// REPLACE this code
    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
    }


// WITH this code

    private val viewModel by viewModels<TasksViewModel> {
        TasksViewModelFactory((requireContext().applicationContext as TodoApplication).taskRepository)
    }
  1. 針對 StatisticsViewModelAddEditTaskViewModel,請更新取得存放區的程式碼,改為使用 TodoApplication 中的存放區。

TasksFragment.kt

// REPLACE this code
    private val tasksRepository = DefaultTasksRepository.getRepository(application)



// WITH this code

    private val tasksRepository = (application as TodoApplication).taskRepository

  1. 執行應用程式 (而非測試)!

由於您只重構程式碼,應用程式應該能照常執行。

步驟 3:建立 FakeAndroidTestRepository

測試來源集中已有 FakeTestRepository。根據預設,您無法在 testandroidTest 來源集之間共用測試類別。因此,您需要在 androidTest 來源集中複製 FakeTestRepository 類別,並將其命名為 FakeAndroidTestRepository

  1. 在來源集上按一下滑鼠右鍵 androidTest,然後建立資料套件。再次按一下滑鼠右鍵,然後建立「來源」 套件。
  2. 在這個來源套件中,建立名為 FakeAndroidTestRepository.kt 的新類別。
  3. 將下列程式碼複製到該類別。

FakeAndroidTestRepository.kt

import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import com.example.android.architecture.blueprints.todoapp.data.Result
import com.example.android.architecture.blueprints.todoapp.data.Result.Error
import com.example.android.architecture.blueprints.todoapp.data.Result.Success
import com.example.android.architecture.blueprints.todoapp.data.Task
import kotlinx.coroutines.runBlocking
import java.util.LinkedHashMap



class FakeAndroidTestRepository : TasksRepository {

    var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()

    private var shouldReturnError = false

    private val observableTasks = MutableLiveData<Result<List<Task>>>()

    fun setReturnError(value: Boolean) {
        shouldReturnError = value
    }

    override suspend fun refreshTasks() {
        observableTasks.value = getTasks()
    }

    override suspend fun refreshTask(taskId: String) {
        refreshTasks()
    }

    override fun observeTasks(): LiveData<Result<List<Task>>> {
        runBlocking { refreshTasks() }
        return observableTasks
    }

    override fun observeTask(taskId: String): LiveData<Result<Task>> {
        runBlocking { refreshTasks() }
        return observableTasks.map { tasks ->
            when (tasks) {
                is Result.Loading -> Result.Loading
                is Error -> Error(tasks.exception)
                is Success -> {
                    val task = tasks.data.firstOrNull() { it.id == taskId }
                        ?: return@map Error(Exception("Not found"))
                    Success(task)
                }
            }
        }
    }

    override suspend fun getTask(taskId: String, forceUpdate: Boolean): Result<Task> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        tasksServiceData[taskId]?.let {
            return Success(it)
        }
        return Error(Exception("Could not find task"))
    }

    override suspend fun getTasks(forceUpdate: Boolean): Result<List<Task>> {
        if (shouldReturnError) {
            return Error(Exception("Test exception"))
        }
        return Success(tasksServiceData.values.toList())
    }

    override suspend fun saveTask(task: Task) {
        tasksServiceData[task.id] = task
    }

    override suspend fun completeTask(task: Task) {
        val completedTask = Task(task.title, task.description, true, task.id)
        tasksServiceData[task.id] = completedTask
    }

    override suspend fun completeTask(taskId: String) {
        // Not required for the remote data source.
        throw NotImplementedError()
    }

    override suspend fun activateTask(task: Task) {
        val activeTask = Task(task.title, task.description, false, task.id)
        tasksServiceData[task.id] = activeTask
    }

    override suspend fun activateTask(taskId: String) {
        throw NotImplementedError()
    }

    override suspend fun clearCompletedTasks() {
        tasksServiceData = tasksServiceData.filterValues {
            !it.isCompleted
        } as LinkedHashMap<String, Task>
    }

    override suspend fun deleteTask(taskId: String) {
        tasksServiceData.remove(taskId)
        refreshTasks()
    }

    override suspend fun deleteAllTasks() {
        tasksServiceData.clear()
        refreshTasks()
    }

   
    fun addTasks(vararg tasks: Task) {
        for (task in tasks) {
            tasksServiceData[task.id] = task
        }
        runBlocking { refreshTasks() }
    }
}

步驟 4:為測試準備 ServiceLocator

好了,現在要使用 ServiceLocator,在測試時換入測試替身。如要這麼做,您需要在 ServiceLocator 程式碼中加入一些程式碼。

  1. 開啟 ServiceLocator.kt
  2. tasksRepository 的設定程式標示為 @VisibleForTesting。這項註解可表明設定器公開的原因是為了測試。

ServiceLocator.kt

    @Volatile
    var tasksRepository: TasksRepository? = null
        @VisibleForTesting set

無論是單獨執行測試,還是與其他測試一起執行,測試的運作方式都應完全相同。也就是說,測試之間不應有任何相互依賴的行為 (因此請避免在測試之間共用物件)。

由於 ServiceLocator 是單例模式,因此可能會在測試之間意外共用。為避免這種情況,請建立方法,在測試之間正確重設 ServiceLocator 狀態。

  1. 新增名為 lock 的例項變數,並將值設為 Any

ServiceLocator.kt

private val lock = Any()
  1. 新增名為 resetRepository 的測試專用方法,該方法會清除資料庫,並將存放區和資料庫都設為空值。

ServiceLocator.kt

    @VisibleForTesting
    fun resetRepository() {
        synchronized(lock) {
            runBlocking {
                TasksRemoteDataSource.deleteAllTasks()
            }
            // Clear all data to avoid test pollution.
            database?.apply {
                clearAllTables()
                close()
            }
            database = null
            tasksRepository = null
        }
    }

步驟 5:使用 ServiceLocator

在這個步驟中,您會使用 ServiceLocator

  1. 開啟 TaskDetailFragmentTest
  2. 宣告 lateinit TasksRepository 變數。
  3. 新增設定和清除方法,在每次測試前設定 FakeAndroidTestRepository,並在每次測試後清除。

TaskDetailFragmentTest.kt

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }
  1. activeTaskDetails_DisplayedInUi() 的函式主體包裝在 runBlockingTest 中。
  2. 啟動片段前,請先將 activeTask 儲存在存放區中。
repository.saveTask(activeTask)

最終測試如下所示。

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi()  = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }
  1. 為整個類別加上 @ExperimentalCoroutinesApi 註解。

完成後,程式碼會如下所示。

TaskDetailFragmentTest.kt

@MediumTest
@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }


    @Test
    fun activeTaskDetails_DisplayedInUi()  = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

    }

}
  1. 執行 activeTaskDetails_DisplayedInUi() 測試。

與先前一樣,您應該會看到片段,但這次因為您已正確設定存放區,因此片段會顯示工作資訊。


在本步驟中,您將使用 Espresso UI 測試程式庫完成第一個整合測試。您已建構程式碼,因此可以新增 UI 的斷言測試。如要執行這項操作,請使用 Espresso 測試程式庫

Espresso 可協助你:

  • 與檢視畫面互動,例如點選按鈕、滑動滑桿或向下捲動畫面。
  • 確認特定檢視區塊位於畫面上或處於特定狀態 (例如包含特定文字,或已勾選核取方塊等)。

步驟 1:注意 Gradle 依附元件

Android 專案預設會納入主要 Espresso 依附元件,因此您應該已經有這個依附元件。

app/build.gradle

dependencies {

  // ALREADY in your code
    androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
   
 // Other dependencies
}

androidx.test.espresso:espresso-core:建立新的 Android 專案時,系統預設會加入這個核心 Espresso 依附元件。其中包含大多數檢視區塊的基本測試程式碼,以及這些檢視區塊上的動作。

步驟 2:關閉動畫

Espresso 測試是在實際裝置上執行,因此本質上是檢測設備測試。其中一個問題是動畫:如果動畫延遲,而您嘗試測試檢視區塊是否在畫面上,但動畫仍在執行,Espresso 可能會意外導致測試失敗。這可能會導致 Espresso 測試不穩定。

進行 Espresso UI 測試時,建議關閉動畫 (測試也會更快執行!):

  1. 在測試裝置上,依序前往「設定」>「開發人員選項」
  2. 停用「視窗動畫比例」、「轉場動畫比例」和「動畫影片長度比例」這三項設定。

步驟 3:查看 Espresso 測試

編寫 Espresso 測試前,請先看看一些 Espresso 程式碼。

onView(withId(R.id.task_detail_complete_checkbox)).perform(click()).check(matches(isChecked()))

這項陳述式會找出 ID 為 task_detail_complete_checkbox 的核取方塊檢視區塊、點選該核取方塊,然後確認核取方塊已勾選。

大多數 Espresso 陳述式都由四個部分組成:

1. 靜態 Espresso 方法

onView

onView 是靜態 Espresso 方法的範例,可啟動 Espresso 陳述式。onView 是最常見的選項之一,但也有其他選項,例如 onData

2. ViewMatcher

withId(R.id.task_detail_title_text)

withIdViewMatcher 的範例,會依 ID 取得檢視區塊。您可以在說明文件中查詢其他檢視區塊比對器。

3. ViewAction

perform(click())

perform 方法,會採用 ViewActionViewAction 是指可對檢視區塊執行的動作,例如點選檢視區塊。

4. ViewAssertion

check(matches(isChecked()))

check,這需要 ViewAssertionViewAssertions 檢查或判斷檢視區塊的相關事項。您最常使用的 ViewAssertionmatches 判斷。如要完成判斷,請使用另一個 ViewMatcher,在本例中為 isChecked

請注意,您不一定會在 Espresso 陳述式中同時呼叫 performcheck。您可以使用 check 建立僅進行判斷的陳述式,或使用 perform 僅執行 ViewAction

  1. 開啟 TaskDetailFragmentTest.kt
  2. 更新 activeTaskDetails_DisplayedInUi 測試。

TaskDetailFragmentTest.kt

    @Test
    fun activeTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add active (incomplete) task to the DB
        val activeTask = Task("Active Task", "AndroidX Rocks", false)
        repository.saveTask(activeTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
        onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_title_text)).check(matches(withText("Active Task")))
        onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
        // and make sure the "active" checkbox is shown unchecked
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(not(isChecked())))
    }

如有需要,請使用下列匯入陳述式:

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isChecked
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.hamcrest.core.IsNot.not
  1. // THEN 註解後的所有內容都會使用 Espresso。檢查測試結構和 withId 的使用情形,並確認詳細資料頁面的外觀。
  2. 執行測試,並確認通過測試。

步驟 4:(選用) 自行編寫 Espresso 測試

現在請自行撰寫測試。

  1. 建立名為 completedTaskDetails_DisplayedInUi 的新測試,並複製這個架構程式碼。

TaskDetailFragmentTest.kt

    @Test
    fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add completed task to the DB
       
        // WHEN - Details fragment launched to display task
        
        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
}
  1. 查看先前的測試,完成這項測試。
  2. 執行並確認測試通過。

完成的 completedTaskDetails_DisplayedInUi 應如下列程式碼所示。

TaskDetailFragmentTest.kt

    @Test
    fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
        // GIVEN - Add completed task to the DB
        val completedTask = Task("Completed Task", "AndroidX Rocks", true)
        repository.saveTask(completedTask)

        // WHEN - Details fragment launched to display task
        val bundle = TaskDetailFragmentArgs(completedTask.id).toBundle()
        launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

        // THEN - Task details are displayed on the screen
        // make sure that the title/description are both shown and correct
        onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_title_text)).check(matches(withText("Completed Task")))
        onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
        // and make sure the "active" checkbox is shown unchecked
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
        onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isChecked()))
    }

在最後一個步驟中,您將瞭解如何使用名為模擬的另一種測試替身,以及 Mockito 測試程式庫,測試 Navigation 元件

在本程式碼研究室中,您使用了名為「假物件」的測試替身。虛擬物件是眾多測試替身之一。您應該使用哪個測試替身來測試 Navigation 元件

請思考導覽的發生方式。假設您按下 TasksFragment 中的其中一項工作,即可前往工作詳細資料畫面。

以下是 TasksFragment 中的程式碼,按下時會導覽至工作詳細資料畫面。

TasksFragment.kt

private fun openTaskDetails(taskId: String) {
    val action = TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment(taskId)
    findNavController().navigate(action)
}


導覽作業是因呼叫 navigate 方法而發生。如果您需要撰寫 assert 陳述式,就無法直接測試是否已導覽至 TaskDetailFragment。導覽是複雜的動作,除了初始化 TaskDetailFragment 之外,不會產生明確的輸出內容或狀態變化。

您可以確認 navigate 方法是否使用正確的動作參數呼叫。這正是模擬測試替身的作用,也就是檢查是否呼叫了特定方法。

Mockito 是用於製作測試替身的架構。雖然 API 和名稱中使用了「模擬」一詞,但這「並非」僅用於製作模擬。此外,它還能製作存根和間諜。

您將使用 Mockito 建立模擬 NavigationController,確認導覽方法是否正確呼叫。

步驟 1:新增 Gradle 依附元件

  1. 新增 Gradle 依附元件。

app/build.gradle

    // Dependencies for Android instrumented unit tests
    androidTestImplementation "org.mockito:mockito-core:$mockitoVersion"

    androidTestImplementation "com.linkedin.dexmaker:dexmaker-mockito:$dexMakerVersion" 

    androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion"



  • org.mockito:mockito-core:這是 Mockito 依附元件。
  • dexmaker-mockito:在 Android 專案中使用 Mockito 時,必須使用這個程式庫。Mockito 需要在執行階段產生類別。在 Android 上,這項作業是使用 DEX 位元碼完成,因此這個程式庫可讓 Mockito 在 Android 上的執行階段產生物件。
  • androidx.test.espresso:espresso-contrib:這個程式庫是由外部貢獻內容組成 (因此得名),內含 DatePickerRecyclerView 等進階檢視區塊的測試程式碼。其中也包含無障礙檢查和稍後會介紹的 CountingIdlingResource 類別。

步驟 2:建立 TasksFragmentTest

  1. 開啟 TasksFragment
  2. TasksFragment 類別名稱上按一下滑鼠右鍵,然後依序選取「產生」和「測試」。在 androidTest 來源集中建立測試。
  3. 將這組代碼複製到 TasksFragmentTest

TasksFragmentTest.kt

@RunWith(AndroidJUnit4::class)
@MediumTest
@ExperimentalCoroutinesApi
class TasksFragmentTest {

    private lateinit var repository: TasksRepository

    @Before
    fun initRepository() {
        repository = FakeAndroidTestRepository()
        ServiceLocator.tasksRepository = repository
    }

    @After
    fun cleanupDb() = runBlockingTest {
        ServiceLocator.resetRepository()
    }

}

這段程式碼與您編寫的 TaskDetailFragmentTest 程式碼類似。這個類別會設定及拆除 FakeAndroidTestRepository。新增導覽測試,測試點選工作清單中的工作時,是否會前往正確的 TaskDetailFragment

  1. 新增測試 clickTask_navigateToDetailFragmentOne

TasksFragmentTest.kt

    @Test
    fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
        repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
        repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))

        // GIVEN - On the home screen
        val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
        
    }
  1. 使用 Mockito 的 mock 函式建立模擬。

TasksFragmentTest.kt

 val navController = mock(NavController::class.java)

如要在 Mockito 中模擬,請傳入要模擬的類別。

接著,您必須將 NavController 與片段建立關聯。onFragment 可讓您呼叫片段本身的方法。

  1. 將新模擬物件設為片段的 NavController
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. 在具有「TITLE1」文字的 RecyclerView 中,新增點選項目的程式碼。
// WHEN - Click on the first list item
        onView(withId(R.id.tasks_list))
            .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
                hasDescendant(withText("TITLE1")), click()))

RecyclerViewActionsespresso-contrib 程式庫的一部分,可讓您對 RecyclerView 執行 Espresso 動作

  1. 確認 navigate 已呼叫,且引數正確無誤。
// THEN - Verify that we navigate to the first detail screen
verify(navController).navigate(
    TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")

Mockito 的 verify 方法會將這個項目設為模擬項目,您可以確認模擬的 navController 是否呼叫了特定方法 (navigate) 和參數 (ID 為「id1」的 actionTasksFragmentToTaskDetailFragment)。

完整的測試如下所示:

@Test
fun clickTask_navigateToDetailFragmentOne() = runBlockingTest {
    repository.saveTask(Task("TITLE1", "DESCRIPTION1", false, "id1"))
    repository.saveTask(Task("TITLE2", "DESCRIPTION2", true, "id2"))

    // GIVEN - On the home screen
    val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
    
                val navController = mock(NavController::class.java)
    scenario.onFragment {
        Navigation.setViewNavController(it.view!!, navController)
    }

    // WHEN - Click on the first list item
    onView(withId(R.id.tasks_list))
        .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
            hasDescendant(withText("TITLE1")), click()))


    // THEN - Verify that we navigate to the first detail screen
    verify(navController).navigate(
        TasksFragmentDirections.actionTasksFragmentToTaskDetailFragment( "id1")
    )
}
  1. 執行測試!

總而言之,如要測試導覽功能,您可以:

  1. 使用 Mockito 建立 NavController 模擬。
  2. 將模擬的 NavController 附加至片段。
  3. 確認系統已使用正確的動作和參數呼叫導覽。

步驟 3:選用,撰寫 clickAddTaskButton_navigateToAddEditFragment

如要確認自己是否能編寫導覽測試,請嘗試完成這項工作。

  1. 編寫測試 clickAddTaskButton_navigateToAddEditFragment,確認點選 + 懸浮動作按鈕後,系統會導覽至 AddEditTaskFragment

答案如下。

TasksFragmentTest.kt

    @Test
    fun clickAddTaskButton_navigateToAddEditFragment() {
        // GIVEN - On the home screen
        val scenario = launchFragmentInContainer<TasksFragment>(Bundle(), R.style.AppTheme)
        val navController = mock(NavController::class.java)
        scenario.onFragment {
            Navigation.setViewNavController(it.view!!, navController)
        }

        // WHEN - Click on the "+" button
        onView(withId(R.id.add_task_fab)).perform(click())

        // THEN - Verify that we navigate to the add screen
        verify(navController).navigate(
            TasksFragmentDirections.actionTasksFragmentToAddEditTaskFragment(
                null, getApplicationContext<Context>().getString(R.string.add_task)
            )
        )
    }

按一下這裡,即可查看您開始時的程式碼與最終程式碼之間的差異。

完成程式碼研究室後,如要下載當中用到的程式碼,可以使用以下 Git 指令:

$ git clone https://github.com/googlecodelabs/android-testing.git
$ cd android-testing
$ git checkout end_codelab_2


您也可以將存放區下載為 ZIP 檔案、將其解壓縮,並在 Android Studio 中開啟。

下載 ZIP 檔

本程式碼研究室說明如何設定手動依附元件插入、服務定位器,以及如何在 Android Kotlin 應用程式中使用虛擬和模擬物件。特別是:

  • 您要測試的內容和測試策略,決定了要為應用程式實作的測試類型。單元測試著重於特定功能,且速度很快。整合測試可驗證程式各部分之間的互動。端對端測試會驗證功能、準確度最高、通常會經過儀器處理,且可能需要較長時間才能執行。
  • 應用程式的架構會影響測試難度。
  • TDD 或測試驅動開發是一種策略,您會先編寫測試,然後建立通過測試的功能。
  • 如要隔離應用程式的特定部分進行測試,可以使用測試替身。測試替身是專為測試而設計的類別版本。舉例來說,您可以模擬從資料庫或網際網路取得資料。
  • 使用依附元件插入功能,將實際類別替換為測試類別,例如存放區或網路層。
  • 使用檢測設備測試 (androidTest) 啟動 UI 元件。
  • 如果無法使用建構函式依附元件插入 (例如啟動片段),通常可以使用服務定位器。服務定位器模式是依附元件插入的替代方案。這項作業需要建立名為「服務定位器」的單例類別,目的是為一般和測試程式碼提供依附元件。

Udacity 課程:

Android 開發人員說明文件:

影片:

其他:

如要查看本課程其他程式碼研究室的連結,請參閱 Android Kotlin 進階功能程式碼研究室登陸頁面。