編寫你的第一個 Android 單元測試
- 2019 年 12 月 12 日
- 筆記
來源:http://www.51testing.com
本文主要面向單元測試新手,首先簡單介紹了什麼是單元測試,為什麼要寫單元測試,討論了一下 Android 項目中哪些代碼適合做單元測試,並以一個簡單例子演示了如何編寫屬於你的第一個 Android 單元測試(kotlin 代碼)。
什麼是單元測試
單元測試是對程序的最小單元進行正確性檢驗的測試工作。程序單元是應用的最小可測試部件。一個單元可能是單個程序、類、對象、方法等。 —— Wikipedia
為什麼要做單元測試
沒有測試的代碼都是不可靠的。— 魯迅
驗證代碼正確性,增強對代碼的信心
最直接的好處。在沒有單元測試的時候,通常我們自測的方法就是跑一跑程序,簡單構造一下主要的分支場景,如果通過了,就認為 OK 可以提交給 QA 同學了。但實際上有些時候有些分支自己是無法測到或者很難構造出來條件的,這隻能依靠 QA 同學手工測試來覆蓋,如果他們也沒有測到,那隻能老天保佑了。而通過單元測試我們可以方便構造各種測試場景,對於通過測試的代碼,我們會更有信心
在不需要 QA 參與的情況下保持或改進產品質量
說白了就是可以放心的重構。QA 同學總是談重構而色變,我們在重構遺留代碼的時候也是提心弔膽,生怕改錯了舊的邏輯,或者意外影響到別的模塊。有了單元測試,我們就可以更加大膽的進行重構,重構完只要跑一下單測驗證是否通過就可以了(適合小範圍的重構,大的重構可能就需要重寫單元測試了)
加深對業務理解
在設計測試用例的過程中,需要考慮到業務上的各種場景,有助於我們跳出代碼加深對業務的理解
幫你寫出更好的代碼
單元測試要求被測試的代碼高內聚,低耦合,所以你在寫業務代碼的時候就要考慮到如何寫測試,或者反過來,先寫測試用例的話會讓你能夠寫出來結構性更好的代碼
單元測試有什麼代價嗎?當然也是有的,編寫和維護測試用例需要花費一定的時間和精力,當項目進度壓力比較大的時候,很多人是不願意再花時間去寫測試的。這就需要進行權衡,要麼不寫然後喪失前面說的各種好處,要麼後面有時間再補上來,但也錯過了寫測試的最好時間。
Android 單元測試
Android 項目默認會創建兩個測試目錄,分別為 src/test 和 src/androidTest 前者是單元測試目錄,後者是依賴 Android 框架的 instrumentation 測試目錄。聲明測試也有區別,前者是 testImplementation 後者是 androidTestImplementation,我們今天討論的是前者,也叫 Local Unit Test,意思也就是說不依賴 Android 真機或者模擬器,可以直接在本地 JVM 上運行的單元測試。
Android 的單元測試與普通的 java 項目並沒有太大差異,首先需要關注的是如何分辨那些類或者方法需要測試。
一個好的單元測試的一個重要特性就是運行速度要快,通常是毫秒級的,而依賴 Android 框架的代碼都需要在模擬器上或者真機上運行(也不是絕對的),速度不可避免的會慢很多,所以我們在做 Android 單元測試的時候會避免讓被測試代碼對 Android 框架有任何依賴。在這個條件下,一般適合進行單元測試的代碼就是:
MVP 結構中的 Presenter 或者 MVVM 結構中的 ViewModel
Helper 或者 Utils 工具類
公共基礎模塊,比如網絡庫、數據庫等
如果你的項目中代碼與 Android 框架耦合比較高,那麼可能就不得不先對目標代碼進行重構,然後再編寫測試代碼。如何重構不在本文討論範圍,請自行探索。
編寫第一個 Android 單元測試
SETUP
Android 單元測試主要使用是 JUnit 測試框架 + Mockito Mock 類庫 + Mockito-kotlin 的擴展庫,需要在 build.gradle 中聲明測試依賴。後面的示例代碼對應的依賴如下。
testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:2.19.0' testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0' |
---|
具體每個庫是用來做什麼的,後面根據具體的代碼來說明。
目標代碼
這裡以一個簡單的 MVP 中 Presenter 的例子來說明如何寫單元測試。
以下測試代碼來自於這裡,是一個食譜搜索結果展示頁面。
class SearchResultsPresenter(private val repository: RecipeRepository) : BasePresenter<SearchResultsPresenter.View>() { private var recipes: List<Recipe>? = null fun search(query: String) { view?.showLoading() repository.getRecipes(query, object : RecipeRepository.RepositoryCallback<List<Recipe>> { override fun onSuccess(recipes: List<Recipe>?) { this@SearchResultsPresenter.recipes = recipes if (recipes != null && recipes.isNotEmpty()) { view?.showRecipes(recipes) } else { view?.showEmptyRecipes() } } override fun onError() { view?.showError() } }) } fun addFavorite(recipe: Recipe) { recipe.isFavorited = true repository.addFavorite(recipe) val recipeIndex = recipes?.indexOf(recipe) if (recipeIndex != null) { view?.refreshFavoriteStatus(recipeIndex) } } fun removeFavorite(recipe: Recipe) { repository.removeFavorite(recipe) recipe.isFavorited = false val recipeIndex = recipes?.indexOf(recipe) if (recipeIndex != null) { view?.refreshFavoriteStatus(recipeIndex) } } interface View { fun showLoading() fun showRecipes(recipes: List<Recipe>) fun showEmptyRecipes() fun showError() fun refreshFavoriteStatus(recipeIndex: Int) } } |
---|
簡單分析一下代碼。
首先這個 Presenter 類包含了一個內部類 View ,定義了 MVP 中 View 應該實現的一些方法,包括顯示加載狀態,顯示食譜列表,顯示空頁面,顯示錯誤頁面,刷新最愛等接口方法。
它的構造函數接受了一個 RecipeRepository 對象,我們來看一下 RecipeRepository 的定義。
interface RecipeRepository { fun addFavorite(item: Recipe) fun removeFavorite(item: Recipe) fun getFavoriteRecipes(): List<Recipe> fun getRecipes(query: String, callback: RepositoryCallback<List<Recipe>>) } interface RepositoryCallback<in T> { fun onSuccess(t: T?) fun onError() } |
---|
可以看到它是也是一個接口類,顧名思義它是一個 recipe 的數據倉庫,定義了一系列的數據獲取和更新接口,至於從哪裡獲取並不需要我們不關心,可以是本地文件、數據庫、網絡等等。這也正是依賴翻轉原則的體現。
這個 Presenter 又繼承了 BasePresenter,這個類是一個抽象類,定義了兩個方法,分別是 attachView() 和 detachView(),還有一個字段 view。
abstract class BasePresenter<V> { protected var view: V? = null fun attachView(view: V) { this.view = view } fun detachView() { this.view = null } } |
---|
回到 SearchResultsPresenter 自身,這個類有三個主要方法,第一個 search() 接受一個字符串,調用了 repository 的方法獲取搜索結果,根據結果分別調用 View 的不同方法;第二個 addFavorite(),它接受一個 recipe 對象,將其設置為最愛,並調用 repository 更新到數據倉庫中,最後調用 view 方法刷新 UI;第三個方法 removeFavorite() ,它與上一個方法剛好相反。基類的方法不在我們測試範圍內,不用考慮。
這三個方法無疑就是我們單元測試的目標了,繼續看如何寫測試代碼。
創建測試類
首先定位到我們要測試的類,使用快捷鍵 CMD + N (Generate),選中 Test,就會出來一個彈窗,引導我們創建一個對應的測試類,類名通常是我們要測試的類 + Test 後綴。要記得位置要放到 src/test 目錄下喲(也可以手動定位到相應目錄,創建一個新的文件,但會慢很多)。

編寫測試代碼
行為驗證
首先添加如下代碼
class SearchResultsPresenterTests { private lateinit var repository: RecipeRepository private lateinit var presenter: SearchResultsPresenter private lateinit var view: SearchResultsPresenter.View @Before fun setup() { repository = mock() view = mock() presenter = SearchResultsPresenter(repository) presenter.attachView(view) } |
---|
解釋一下,這裡可能比較陌生的代碼有兩處:
@Before 註解
這個註解是 Junit 測試框架的一部分,當前測試類中的每一個測試用例都會先調用 @Before 註解的方法,所以可以用來做一些公共的 setup 的操作。具體在這裡,我們要測試的是 Presenter,所以就是創建好了一個 Presenter 實例,並配置了需要與 Presenter 交互的 View / Repository 等外部對象。與 Before 對應,還有一個 @After 註解,可以標註一個方法,用來在每個用例執行完畢後做一些清理操作,如果不需要的話 ,也可以省略不寫。
mock() 方法
這個方法是 mockito-kotlin 庫提供的,它是一個包裝類庫,背後又調用了 Mockito 類庫,這個庫可以用來偽造一些穩定的依賴類,避免不穩定的依賴造成我們的單元測試結果不可預期。具體在這裡,因為我們測試的目標是 Presenter 類,與 Presenter 有交互關係的 View 和 Repo 都有抽象的接口,我們不想測試具體的 View 和 Repo 類(一 View 依賴了 Android 框架,運行太慢,二 Repo 可能依賴了網絡或者數據庫或者文件,不夠穩定),就可以使用 mock() 方法來創建一個模擬的類(這裡 mock() 是一個泛型方法,使用了 kotlin 的類型推斷特性)。 Mock 出來的類可以用來檢測對應的方法是否被調用,調用了多少次,調用的次序等等。
接下來添加第一個測試用例,我們要驗證一下調用 presenter 的 search() 方法後,View 的 showLoading() 方法會被調用到。
@Test fun search_callsShowLoading() { presenter.search("eggs") verify(view).showLoading() } |
---|
首先當然是先調用 presenter 的 search 方法,然後我們 調用了一個 verify 方法,它會接受一個 Mock 的對象,然後我們就可以驗證這個 Mock 對象的 showLoading() 方法被調用過了! 很簡單有沒有。在這個方法聲明的左邊,有一個運行按鈕,點擊就可以執行這個測試用例了(快捷鍵 Ctrl + Shift + R)。

我們再來寫一個比較複雜的測試用例,這次我們要驗證一下 search() 調用後,repo 的 getRecipes() 方法會調用到,當回調返回後,view 的 showRecipes() 方法會調用到。
@Test fun search_succeed_callShowRecipes() { val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false) val recipes = listOf(recipe) doAnswer { val callback: RepositoryCallback<List<Recipe>> = it.getArgument(1) callback.onSuccess(recipes) }.whenever(repository).getRecipes(eq("eggs"), any()) presenter.search("eggs") verify(repository).getRecipes(eq("eggs"), any()) verify(view).showRecipes(eq(recipes)) } |
---|
喔,這個方法代碼量一下多了好多,但不要被嚇到,其實都很好理解,首先我們創建了 recipes 對象來作為 repo 的搜索的返回結果,這裡我們使用了一個新的方法,doAnswer{}.whenever().getRecipes(),也很好理解,就是當調用的到 Mock 對象的 getRecipes() 方法的時候做一些事情,在 doAnswer{} 方法體中,我們拿到了回調的對象,並執行了 onSuccess() 回調,將我們構造的搜索結果返回回去(這個過程就叫做 Stubbing,翻譯過來就是插樁)。好了,到這裡位置我們已經構造好了測試的前提條件,下一步就是調用 presenter 的 search() 方法了。最後就是驗證步驟了,也很好理解,不廢話了。
前面還漏了兩個方法 eq("eggs") 和 any(),這兩個方法返回都是 Matcher 對象,顧名思義就是用來校驗參數是否與預期的符合,any() 是一個特殊的 Matcher,意思就是我們不在乎到底是什麼。需要注意的是,如果在方法調用時有一個參數使用了 Matcher,所有其他參數都必須也是 Matcher,這個不需要你記住,如果你寫錯了,運行時就會報相應的錯誤提示。
根據前面的例子,很容易就可以聯想到還可以增加 search 失敗的時候調用 view.showError(),以及 search 結果為空時,調用 view.showEmpty() 的測試用例,小菜一疊是不是?
前面寫的這些測試用例都是驗證被測試對象依賴的模塊的某些方法可以被正確調用,所以可以歸為一類叫做行為驗證,也就是 Mockito 通常被用來做的事情。
狀態驗證
還有一類測試,叫做狀態驗證,通常使用 JUnit 庫中的 Assert 函數,我們也舉一個例子。presenter 中有一個方法 addFavorite() 是將一個食譜添加為最愛,我們來看看應該怎麼寫測試用例。
@Test fun addFavorite_shouldUpdateRecipeStatus() { val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false) presenter.addFavorite(recipe) assertThat(recipe.isFavorited, `is`(true)) } |
---|
還是很簡單,我們構造了一個默認 favorited 屬性為 false 的 recipe,然後調用 addFavorite() 方法,然後去驗證 recipe 對象的 isFavorited 屬性應該是 True . 這裡驗證的時候使用了 JUnit 庫中的 assertThat() 方法,這個方法接收兩個參數 ,第一個參數是驗證的目標,第二個參數是一個 Matcher,因為 kotlin 中 is 是保留關鍵字,所以需要用 ` 進行轉義。
相似的,也可以給 presenter 的 removeFavorite() 方法添加測試用例。
完整的測試類
好了,現在我們可以給 Presenter 編寫出一個完整的測試類了,看一下完整的代碼。
class SearchResultsPresenterTests { private lateinit var repository: RecipeRepository private lateinit var presenter: SearchResultsPresenter private lateinit var view: SearchResultsPresenter.View @Before fun setup() { repository = mock() view = mock() presenter = SearchResultsPresenter(repository) presenter.attachView(view) } @Test fun search_callsShowLoading() { presenter.search("eggs") verify(view).showLoading() } @Test fun search_succeed_callShowRecipes() { val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false) val recipes = listOf(recipe) doAnswer { val callback: RepositoryCallback<List<Recipe>> = it.getArgument(1) callback.onSuccess(recipes) }.whenever(repository).getRecipes(eq("eggs"), any()) presenter.search("eggs") verify(repository).getRecipes(eq("eggs"), any()) verify(view).showRecipes(eq(recipes)) } @Test fun search_error_callShowError() { doAnswer { val callback: RepositoryCallback<List<Recipe>> = it.getArgument(1) callback.onError() }.whenever(repository).getRecipes(eq("eggs"), any()) presenter.search("eggs") verify(repository).getRecipes(eq("eggs"), any()) verify(view).showError() } @Test fun addFavorite_shouldUpdateRecipeStatus() { val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false) presenter.addFavorite(recipe) assertThat(recipe.isFavorited, `is`(true)) } @Test fun removeFavorite_shouldUpdateRecipeStatus() { val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", true) presenter.removeFavorite(recipe) assertThat(recipe.isFavorited, `is`(false)) } } |
---|
這已經是一個相對完整的測試類了,在類聲明的第一行的左邊,同樣有一個按鈕點擊後可以運行整個類內定義的所有測試用例,同樣也有快捷鍵 Ctrl + Shift + R,光標放到類上運行即可。執行結果如下圖。

如何判斷測試的有效性
測試代碼很快寫完了,你可能會想,怎麼才能衡量測試的有效性呢?這裡就要引入另外一個概念,叫測試覆蓋率 (Code Coverage)。
測試覆蓋率有着不同的維度,比如類數量、方法數量、行數、條件分支等等,具體什麼意思不在本文討論範圍,大家可以自行探索。Android Studio 內置了工具可以幫我們進行統計。
回顧前面運行測試用例的時候,Android Studio 會幫我們創建一個 Task,而在運行按鈕右邊,還有一個按鈕叫 「Run [test-task-name] with coverage」,這個就是 IDE 內置的統計測試覆蓋率的工具啦。

運行之後會自動打開一個 Coverage 結果頁面窗口,點進去就可看到當前測試 task 對相關的被測試代碼的一個覆蓋情況。結果顯示我們的測試用例覆蓋了 100% 的類和方法和 88% 的行數。

點擊打開具體類還能看到每一行代碼有沒有執行到,非常好用,為我們對測試用例的調整和完善提供了很好的參考價值。比如,觀察這個 addFavorite() 方法,我們的測試用例沒有覆蓋到 view 的 refresh 方法調用情況。

陷阱注意!
看起來測試覆蓋率是一個很好的衡量單元測試覆蓋程度甚至是測試質量的指標,實際上確實有很多開發者也因此會追求 100% 的測試覆蓋率,但這樣真的好嗎?
「單元測試並不是越多越好,而是越有效越好。」 這句話不是我說的,而是 Kent Beck 說的,他是 TDD 和 XP 的發起者,也是敏捷開發的奠基人。說這些的意思是提醒大家不要陷入教條主義,測試的目的是為了提升對代碼質量,只要自己和團隊有信心,就愛怎麼測試就怎麼測,怎麼合適怎麼測,沒有必要一定要寫測試,一定要測試先行。
星雲測試
http://www.teststars.cc
奇林軟件
http://www.kylinpet.com
聯合通測
http://www.quicktesting.net