TL;DR: 本文主要面向單元測試新手,首先簡單介紹了什麼是單元測試,爲何要寫單元測試,討論了一下 Android 項目中哪些代碼適合作單元測試,並以一個簡單例子演示瞭如何編寫屬於你的第一個 Android 單元測試(kotlin 代碼)。html
單元測試是對程序的最小單元進行正確性檢驗的測試工做。程序單元是應用的最小可測試部件。一個單元多是單個程序、類、對象、方法等。 —— Wikipediajava
沒有測試的代碼都是不可靠的。— 魯迅android
最直接的好處。在沒有單元測試的時候,一般咱們自測的方法就是跑一跑程序,簡單構造一下主要的分支場景,若是經過了,就認爲 OK 能夠提交給 QA 同窗了。但實際上有些時候有些分支本身是沒法測到或者很難構造出來條件的,這隻能依靠 QA 同窗手工測試來覆蓋,若是他們也沒有測到,那隻能老天保佑了。而經過單元測試咱們能夠方便構造各類測試場景,對於經過測試的代碼,咱們會更有信心git
說白了就是能夠放心的重構。QA 同窗老是談重構而色變,咱們在重構遺留代碼的時候也是提心吊膽,生怕改錯了舊的邏輯,或者意外影響到別的模塊。有了單元測試,咱們就能夠更加大膽的進行重構,重構完只要跑一下單測驗證是否經過就能夠了(適合小範圍的重構,大的重構可能就須要重寫單元測試了)github
在設計測試用例的過程當中,須要考慮到業務上的各類場景,有助於咱們跳出代碼加深對業務的理解web
單元測試要求被測試的代碼高內聚,低耦合,因此你在寫業務代碼的時候就要考慮到如何寫測試,或者反過來,先寫測試用例的話會讓你可以寫出來結構性更好的代碼shell
單元測試有什麼代價嗎?固然也是有的,編寫和維護測試用例須要花費必定的時間和精力,當項目進度壓力比較大的時候,不少人是不肯意再花時間去寫測試的。這就須要進行權衡,要麼不寫而後喪失前面說的各類好處,要麼後面有時間再補上來,但也錯過了寫測試的最好時間。數據庫
Android 項目默認會建立兩個測試目錄,分別爲 src/test 和 src/androidTest 前者是單元測試目錄,後者是依賴 Android 框架的 instrumentation 測試目錄。聲明測試也有區別,前者是 testImplementation
後者是 androidTestImplementation
,咱們今天討論的是前者,也叫 Local Unit Test,意思也就是說不依賴 Android 真機或者模擬器,能夠直接在本地 JVM 上運行的單元測試。網絡
Android 的單元測試與普通的 java 項目並無太大差別,首先須要關注的是如何分辨那些類或者方法須要測試。app
一個好的單元測試的一個重要特性就是運行速度要快,一般是毫秒級的,而依賴 Android 框架的代碼都須要在模擬器上或者真機上運行(也不是絕對的),速度不可避免的會慢不少,因此咱們在作 Android 單元測試的時候會避免讓被測試代碼對 Android 框架有任何依賴。在這個條件下,通常適合進行單元測試的代碼就是:
若是你的項目中代碼與 Android 框架耦合比較高,那麼可能就不得不先對目標代碼進行重構,而後再編寫測試代碼。如何重構不在本文討論範圍,請自行探索。
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)
}
複製代碼
解釋一下,這裏可能比較陌生的代碼有兩處:
這個註解是 Junit 測試框架的一部分,當前測試類中的每個測試用例都會先調用 @Before 註解的方法,因此能夠用來作一些公共的 setup 的操做。具體在這裏,咱們要測試的是 Presenter,因此就是建立好了一個 Presenter 實例,並配置了須要與 Presenter 交互的 View / Repository 等外部對象。與 Before 對應,還有一個 @After 註解,能夠標註一個方法,用來在每一個用例執行完畢後作一些清理操做,若是不須要的話 ,也能夠省略不寫。
這個方法是 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 的發起者,也是敏捷開發的奠定人。說這些的意思是提醒你們不要陷入教條主義,測試的目的是爲了提高對代碼質量,只要本身和團隊有信心,就愛怎麼測試就怎麼測,怎麼合適怎麼測,沒有必要必定要寫測試,必定要測試先行。
OK,到此爲止,你應該已經學會了編寫 Android 單元測試的基本知識,若是想進一步瞭解 Android 測試,建議能夠閱讀如下資料:
Happy unit testing!