這個程式碼研究室是「Android Kotlin 進階功能」課程的一部分。如果您按部就班完成每一堂程式碼研究室課程,就能充分體驗到本課程的價值,但這不是強制要求。如要查看所有課程程式碼研究室,請前往 Android Kotlin 進階功能程式碼研究室登陸頁面。
簡介
這個第二個測試程式碼研究室的主題是測試替身:何時在 Android 中使用測試替身,以及如何使用依附元件注入、服務定位器模式和程式庫實作測試替身。您將學會如何編寫:
- 存放區單元測試
- 片段和 ViewModel 整合測試
- 片段導覽測試
必備知識
您必須已經熟悉下列項目:
- Kotlin 程式設計語言
- 第一個程式碼研究室涵蓋的測試概念:在 Android 上編寫及執行單元測試、使用 JUnit、Hamcrest、AndroidX 測試、Robolectric,以及測試 LiveData
- 下列 Android Jetpack 核心程式庫:
ViewModel、LiveData和導覽元件 - 應用程式架構,採用應用程式架構指南和 Android 基本概念程式碼研究室的模式
- Android 協同程式基本概念
課程內容
- 如何規劃測試策略
- 如何建立及使用測試替身,也就是模擬和仿冒物件
- 如何在 Android 上使用手動依附元件插入進行單元測試和整合測試
- 如何套用服務定位器模式
- 如何測試存放區、片段、ViewModel 和 Navigation 元件
您將使用下列程式庫和程式碼概念:
學習內容
- 使用測試替身和依附元件插入功能,為存放區編寫單元測試。
- 使用測試替身和依附元件插入功能,編寫檢視區塊模型的單元測試。
- 使用 Espresso UI 測試架構,為片段及其 ViewModel 編寫整合測試。
- 使用 Mockito 和 Espresso 編寫導覽測試。
在本系列程式碼研究室中,您將使用 TO-DO Notes 應用程式。這個應用程式可讓您寫下待辦事項,並以清單形式顯示。然後標示為完成或未完成、篩選或刪除。

這個應用程式是以 Kotlin 編寫,有幾個畫面,使用 Jetpack 元件,並遵循《應用程式架構指南》的架構。瞭解如何測試這個應用程式後,您就能測試使用相同程式庫和架構的應用程式。
下載程式碼
如要開始,請先下載程式碼:
或者,您也可以複製 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。如果您熟悉下列任一範例,這個應用程式的架構與這些範例類似:
- 設有檢視畫面程式碼研究室的 Room
- Android Kotlin 基礎知識訓練程式碼研究室
- 進階 Android 訓練程式碼研究室
- Android Sunflower 範例
- 使用 Kotlin 開發 Android 應用程式 Udacity 訓練課程
您不必深入瞭解任何一層的邏輯,但請務必瞭解應用程式的整體架構。
以下是您會看到的套裝方案摘要:
Package: | |
| 新增或編輯工作畫面:用於新增或編輯工作的 UI 層程式碼。 |
| 資料層:處理工作資料層。其中包含資料庫、網路和存放區程式碼。 |
| 統計資料畫面:統計資料畫面的 UI 層程式碼。 |
| 工作詳細資料畫面:單一工作的 UI 層程式碼。 |
| 工作畫面:所有工作的清單 UI 層代碼。 |
| 公用程式類別:應用程式各部分使用的共用類別,例如用於多個畫面的滑動重新整理版面配置。 |
資料層 (.data)
這個應用程式包含 remote 套件中的模擬網路層,以及 local 套件中的資料庫層。為求簡化,這個專案會使用延遲時間的 HashMap 模擬網路層,而不是發出實際的網路要求。
DefaultTasksRepository 會協調或調解網路層和資料庫層之間的關係,並將資料傳回 UI 層。
UI 層 ( .addedittask、.statistics、.taskdetail、.tasks)
每個 UI 層套件都包含片段和檢視模型,以及 UI 所需的任何其他類別 (例如工作清單的配接器)。TaskActivity 是包含所有片段的活動。
導覽
應用程式的導覽是由 Navigation 元件控制。這是在 nav_graph.xml 檔案中定義的。檢視模型會使用 Event 類別觸發導覽,並決定要傳遞哪些引數。片段會觀察 Event,並在畫面之間執行實際導覽。
在本程式碼研究室中,您將瞭解如何使用測試替身和依附元件插入功能,測試存放區、檢視模式和片段。在深入瞭解這些內容之前,請務必先瞭解編寫這些測試的理由,這將有助於您決定測試內容和方式。
本節將說明一般測試的最佳做法,以及這些做法在 Android 上的應用。
測試金字塔
思考測試策略時,有三個相關的測試面向:
- 範圍:測試涵蓋多少程式碼?測試可以針對單一方法、整個應用程式或介於兩者之間的範圍執行。
- 速度:測試執行速度有多快?測試速度可能從毫秒到幾分鐘不等。
- 保真度:測試有多「真實」?舉例來說,如果您測試的程式碼部分需要發出網路要求,測試程式碼是否會實際發出這項要求,還是會模擬結果?如果測試實際上會與網路通訊,表示保真度較高。但缺點是測試時間可能較長、網路中斷時可能會發生錯誤,以及使用成本可能較高。
這些層面之間存在固有的取捨關係。舉例來說,速度和準確度是兩難的選擇,測試速度越快,準確度通常就越低,反之亦然。自動化測試通常可分為以下三類:
- 單元測試:這類測試的重點非常明確,只會針對單一類別 (通常是該類別中的單一方法) 執行測試。如果單元測試失敗,您就能確切知道程式碼中發生問題的位置。由於在現實世界中,應用程式涉及的不只是執行一個方法或類別,因此單元測試的保真度較低。速度夠快,每次變更程式碼時都能執行。這些測試通常是在本機執行的測試 (位於
test來源集)。範例: 測試檢視區塊模型和存放區中的單一方法。 - 整合測試:測試多個類別的互動,確保這些類別一起使用時的行為符合預期。整合測試的結構化方式之一,是測試單一功能,例如儲存工作的功能。整合測試的程式碼範圍比單元測試更大,但仍經過最佳化,可快速執行,而非追求完整保真度。視情況而定,這些測試可以在本機執行,也可以做為檢測設備測試執行。範例: 測試單一片段和檢視模型配對的所有功能。
- 端對端測試 (E2e):測試多項功能是否能正常運作。這類測試會測試應用程式的大部分內容,並模擬實際使用情況,因此通常速度較慢。這類測試的準確度最高,可確保應用程式整體運作正常。一般來說,這些測試會是已檢測的測試 (位於
androidTest來源集中)。
範例: 啟動整個應用程式,並一併測試幾項功能。
這些測試的建議比例通常以金字塔表示,其中絕大多數是單元測試。

架構與測試
您能否在測試金字塔的所有不同層級測試應用程式,本質上與應用程式架構息息相關。舉例來說,架構極度不良的應用程式可能會將所有邏輯放在一個方法中。您或許可以為此編寫端對端測試,因為這類測試通常會測試應用程式的大部分內容,但編寫單元或整合測試呢?所有程式碼都集中在一處,因此很難只測試與單一單元或功能相關的程式碼。
較好的做法是將應用程式邏輯分解為多個方法和類別,讓每個部分都能獨立測試。架構是劃分及整理程式碼的方式,可簡化單元和整合測試。您要測試的待辦事項應用程式採用特定架構:
在本課程中,您將瞭解如何適當隔離並測試上述架構的各個部分:
- 首先,您要單元測試 存放區。
- 接著,您會在檢視模型中使用測試替身,這是檢視模型單元測試和整合測試的必要條件。
- 接下來,您將學習如何為片段及其檢視畫面模型編寫整合測試。
- 最後,您將學習如何編寫包含導覽元件的整合測試。
下一個課程會介紹端對端測試。
為類別的一部分 (方法或一小組方法) 編寫單元測試時,目標是只測試該類別中的程式碼。
測試特定類別或類別中的程式碼可能很棘手。讓我們來看看下面這個例子。開啟 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 | 追蹤呼叫了哪些方法的測試替身。然後,視方法是否正確呼叫,通過或未通過測試。 |
票根 | 測試替身不包含任何邏輯,只會傳回您程式設計要傳回的內容。舉例來說,您可以編寫 |
範例 | 傳遞但未使用的測試替身,例如您只需要將其做為參數提供。如果您有 |
Spy | 測試替身也會追蹤一些額外資訊;舉例來說,如果您建立 |
如要進一步瞭解測試替身,請參閱「Testing on the Toilet:認識測試替身」。
Android 中最常見的測試替身是「假物件」和「模擬物件」。
在這項工作中,您將建立 FakeDataSource 測試替身,以便單元測試 DefaultTasksRepository,並與實際資料來源分離。
步驟 1:建立 FakeDataSource 類別
在這個步驟中,您將建立名為 FakeDataSouce 的類別,這個類別會是 LocalDataSource 和 RemoteDataSource 的測試替身。
- 在 test 來源集中,按一下滑鼠右鍵,然後依序選取「New」->「Package」。

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

步驟 2:實作 TasksDataSource 介面
如要將新類別 FakeDataSource 做為測試替身,必須能夠取代其他資料來源。這些資料來源包括 TasksLocalDataSource 和 TasksRemoteDataSource。

- 請注意,這兩者都會實作
TasksDataSource介面。
class TasksLocalDataSource internal constructor(
private val tasksDao: TasksDao,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : TasksDataSource { ... }
object TasksRemoteDataSource : TasksDataSource { ... }- 讓
FakeDataSource實作TasksDataSource:
class FakeDataSource : TasksDataSource {
}Android Studio 會顯示錯誤訊息,指出您尚未實作 TasksDataSource 的必要方法。
- 使用快速修正選單,然後選取「Implement members」。

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

步驟 3:在 FakeDataSource 中實作 getTasks 方法
FakeDataSource 是一種稱為「虛擬物件」的特定型別測試替身。虛擬物件是測試替身,具有類別的「運作中」實作項目,但實作方式適合測試,不適合用於實際工作環境。「可運作」實作是指類別會根據輸入內容產生實際輸出內容。
舉例來說,虛擬資料來源不會連線至網路,也不會將任何內容儲存至資料庫,而是只會使用記憶體內清單。這會「如您所預期般運作」,也就是說,取得或儲存工作的方法會傳回預期結果,但您永遠無法在正式環境中使用這項實作,因為系統不會將工作儲存至伺服器或資料庫。
A FakeDataSource
- 可讓您測試
DefaultTasksRepository中的程式碼,不必依賴真實的資料庫或網路。 - 為測試提供「夠真實」的實作。
- 變更
FakeDataSource建構函式,建立名為tasks的var,該函式是MutableList<Task>?,預設值為空白的可變動清單。
class FakeDataSource(var tasks: MutableList<Task>? = mutableListOf()) : TasksDataSource { // Rest of class }
這是「偽造」資料庫或伺服器回應的任務清單。目前的目標是測試存放區 的 getTasks 方法。這會呼叫資料來源的 getTasks、deleteAllTasks 和 saveTask 方法。
編寫這些方法的虛擬版本:
- 寫入
getTasks:如果tasks不是null,請傳回Success結果。如果tasks是null,就會傳回Error結果。 - 寫入
deleteAllTasks:清除可變動的工作清單。 - 撰寫
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 中使用建構函式依附元件插入
- 將
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 }- 由於您已傳遞依附元件,請移除
init方法。您不再需要建立依附元件。 - 同時刪除舊的執行個體變數。您在建構函式中定義這些項目:
DefaultTasksRepository.kt
// Delete these old variables
private val tasksRemoteDataSource: TasksDataSource
private val tasksLocalDataSource: TasksDataSource
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO- 最後,更新
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。
- 在
DefaultTasksRepository類別名稱上按一下滑鼠右鍵,然後依序選取「產生」和「測試」。 - 按照提示在「test」來源集中建立
DefaultTasksRepositoryTest。 - 在新的
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 }- 建立三個變數、兩個
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。
- 建立名為
createRepository的方法,並加上@Before註解。 - 使用
remoteTasks和localTasks清單,例項化虛擬資料來源。 - 使用您剛建立的兩個虛假資料來源和
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 測試!
- 為存放區的
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 依附元件,以便在測試中處理啟動協同程式。
- 使用
testImplementation,將測試協同程式所需的依附元件新增至測試來源集。
app/build.gradle
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"別忘了同步處理!
kotlinx-coroutines-test 是協同程式測試程式庫,專門用於測試協同程式。如要執行測試,請使用 runBlockingTest 函式。這是協同程式測試程式庫提供的函式。它會接收程式碼區塊,然後在特殊的協同程式環境中執行這個程式碼區塊,這個環境會同步且立即執行,也就是說,動作會以可預測的順序發生。這項做法基本上會讓協同程式以非協同程式的形式執行,因此適用於測試程式碼。
呼叫 suspend 函式時,請在測試類別中使用 runBlockingTest。在本系列課程的下一個程式碼研究室中,您將進一步瞭解 runBlockingTest 的運作方式,以及如何測試協同程式。
- 在類別上方新增
@ExperimentalCoroutinesApi。這表示您知道自己要在類別中使用實驗性協同程式 API (runBlockingTest)。否則會收到警示。 - 返回
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))
}
}- 執行新的
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 介面)。
- 開啟
DefaultTasksRepository,然後按一下滑鼠右鍵類別名稱。然後依序選取「Refactor」->「Extract」->「Interface」。

- 選擇「擷取至個別檔案」。

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

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

DefaultTasksRepository 現已實作 TasksRepository。
- 執行應用程式 (而非測試),確認一切正常運作。
步驟 2:建立 FakeTestRepository
現在您有了介面,可以建立 DefaultTaskRepository 測試替身。
- 在 test 來源集中,於 data/source 中建立 Kotlin 檔案和類別
FakeTestRepository.kt,並從TasksRepository介面擴充。
FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
}系統會告知您需要實作介面方法。
- 將游標懸停在錯誤上,直到看到建議選單,然後按一下並選取「Implement members」。
- 選取所有方法,然後按一下「確定」。

步驟 3:實作 FakeTestRepository 方法
現在您有一個 FakeTestRepository 類別,其中包含「未實作」的方法。與您實作 FakeDataSource 的方式類似,FakeTestRepository 會由資料結構提供支援,而非處理本機和遠端資料來源之間複雜的中介程序。
請注意,FakeTestRepository 不需使用 FakeDataSources 或類似項目,只要根據輸入內容回傳逼真的虛假輸出內容即可。您將使用 LinkedHashMap 儲存工作清單,並使用 MutableLiveData 儲存可觀測的工作。
- 在
FakeTestRepository中,同時新增代表目前工作清單的LinkedHashMap變數,以及可觀察工作的MutableLiveData。
FakeTestRepository.kt
class FakeTestRepository : TasksRepository {
var tasksServiceData: LinkedHashMap<String, Task> = LinkedHashMap()
private val observableTasks = MutableLiveData<Result<List<Task>>>()
// Rest of class
}實作下列方法:
getTasks:這個方法應採用tasksServiceData,並使用tasksServiceData.values.toList()將其轉換為清單,然後以Success結果的形式傳回。refreshTasks:將observableTasks的值更新為getTasks()傳回的值。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,但為了簡化這項作業,請新增專為測試設計的輔助方法,以便新增工作。
- 新增
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 畫面相關的類別和測試。
- 開啟
TasksViewModel。 - 變更
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 相同檔案中,但也可以放在自己的檔案中。
- 在
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 建構方式的標準做法。現在您已擁有工廠,可以在建構檢視模型時使用。
- 更新
TasksFragment,即可使用工廠。
TasksFragment.kt
// REPLACE
private val viewModel by viewModels<TasksViewModel>()
// WITH
private val viewModel by viewModels<TasksViewModel> {
TasksViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}- 執行應用程式程式碼,確認一切運作正常!
步驟 2:在 TasksViewModelTest 中使用 FakeTestRepository
現在,您可以在檢視模型測試中使用偽造的存放區,不必使用實際存放區。
- 開啟
TasksViewModelTest。 - 在
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
}- 更新
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)
}- 由於您不再使用 AndroidX Test
ApplicationProvider.getApplicationContext程式碼,因此也可以移除@RunWith(AndroidJUnit4::class)註解。 - 執行測試,確保一切運作正常!
使用建構函式依附元件插入功能後,您現在已移除 DefaultTasksRepository 做為依附元件,並在測試中將其替換為 FakeTestRepository。
步驟 3:同時更新 TaskDetail 片段和 ViewModel
對 TaskDetailFragment 和 TaskDetailViewModel 進行完全相同的變更。這樣一來,您之後編寫 TaskDetail 測試時,程式碼就會準備就緒。
- 開啟
TaskDetailViewModel。 - 更新建構函式:
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 }- 在類別外的
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)
}- 更新
TasksFragment,即可使用工廠。
TasksFragment.kt
// REPLACE
private val viewModel by viewModels<TaskDetailViewModel>()
// WITH
private val viewModel by viewModels<TaskDetailViewModel> {
TaskDetailViewModelFactory(DefaultTasksRepository.getRepository(requireActivity().application))
}- 執行程式碼,確認一切正常。
您現在可以在 TasksFragment 和 TasksDetailFragment 中使用 FakeTestRepository,而非實際存放區。
接著,您將編寫整合測試,測試片段和 ViewModel 互動。您會發現檢視區塊模型程式碼是否適當更新了 UI。如要執行這項操作,請使用
- ServiceLocator 模式
- Espresso 和 Mockito 程式庫
整合測試 會測試多個類別的互動情形,確保這些類別一起使用時能正常運作。這些測試可以在本機 (test 來源集) 執行,也可以做為檢測設備測試 (androidTest 來源集) 執行。

在您的情況下,您將取得每個片段,並為片段和檢視模型編寫整合測試,以測試片段的主要功能。
步驟 1:新增 Gradle 依附元件
- 新增下列 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 編寫片段測試,因為與其他片段相比,這個片段的功能相當基本。
- 開啟
taskdetail.TaskDetailFragment。 - 產生
TaskDetailFragment的測試,做法與先前相同。接受預設選項,並將其放在 androidTest 來源集 (而非test來源集)。

- 將下列註解新增至
TaskDetailFragmentTest類別。
TaskDetailFragmentTest.kt
@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {
}這些註解的用途如下:
@MediumTest:將測試標示為「中等執行時間」的整合測試 (相較於@SmallTest單元測試和@LargeTest大型端對端測試)。方便您分組及選擇要執行的測試大小。@RunWith(AndroidJUnit4::class):用於使用 AndroidX Test 的任何類別。
步驟 3:從測試啟動片段
在這項工作中,您將使用 AndroidX Testing 程式庫啟動 TaskDetailFragment。FragmentScenario 是 AndroidX Test 中的類別,可包裝片段,讓您直接控制片段的生命週期以進行測試。如要為片段編寫測試,請為要測試的片段 (TaskDetailFragment) 建立 FragmentScenario。
- 將這項測試複製到
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)
}
上述程式碼:
- 建立工作。
- 建立
Bundle,代表傳遞至片段的工作片段引數。 launchFragmentInContainer函式會使用這個套件和主題建立FragmentScenario。
這還不是完成的測試,因為尚未判斷任何內容。現在請執行測試,並觀察會發生什麼情況。
- 這是檢測設備測試,因此請確認模擬器或裝置可見。
- 執行測試。
應該會發生下列情況:
- 首先,由於這是設備測試,因此測試會在實體裝置 (如果已連線) 或模擬器上執行。
- 系統應會啟動該片段。
- 請注意,這個片段不會透過任何其他片段導覽,也不會與任何活動相關聯的選單,只有這個片段。
最後,請仔細查看,會發現片段顯示「沒有資料」,因為片段未成功載入工作資料。

測試需要載入 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 來源集) 中,所有這些依附元件都是一般應用程式依附元件。在測試中,您會修改服務定位器,提供依附元件的測試雙重版本。
未使用服務定位器
| 使用服務定位器
|
針對本程式碼研究室應用程式,請執行下列操作:
- 建立可建構及儲存存放區的服務定位器類別。根據預設,這會建構「一般」存放區。
- 重構程式碼,以便在需要存放區時使用服務定位器。
- 在測試類別中,呼叫 Service Locator 的方法,將「一般」存放區換成測試替身。
步驟 1:建立 ServiceLocator
我們來建立 ServiceLocator 類別。由於主要應用程式碼會使用這個類別,因此它會與其餘應用程式碼一起存在於主要來源集中。
注意:ServiceLocator 是單例項,因此請使用Kotlin object 關鍵字。
- 在主要來源集的頂層建立 ServiceLocator.kt 檔案。
- 定義名為
ServiceLocator的object。 - 建立
database和repository例項變數,並將兩者都設為null。 - 使用
@Volatile註解存放區,因為多個執行緒可能會使用該存放區 (詳情請參閱這篇文章)。@Volatile
程式碼應如下所示。
object ServiceLocator {
private var database: ToDoDatabase? = null
@Volatile
var tasksRepository: TasksRepository? = null
}目前 ServiceLocator 只需要知道如何傳回 TasksRepository。系統會傳回現有的 DefaultTasksRepository,或視需要建立並傳回新的 DefaultTasksRepository。
定義下列函式:
provideTasksRepository:提供現有存放區或建立新存放區。這個方法應為synchronized,this避免在多個執行緒執行的情況下,意外建立兩個存放區執行個體。createTasksRepository:用於建立新存放區的程式碼。將呼叫createTaskLocalDataSource並建立新的TasksRemoteDataSource。createTaskLocalDataSource:建立新本機資料來源的程式碼。系統將撥打createDataBase。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 類別中使用服務定位器。
- 在套件階層的頂層,開啟
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 方法。
- 開啟
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 提供的存放區,而不是直接建立存放區。
- 開啟
TaskDetailFragement,然後在類別頂端找到對getRepository的呼叫。 - 將這個呼叫替換為從
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)
}- 對
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)
}- 針對
StatisticsViewModel和AddEditTaskViewModel,請更新取得存放區的程式碼,改為使用TodoApplication中的存放區。
TasksFragment.kt
// REPLACE this code
private val tasksRepository = DefaultTasksRepository.getRepository(application)
// WITH this code
private val tasksRepository = (application as TodoApplication).taskRepository
- 執行應用程式 (而非測試)!
由於您只重構程式碼,應用程式應該能照常執行。
步驟 3:建立 FakeAndroidTestRepository
測試來源集中已有 FakeTestRepository。根據預設,您無法在 test 和 androidTest 來源集之間共用測試類別。因此,您需要在 androidTest 來源集中複製 FakeTestRepository 類別,並將其命名為 FakeAndroidTestRepository。
- 在來源集上按一下滑鼠右鍵
androidTest,然後建立資料套件。再次按一下滑鼠右鍵,然後建立「來源」 套件。 - 在這個來源套件中,建立名為
FakeAndroidTestRepository.kt的新類別。 - 將下列程式碼複製到該類別。
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 程式碼中加入一些程式碼。
- 開啟
ServiceLocator.kt。 - 將
tasksRepository的設定程式標示為@VisibleForTesting。這項註解可表明設定器公開的原因是為了測試。
ServiceLocator.kt
@Volatile
var tasksRepository: TasksRepository? = null
@VisibleForTesting set無論是單獨執行測試,還是與其他測試一起執行,測試的運作方式都應完全相同。也就是說,測試之間不應有任何相互依賴的行為 (因此請避免在測試之間共用物件)。
由於 ServiceLocator 是單例模式,因此可能會在測試之間意外共用。為避免這種情況,請建立方法,在測試之間正確重設 ServiceLocator 狀態。
- 新增名為
lock的例項變數,並將值設為Any。
ServiceLocator.kt
private val lock = Any()- 新增名為
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。
- 開啟
TaskDetailFragmentTest。 - 宣告
lateinit TasksRepository變數。 - 新增設定和清除方法,在每次測試前設定
FakeAndroidTestRepository,並在每次測試後清除。
TaskDetailFragmentTest.kt
private lateinit var repository: TasksRepository
@Before
fun initRepository() {
repository = FakeAndroidTestRepository()
ServiceLocator.tasksRepository = repository
}
@After
fun cleanupDb() = runBlockingTest {
ServiceLocator.resetRepository()
}
- 將
activeTaskDetails_DisplayedInUi()的函式主體包裝在runBlockingTest中。 - 啟動片段前,請先將
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)
}- 為整個類別加上
@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)
}
}
- 執行
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 測試時,建議關閉動畫 (測試也會更快執行!):
- 在測試裝置上,依序前往「設定」>「開發人員選項」。
- 停用「視窗動畫比例」、「轉場動畫比例」和「動畫影片長度比例」這三項設定。

步驟 3:查看 Espresso 測試
編寫 Espresso 測試前,請先看看一些 Espresso 程式碼。
onView(withId(R.id.task_detail_complete_checkbox)).perform(click()).check(matches(isChecked()))這項陳述式會找出 ID 為 task_detail_complete_checkbox 的核取方塊檢視區塊、點選該核取方塊,然後確認核取方塊已勾選。
大多數 Espresso 陳述式都由四個部分組成:
onViewonView 是靜態 Espresso 方法的範例,可啟動 Espresso 陳述式。onView 是最常見的選項之一,但也有其他選項,例如 onData。
2. ViewMatcher
withId(R.id.task_detail_title_text)withId 是 ViewMatcher 的範例,會依 ID 取得檢視區塊。您可以在說明文件中查詢其他檢視區塊比對器。
3. ViewAction
perform(click())perform 方法,會採用 ViewAction。ViewAction 是指可對檢視區塊執行的動作,例如點選檢視區塊。
check(matches(isChecked()))check,這需要 ViewAssertion。ViewAssertions 檢查或判斷檢視區塊的相關事項。您最常使用的 ViewAssertion 是 matches 判斷。如要完成判斷,請使用另一個 ViewMatcher,在本例中為 isChecked。

請注意,您不一定會在 Espresso 陳述式中同時呼叫 perform 和 check。您可以使用 check 建立僅進行判斷的陳述式,或使用 perform 僅執行 ViewAction。
- 開啟
TaskDetailFragmentTest.kt。 - 更新
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// THEN註解後的所有內容都會使用 Espresso。檢查測試結構和withId的使用情形,並確認詳細資料頁面的外觀。- 執行測試,並確認通過測試。
步驟 4:(選用) 自行編寫 Espresso 測試
現在請自行撰寫測試。
- 建立名為
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
}- 查看先前的測試,完成這項測試。
- 執行並確認測試通過。
完成的 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 依附元件
- 新增 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
- 開啟
TasksFragment。 - 在
TasksFragment類別名稱上按一下滑鼠右鍵,然後依序選取「產生」和「測試」。在 androidTest 來源集中建立測試。 - 將這組代碼複製到
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。
- 新增測試
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)
}
- 使用 Mockito 的
mock函式建立模擬。
TasksFragmentTest.kt
val navController = mock(NavController::class.java)如要在 Mockito 中模擬,請傳入要模擬的類別。
接著,您必須將 NavController 與片段建立關聯。onFragment 可讓您呼叫片段本身的方法。
- 將新模擬物件設為片段的
NavController。
scenario.onFragment {
Navigation.setViewNavController(it.view!!, navController)
}- 在具有「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 動作。
- 確認
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")
)
}- 執行測試!
總而言之,如要測試導覽功能,您可以:
- 使用 Mockito 建立
NavController模擬。 - 將模擬的
NavController附加至片段。 - 確認系統已使用正確的動作和參數呼叫導覽。
步驟 3:選用,撰寫 clickAddTaskButton_navigateToAddEditFragment
如要確認自己是否能編寫導覽測試,請嘗試完成這項工作。
- 編寫測試
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 中開啟。
本程式碼研究室說明如何設定手動依附元件插入、服務定位器,以及如何在 Android Kotlin 應用程式中使用虛擬和模擬物件。特別是:
- 您要測試的內容和測試策略,決定了要為應用程式實作的測試類型。單元測試著重於特定功能,且速度很快。整合測試可驗證程式各部分之間的互動。端對端測試會驗證功能、準確度最高、通常會經過儀器處理,且可能需要較長時間才能執行。
- 應用程式的架構會影響測試難度。
- TDD 或測試驅動開發是一種策略,您會先編寫測試,然後建立通過測試的功能。
- 如要隔離應用程式的特定部分進行測試,可以使用測試替身。測試替身是專為測試而設計的類別版本。舉例來說,您可以模擬從資料庫或網際網路取得資料。
- 使用依附元件插入功能,將實際類別替換為測試類別,例如存放區或網路層。
- 使用檢測設備測試 (
androidTest) 啟動 UI 元件。 - 如果無法使用建構函式依附元件插入 (例如啟動片段),通常可以使用服務定位器。服務定位器模式是依附元件插入的替代方案。這項作業需要建立名為「服務定位器」的單例類別,目的是為一般和測試程式碼提供依附元件。
Udacity 課程:
Android 開發人員說明文件:
- 應用程式架構指南
runBlocking和runBlockingTestFragmentScenario- Espresso
- Mockito
- JUnit4
- AndroidX 測試程式庫
- AndroidX 架構元件核心測試程式庫
- 來源集
- 從指令列進行測試
影片:
其他:
如要查看本課程其他程式碼研究室的連結,請參閱 Android Kotlin 進階功能程式碼研究室登陸頁面。




