測試雙重及依附功能測試簡介

本程式碼研究室是 Kotlin 進階課程的一部分。只要您按部就班完成程式碼研究室,就能發揮本課程的最大效益。不過,您不一定要這麼做。所有課程程式碼研究室清單均列於進階 Android 版的 Kotlin 程式碼研究室到達網頁中。

引言

第二項測試程式碼研究室的重點在於測試雙重功能:在 Android 上使用測試方法,以及如何使用依附元件植入、服務搜尋器模式和程式庫導入這些程式碼。這樣就能瞭解:

  • 存放區單元測試
  • 片段和檢視模型整合測試
  • 片段瀏覽測試

須知事項

您應該很熟悉:

課程內容

  • 如何規劃測試策略
  • 如何建立及使用僅供測試的雙人 (即假貨和假貨)
  • 如何在 Android 上使用手動依附功能完成單元測試和整合測試
  • 如何套用服務搜尋器模式
  • 如何測試存放區、片段、查看模型和導覽元件

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

執行步驟

  • 使用測試重複和依附功能插入,為存放區寫入單元測試。
  • 使用測試雙層和依附式植入方式寫入檢視模型的單元測試。
  • 使用 Espresso UI 測試架構,撰寫片段及其檢視模型的整合測試。
  • 使用 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:探索範例應用程式的程式碼

TO-DO 應用程式以熱門的架構藍圖測試和架構樣本為基礎 (使用範例的被動架構版本)。此應用程式採用應用程式架構指南中的架構。這個模型使用 ViewModel 搭配片段、存放區和聊天室。如果您熟悉下列任一範例,則這個應用程式的架構十分類似:

更重要的是,您需瞭解應用程式的一般架構,而不是任何圖層的邏輯理解。

以下是所找到套件的摘要:

套件:com.example.android.architecture.blueprints.todoapp

.addedittask

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

.data

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

.statistics

統計資料畫面:統計資料畫面的使用者介面圖層程式碼。

.taskdetail

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

.tasks

工作畫面:用於列出所有工作清單的 UI 圖層程式碼。

.util

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

資料層 (.data)

這個應用程式包含 remote 套件中的模擬網路層,以及 local 套件中的資料庫層。為求簡單來說,在這個專案中,網路層僅模擬 HashMap 的延遲時間,並且提供延遲和真實的網路要求。

DefaultTasksRepository 會在網路層和資料庫層之間進行協調或調解,並且會將資料傳回 UI 層。

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

每個 UI 圖層套件都包含片段和檢視模型,以及使用者介面要求的其他類別 (例如工作清單的轉接程式)。TaskActivity 是包含所有片段的活動。

導覽

應用程式的導覽功能則由導覽元件控管。這是在 nav_graph.xml 檔案中定義。使用 Event 類別在檢視模型中觸發導覽;檢視模型也會決定要傳送哪些引數。這些片段會觀察 Event,並在螢幕之間進行實際瀏覽。

在本程式碼研究室中,您將瞭解如何使用測試雙層和依附式宣告來測試存放區、查看模型和片段。深入探討這些概念之前,請務必瞭解要解釋這些測試的因素和方式。

本節將說明在 Android 上進行一般測試時的最佳做法。

測試金字塔

設計測試策略時有三個相關測試面向:

  • 範圍:測試中有多少程式碼?測試可以使用單一方法、整個應用程式,或是兩者之間的某處執行。
  • 速度:測試的速度有多快?測試速度可能從毫秒到數分鐘不等。
  • 擬真度:測驗中,「真實世界」的測試程度為何?例如,如果測試中的某些程式碼需要提出網路請求、測試程式碼是否確實發出了聯播網請求,還是結果是假的?如果測試是透過網路與裝置連線,就表示該訊號的精確度更高。但缺點是測試的執行時間可能更久、網路發生問題時可能會發生錯誤,或是成本高昂。

這兩項元素之間存在權衡取捨。例如,速度和擬真度是所謂的取捨,執行速度越快,測試的擬真度越低,反之亦然。自動測試的常見方法之一可分為三類:

  • 單元測試:這是對單一類別執行的高度測試,通常是該類別中的單一方法。如果單元測試失敗,您可以在程式碼中清楚瞭解問題所在。由於現實世界的擬真度很低,因此應用程式執行的作業不只一種,而且執行的方法不只一種。這些程式碼夠快,可在您每次變更程式碼時執行。這類測試通常是執行本機測試 (位於 test 來源集中)。範例:在檢視模型和存放區中測試單一方法。
  • 整合測試:這些測試可測試多種類別的互動情形,確保兩者在搭配使用時都能正常運作。建立整合測試的一種方法,是讓他們測試單一功能,例如儲存工作的能力。相較於單元測試,這類程式碼的測試範圍更廣,但是仍處於最佳化的執行速度,而非完全擬真度。視本機的情況而定,這些資料可能會在本機執行或做為檢測測試。範例:測試單一片段的所有功能,並查看模型組合。
  • 端對端測試 (E2e):測試不同的功能組合。這些程式會測試應用程式的大部分內容、仔細模擬實際用量,因此速度通常較慢。它們的擬真度最高,並告訴您您的應用程式實際上可順利運作。這些測試都是大規模的測試 (在 androidTest 來源集中)
    範例:啟動整個應用程式並同時測試幾項功能。

這些測試的建議比例通常會以金字塔表示,而絕大多數的測試都是單位測試。

架構與測試

測試各個不同層級的測試金字塔的能力,等於與應用程式架構相互連結。例如,架構錯誤的應用程式可能會將所有邏輯放在單一方法中。您或許可以撰寫端對端測試,因為這類測試通常會測試應用程式的大部分內容,但撰寫測試或整合測試又怎麼了?由於所有的程式碼都集中在同一處,所以您很難測試單一單元或功能的相關程式碼。

更理想的做法是將應用程式邏輯分解為多個方法和類別,允許每個項目分別進行測試。架構可讓您劃分並整理程式碼,進而簡化單元和整合測試。您要測試的 TO-DO 應用程式會採用下列特定架構:



在本課程中,我們會說明如何測試上述架構的某些部分,以適當方式隔離:

  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 環境。
  • 部分程式碼 (例如網路程式碼) 可能需要長時間執行,甚至偶爾會失敗,因而產生長久的異常測試。
  • 測試之後,他們可能無法診斷出哪個代碼出錯,導致測試失敗。測試可能會開始測試非存放區程式碼,例如您的「存放區」,因為部分相依程式碼 (例如資料庫程式碼) 發生問題,導致單元測試失敗。

重複測試

解決方法就是在重新測試存放區時,不要使用實際網路或資料庫程式碼,而改用兩倍的測試。「測試重複」是指專為測試設計的課程版本。其用途是取代測試中的類別實際版本。就像是特技雙人在遊戲中特技表演的特技,更取代真正的演員,以取代危險行為。

以下是兩種測試雙種類型:

具有「運作中」類別的測試雙層,能夠執行這個類別,但它的實作方式有利於測試,但不適合實際執行。

模擬

用來測試一種方法的測試重覆版本。然後,它會根據測試方法是否正確呼叫來通過或未通過測試。

研究

不含邏輯的測試雙重測試,只會傳回您編寫程式所傳回的內容。StubTaskRepository 可進行程式設計,傳回 getTasks 的特定工作組合。

虛擬

測試通過但不能使用,例如您只需要提供 參數。如果您有 NoOpTaskRepository,它在任何方法中都會使用包含不包含程式碼的 TaskRepository

間諜

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

如要進一步瞭解雙重測試,請參閱測試馬桶:認識您的雙重測試

最常在 Android 上使用的測試重複功能是 Mocks

在這項工作中,您會建立一個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. 使用快速修正選單,然後選取 [實作成員]


  1. 選取所有方法,然後按下 [確定]

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

FakeDataSource 是特定類型的測試測試類型,稱為「假」。假的測試是指具備「處理中」類別的雙重測試,但類別的實作方式很適合用來進行測試,但並不適合生產環境。「處理中」是指實作;該類別會根據輸入內容產生實際的輸出。

舉例來說,假的資料來源將無法連線至網路,或是將任何資料儲存到資料庫,只會使用記憶體內清單。這樣做會「預期會如預期」,因為取得或儲存工作的方法會傳回預期的結果,但是您不能在實作時實作此實作,因為該功能不會儲存到伺服器或資料庫。

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 方法測試。呼叫 getTasks 時呼叫 true (代表其應從遠端資料來源重新載入),它會從遠端資料來源 (而非本機資料來源) 傳回資料。

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:新增 RunblockTest

由於 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:建立 Tasks 存放區介面

使用建構函式依附元件插入的第一步是建立假介面和真實類別共用的通用介面。

這項工具的實際運作方式為何?查看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,然後在課程名稱上按一下滑鼠右鍵。然後選取 [Re 重構 -> Extract -> Interface]

  1. 選擇 [擷取以分隔檔案]。

  1. 在「擷取介面」視窗中,將介面名稱變更為 TasksRepository
  2. 在「成員介面介面」部分中,勾選「成員」和「私人」以外的所有成員。


  1. 按一下 [重構]。新的 TasksRepository 介面應會顯示在 data/source 套件中。

DefaultTasksRepository 現已導入 TasksRepository

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

步驟 2:建立 FakeTestRepository

現在您已擁有介面,因此您可以建立 DefaultTaskRepository 測試重覆。

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

FakeTestRepository.kt

class FakeTestRepository : TasksRepository  {
}

系統會告訴您必須實作介面方法。

  1. 將滑鼠懸停在錯誤上,直到系統顯示建議選單為止,接著點選 [導入成員]
  1. 選取所有方法,然後按下 [確定]

步驟 3:實作 FakeTestRepository 方法

現在您擁有含有「未實作」方法的 FakeTestRepository 類別。就如同實作 FakeDataSource 的方式,FakeTestRepository 將由資料結構提供支援,而不是在本機和遠端資料來源之間處理複雜的中介服務。

請注意,您的 FakeTestRepository 並不需要使用 FakeDataSource,或其他類似方式;只要輸入輸入內容,就能傳回實際的假輸出。您將使用 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:新增測試方法至 addTasks

測試時,最好將存放區中已有部分 Tasks。您可以多次呼叫 saveTask,但為了簡化操作,請新增輔助程式,以便對新增工作的測試進行測試。

  1. 加入 addTasks 方法,該方法會擷取 vararg 的工作,並將每個工作新增至 HashMap,然後再重新整理工作。

FakeTestRepository.kt

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

目前您擁有測試用的存放區,主要用於測試一些重要的方法。接下來,請在測試中使用這項設定!

在這項工作中,您必須使用 ViewModel 內的假類別。使用建構函式依附元件插入功能,透過將 TasksRepository 變數新增至 TasksViewModel's 建構函式,透過建構函式依附元件插入來擷取兩個資料來源。

這個流程與檢視模型不太一樣,因為您沒有直接建構模型。例如:

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 
}

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

  1. TasksViewModel 檔案底部,在課程外,新增 TasksViewModelFactory,並進入純文字 TasksRepository

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 測試 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,而不是實際存放區。

接下來,請撰寫整合測試來測試片段和檢視模型的互動情形。請檢查檢視模型程式碼是否正確更新您的使用者介面。為此,您必須使用

  • ServiceLocator 模式
  • 濃縮咖啡和麥基托圖書館

整合測試可測試多個類別的互動,確保兩者搭配使用時能正常運作。這些測試可在本機執行 (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 測試程式庫啟動 TaskDetailFragmentFragmentScenario 是 AndroidX Test 提供的一個類別,可讓你納入特定片段,並讓您直接控製片段的生命週期。如要撰寫片段的測試,請為您要測試的片段建立 FragmentScenario (TaskDetailFragment)。

  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,但您需要一些方法來取代您的存放區。您接下來將會!

在這項工作中,您將使用 ServiceLocator 將偽造的存放區提供給片段。這樣您就能編寫片段,以及查看模型整合測試。

您可以像以前一樣使用建構函式依附元件插入功能,就像之前對其提供模型模型或存放區所需的依附元件一樣。若要建構建構函式依附元件,您必須建構類別。片段和活動是您可以自行建立的類別範例,但通常無法存取建構函式。

由於您並未建立片段,因此您可以使用建構函式依附元件插入功能,將存放區測試的雙重 (FakeTestRepository) 替換成片段。請改用服務定位器模式。服務定位器模式是依附元件植入的替代方案。這需要建立一個名稱為「Service Locator」的單調類別,其用途是提供一般程式碼和測試程式碼的依附元件。在一般應用程式碼 (main 來源集) 中,所有依附元件都是一般的應用程式依附元件。針對測試,您可以修改 Service Locator,提供依附元件的雙重版本。

未使用服務搜尋器


使用服務搜尋器

在這個程式碼研究室應用程式中,請執行下列步驟:

  1. 建立能夠建構和儲存存放區的 Service Locator 類別。根據預設,系統會建構「一般」存放區。
  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:提供現有的存放區,或是建立新存放區。在 this 上,此方法應設為 synchronized,以免在多個執行緒中執行時意外建立兩個存放區執行個體。
  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) 建立存放區。

重要的是,您只需要建立一個存放區類別的一個例項。為了確保如此,您將在「我的應用程式」類別中使用「服務搜尋器」。

  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 來源集上按一下滑鼠右鍵,並建立 data 套件。再按兩下右鍵,即可建立「來源」套件。
  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. runBlockingTest 中納入 activeTaskDetails_DisplayedInUi() 的函式主體。
  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 測試程式庫完成第一次整合測試。您已設計程式碼,因此可以為使用者介面新增宣告宣告。因此,請使用 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 陳述式由 4 個部分組成:

1. Static Espresso 方法

onView

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

2. ViewMatcher

withId(R.id.task_detail_title_text)

withIdViewMatcher 的範例,依 ID 取得資料檢視。您可以在說明文件中查看其他比對比對工具。

3. ViewAction

perform(click())

使用 ViewActionperform 方法。ViewAction 是一種可以對資料檢視進行的操作,例如在這裡按一下檢視。

4. ViewAssertion

check(matches(isChecked()))

checkViewAssertionViewAssertion 會檢查或聲明該視圖的部分內容。您最常使用的 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」測試程式庫來測試導覽元件

在這個程式碼研究室中,您已經使用稱為假的雙重測試。假造是多種測試雙重型之一。您應該使用哪一種測試雙重測試導覽元件

請思考導覽功能的運作方式。假設按下 TasksFragment 中的其中一項工作,即可前往工作詳情畫面。

這裡是 TasksFragment 中的程式碼,按下程式碼後會瀏覽至工作詳情畫面。

TasksFragment.kt

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


瀏覽作業是透過 navigate 方法發出。如果您需要撰寫宣告聲明,目前並沒有直接測試「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)

在麥多託的模樣中,傳入要模擬的課程。

接下來,您需要將 NavController 與片段建立關聯。onFragment 可讓您在片段本身上呼叫方法。

  1. 將您的新模擬設為片段 NavController
scenario.onFragment {
    Navigation.setViewNavController(it.view!!, navController)
}
  1. 加入程式碼,在 RecyclerView 中點選含有「TITLE1」文字的項目。
// 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 方法就是讓模擬圖成為模擬畫面的原因。在這種情況下,您可以使用參數 (actionTasksFragmentToTaskDetailFragment,ID 為「id1」) 來確認模擬的 navController 是採用特定方法 (navigate)。

完整的測試如下所示:

@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_navigationToAddEditFragment

若要確認您是否可以自行撰寫導航測試,請試試這項工作。

  1. 撰寫測試 clickAddTaskButton_navigateToAddEditFragment,如果您按一下 [+ FAB],系統就會將您導向 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 元件。
  • 當您無法使用建構函式依附元件 (例如啟動片段) 時,通常可以使用服務定位器。服務搜尋器模式是依附元件植入的替代方案。這需要建立一個名稱為「Service Locator」的單調類別,其用途是提供一般程式碼和測試程式碼的依附元件。

Udacity 課程:

Android 開發人員說明文件:

影片:

其他:

如要瞭解本課程中其他程式碼研究室的連結,請參閱 Kotlin 的進階 Android 程式碼研究室到達網頁。