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

這個程式碼研究室是「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 必須依附於 LocalTaskDataSource 和 RemoteTaskDataSource 等其他類別才能運作。換句話說,LocalTaskDataSource 和 RemoteTaskDataSource 是 DefaultTaskRepository 的依附元件。

因此,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 的類別,這個類別會是 LocalDataSource 和 RemoteDataSource 的測試替身。

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

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

步驟 2:實作 TasksDataSource 介面

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

  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 建構函式,建立名為 tasks 的 var,該函式是 MutableList<Task>?,預設值為空白的可變動清單。
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }


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

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

  1. 寫入 getTasks:如果 tasks 不是 null,請傳回 Success 結果。如果 tasks 是 null,就會傳回 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,但不清楚如何在測試中使用。這項作業需要取代 TasksRemoteDataSource 和 TasksLocalDataSource,但僅限於測試。TasksRemoteDataSource 和 TasksLocalDataSource 都是 DefaultTasksRepository 的依附元件,也就是說 DefaultTasksRepositories 需要或「依附」於這些類別才能執行。

目前,依附元件是在 DefaultTasksRepository 的 init 方法中建構。

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 內建立及指派 taskLocalDataSource 和 tasksRemoteDataSource,所以基本上是硬式編碼。無法換入測試替身。

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

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

不插入

注入

步驟 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. 使用 remoteTasks 和 localTasks 清單,例項化虛擬資料來源。
  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

由於 getTasks 是 suspend 函式,因此需要啟動協同程式來呼叫,協同程式錯誤是預期行為。為此,您需要協同程式範圍。如要解決這項錯誤,您需要新增一些 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 介面

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

實際情況如何?查看 TasksRemoteDataSource、TasksLocalDataSource 和 FakeDataSource,你會發現這些介面都相同: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 檔案底部的類別外,新增可接受一般 TasksRepository 的 TasksViewModelFactory。

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

對 TaskDetailFragment 和 TaskDetailViewModel 進行完全相同的變更。這樣一來,您之後編寫 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. 執行程式碼,確認一切正常。

您現在可以在 TasksFragment 和 TasksDetailFragment 中使用 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 程式庫啟動 TaskDetailFragment。FragmentScenario 是 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. 定義名為 ServiceLocator 的 object。
  3. 建立 database 和 repository 例項變數,並將兩者都設為 null。
  4. 使用 @Volatile 註解存放區,因為多個執行緒可能會使用該存放區 (詳情請參閱這篇文章)。@Volatile

程式碼應如下所示。

object ServiceLocator {

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

}

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

定義下列函式:

  1. provideTasksRepository:提供現有存放區或建立新存放區。這個方法應為 synchronized,this 避免在多個執行緒執行的情況下,意外建立兩個存放區執行個體。
  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. 針對 StatisticsViewModel 和 AddEditTaskViewModel,請更新取得存放區的程式碼,改為使用 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。根據預設,您無法在 test 和 androidTest 來源集之間共用測試類別。因此,您需要在 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)

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

3. ViewAction

perform(click())

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

4. ViewAssertion

check(matches(isChecked()))

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

請注意,您不一定會在 Espresso 陳述式中同時呼叫 perform 和 check。您可以使用 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:這個程式庫是由外部貢獻內容組成 (因此得名),內含 DatePicker 和 RecyclerView 等進階檢視區塊的測試程式碼。其中也包含無障礙檢查和稍後會介紹的 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()))

RecyclerViewActions 是 espresso-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 進階功能程式碼研究室登陸頁面。