摘要:什么是單元測試單元測試是對(duì)程序的最小單元進(jìn)行正確性檢驗(yàn)的測試工作。編寫第一個(gè)單元測試單元測試主要使用是測試框架類庫的擴(kuò)展庫,需要在中聲明測試依賴。目標(biāo)代碼這里以一個(gè)簡單的中的例子來說明如何寫單元測試。
TL;DR: 本文主要面向單元測試新手,首先簡單介紹了什么是單元測試,為什么要寫單元測試,討論了一下 Android 項(xiàng)目中哪些代碼適合做單元測試,并以一個(gè)簡單例子演示了如何編寫屬于你的第一個(gè) Android 單元測試(kotlin 代碼)。
什么是單元測試為什么要做單元測試單元測試是對(duì)程序的最小單元進(jìn)行正確性檢驗(yàn)的測試工作。程序單元是應(yīng)用的最小可測試部件。一個(gè)單元可能是單個(gè)程序、類、對(duì)象、方法等。 —— Wikipedia
沒有測試的代碼都是不可靠的?!?魯迅
驗(yàn)證代碼正確性,增強(qiáng)對(duì)代碼的信心
最直接的好處。在沒有單元測試的時(shí)候,通常我們自測的方法就是跑一跑程序,簡單構(gòu)造一下主要的分支場景,如果通過了,就認(rèn)為 OK 可以提交給 QA 同學(xué)了。但實(shí)際上有些時(shí)候有些分支自己是無法測到或者很難構(gòu)造出來?xiàng)l件的,這只能依靠 QA 同學(xué)手工測試來覆蓋,如果他們也沒有測到,那只能老天保佑了。而通過單元測試我們可以方便構(gòu)造各種測試場景,對(duì)于通過測試的代碼,我們會(huì)更有信心
在不需要 QA 參與的情況下保持或改進(jìn)產(chǎn)品質(zhì)量
說白了就是可以放心的重構(gòu)。QA 同學(xué)總是談重構(gòu)而色變,我們?cè)谥貥?gòu)遺留代碼的時(shí)候也是提心吊膽,生怕改錯(cuò)了舊的邏輯,或者意外影響到別的模塊。有了單元測試,我們就可以更加大膽的進(jìn)行重構(gòu),重構(gòu)完只要跑一下單測驗(yàn)證是否通過就可以了(適合小范圍的重構(gòu),大的重構(gòu)可能就需要重寫單元測試了)
加深對(duì)業(yè)務(wù)理解
在設(shè)計(jì)測試用例的過程中,需要考慮到業(yè)務(wù)上的各種場景,有助于我們跳出代碼加深對(duì)業(yè)務(wù)的理解
幫你寫出更好的代碼
單元測試要求被測試的代碼高內(nèi)聚,低耦合,所以你在寫業(yè)務(wù)代碼的時(shí)候就要考慮到如何寫測試,或者反過來,先寫測試用例的話會(huì)讓你能夠?qū)懗鰜斫Y(jié)構(gòu)性更好的代碼
單元測試有什么代價(jià)嗎?當(dāng)然也是有的,編寫和維護(hù)測試用例需要花費(fèi)一定的時(shí)間和精力,當(dāng)項(xiàng)目進(jìn)度壓力比較大的時(shí)候,很多人是不愿意再花時(shí)間去寫測試的。這就需要進(jìn)行權(quán)衡,要么不寫然后喪失前面說的各種好處,要么后面有時(shí)間再補(bǔ)上來,但也錯(cuò)過了寫測試的最好時(shí)間。
Android 單元測試Android 項(xiàng)目默認(rèn)會(huì)創(chuàng)建兩個(gè)測試目錄,分別為 src/test 和 src/androidTest 前者是單元測試目錄,后者是依賴 Android 框架的 instrumentation 測試目錄。聲明測試也有區(qū)別,前者是 testImplementation 后者是 androidTestImplementation,我們今天討論的是前者,也叫 Local Unit Test,意思也就是說不依賴 Android 真機(jī)或者模擬器,可以直接在本地 JVM 上運(yùn)行的單元測試。
Android 的單元測試與普通的 java 項(xiàng)目并沒有太大差異,首先需要關(guān)注的是如何分辨那些類或者方法需要測試。
一個(gè)好的單元測試的一個(gè)重要特性就是運(yùn)行速度要快,通常是毫秒級(jí)的,而依賴 Android 框架的代碼都需要在模擬器上或者真機(jī)上運(yùn)行(也不是絕對(duì)的),速度不可避免的會(huì)慢很多,所以我們?cè)谧?Android 單元測試的時(shí)候會(huì)避免讓被測試代碼對(duì) Android 框架有任何依賴。在這個(gè)條件下,一般適合進(jìn)行單元測試的代碼就是:
MVP 結(jié)構(gòu)中的 Presenter 或者 MVVM 結(jié)構(gòu)中的 ViewModel
Helper 或者 Utils 工具類
公共基礎(chǔ)模塊,比如網(wǎng)絡(luò)庫、數(shù)據(jù)庫等
如果你的項(xiàng)目中代碼與 Android 框架耦合比較高,那么可能就不得不先對(duì)目標(biāo)代碼進(jìn)行重構(gòu),然后再編寫測試代碼。如何重構(gòu)不在本文討論范圍,請(qǐng)自行探索。
編寫第一個(gè) Android 單元測試 SETUPAndroid 單元測試主要使用是 JUnit 測試框架 + Mockito Mock 類庫 + Mockito-kotlin 的擴(kuò)展庫,需要在 build.gradle 中聲明測試依賴。后面的示例代碼對(duì)應(yīng)的依賴如下。
testImplementation junit:junit:4.12
testImplementation org.mockito:mockito-core:2.19.0
testImplementation com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0
具體每個(gè)庫是用來做什么的,后面根據(jù)具體的代碼來說明。
目標(biāo)代碼這里以一個(gè)簡單的 MVP 中 Presenter 的例子來說明如何寫單元測試。 以下測試代碼來自于這里,是一個(gè)食譜搜索結(jié)果展示頁面。
class SearchResultsPresenter(private val repository: RecipeRepository) :
BasePresenter() {
private var recipes: List);null
fun search(query: String) {
view);object : RecipeRepository.RepositoryCallback> {
override fun onSuccess(recipes: List<Recipe>); {
this@SearchResultsPresenter.recipes = recipes
if (recipes != null && recipes.isNotEmpty()) {
view);else {
view);override fun onError() {
view);fun addFavorite(recipe: Recipe) {
recipe.isFavorited = true
repository.addFavorite(recipe)
val recipeIndex = recipes);if (recipeIndex != null) {
view);fun removeFavorite(recipe: Recipe) {
repository.removeFavorite(recipe)
recipe.isFavorited = false
val recipeIndex = recipes);if (recipeIndex != null) {
view);interface View {
fun showLoading()
fun showRecipes(recipes: List<Recipe>)
fun showEmptyRecipes()
fun showError()
fun refreshFavoriteStatus(recipeIndex: Int)
}
}
簡單分析一下代碼。
首先這個(gè) Presenter 類包含了一個(gè)內(nèi)部類 View ,定義了 MVP 中 View 應(yīng)該實(shí)現(xiàn)的一些方法,包括顯示加載狀態(tài),顯示食譜列表,顯示空頁面,顯示錯(cuò)誤頁面,刷新最愛等接口方法。
它的構(gòu)造函數(shù)接受了一個(gè) RecipeRepository 對(duì)象,我們來看一下 RecipeRepository 的定義。
interface RecipeRepository {
fun addFavorite(item: Recipe)
fun removeFavorite(item: Recipe)
fun getFavoriteRecipes(): List
fun getRecipes(query: String, callback: RepositoryCallback<List<Recipe>>)
}
interface RepositoryCallback<in T> {
fun onSuccess(t: T);
fun onError()
}
可以看到它是也是一個(gè)接口類,顧名思義它是一個(gè) recipe 的數(shù)據(jù)倉庫,定義了一系列的數(shù)據(jù)獲取和更新接口,至于從哪里獲取并不需要我們不關(guān)心,可以是本地文件、數(shù)據(jù)庫、網(wǎng)絡(luò)等等。這也正是依賴翻轉(zhuǎn)原則的體現(xiàn)。
這個(gè) Presenter 又繼承了 BasePresenter,這個(gè)類是一個(gè)抽象類,定義了兩個(gè)方法,分別是 attachView() 和 detachView(),還有一個(gè)字段 view。
abstract class BasePresenter<V> {
protected var view: V);null
fun attachView(view: V) {
this.view = view
}
fun detachView() {
this.view = null
}
}
回到 SearchResultsPresenter 自身,這個(gè)類有三個(gè)主要方法,第一個(gè) search() 接受一個(gè)字符串,調(diào)用了 repository 的方法獲取搜索結(jié)果,根據(jù)結(jié)果分別調(diào)用 View 的不同方法;第二個(gè) addFavorite(),它接受一個(gè) recipe 對(duì)象,將其設(shè)置為最愛,并調(diào)用 repository 更新到數(shù)據(jù)倉庫中,最后調(diào)用 view 方法刷新 UI;第三個(gè)方法 removeFavorite() ,它與上一個(gè)方法剛好相反?;惖姆椒ú辉谖覀儨y試范圍內(nèi),不用考慮。
這三個(gè)方法無疑就是我們單元測試的目標(biāo)了,繼續(xù)看如何寫測試代碼。
創(chuàng)建測試類首先定位到我們要測試的類,使用快捷鍵 CMD + N (Generate),選中 Test,就會(huì)出來一個(gè)彈窗,引導(dǎo)我們創(chuàng)建一個(gè)對(duì)應(yīng)的測試類,類名通常是我們要測試的類 + Test 后綴。要記得位置要放到 src/test 目錄下喲(也可以手動(dòng)定位到相應(yīng)目錄,創(chuàng)建一個(gè)新的文件,但會(huì)慢很多)。
編寫測試代碼
首先添加如下代碼
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 注解
這個(gè)注解是 Junit 測試框架的一部分,當(dāng)前測試類中的每一個(gè)測試用例都會(huì)先調(diào)用 @Before 注解的方法,所以可以用來做一些公共的 setup 的操作。具體在這里,我們要測試的是 Presenter,所以就是創(chuàng)建好了一個(gè) Presenter 實(shí)例,并配置了需要與 Presenter 交互的 View / Repository 等外部對(duì)象。與 Before 對(duì)應(yīng),還有一個(gè) @After 注解,可以標(biāo)注一個(gè)方法,用來在每個(gè)用例執(zhí)行完畢后做一些清理操作,如果不需要的話 ,也可以省略不寫。
mock() 方法
這個(gè)方法是 mockito-kotlin 庫提供的,它是一個(gè)包裝類庫,背后又調(diào)用了 Mockito 類庫,這個(gè)庫可以用來偽造一些穩(wěn)定的依賴類,避免不穩(wěn)定的依賴造成我們的單元測試結(jié)果不可預(yù)期。具體在這里,因?yàn)槲覀儨y試的目標(biāo)是 Presenter 類,與 Presenter 有交互關(guān)系的 View 和 Repo 都有抽象的接口,我們不想測試具體的 View 和 Repo 類(一 View 依賴了 Android 框架,運(yùn)行太慢,二 Repo 可能依賴了網(wǎng)絡(luò)或者數(shù)據(jù)庫或者文件,不夠穩(wěn)定),就可以使用 mock() 方法來創(chuàng)建一個(gè)模擬的類(這里 mock() 是一個(gè)泛型方法,使用了 kotlin 的類型推斷特性)。 Mock 出來的類可以用來檢測對(duì)應(yīng)的方法是否被調(diào)用,調(diào)用了多少次,調(diào)用的次序等等。
接下來添加第一個(gè)測試用例,我們要驗(yàn)證一下調(diào)用 presenter 的 search() 方法后,View 的 showLoading() 方法會(huì)被調(diào)用到。
@Test
fun search_callsShowLoading() {
presenter.search("eggs")
verify(view).showLoading()
}
首先當(dāng)然是先調(diào)用 presenter 的 search 方法,然后我們 調(diào)用了一個(gè) verify 方法,它會(huì)接受一個(gè) Mock 的對(duì)象,然后我們就可以驗(yàn)證這個(gè) Mock 對(duì)象的 showLoading() 方法被調(diào)用過了! 很簡單有沒有。在這個(gè)方法聲明的左邊,有一個(gè)運(yùn)行按鈕,點(diǎn)擊就可以執(zhí)行這個(gè)測試用例了(快捷鍵 Ctrl + Shift + R)。
我們?cè)賮韺懸粋€(gè)比較復(fù)雜的測試用例,這次我們要驗(yàn)證一下 search() 調(diào)用后,repo 的 getRecipes() 方法會(huì)調(diào)用到,當(dāng)回調(diào)返回后,view 的 showRecipes() 方法會(huì)調(diào)用到。
@Test
fun search_succeed_callShowRecipes() {
val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false)
val recipes = listOf(recipe)
doAnswer {
val callback: RepositoryCallback> = 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))
}
喔,這個(gè)方法代碼量一下多了好多,但不要被嚇到,其實(shí)都很好理解,首先我們創(chuàng)建了 recipes 對(duì)象來作為 repo 的搜索的返回結(jié)果,這里我們使用了一個(gè)新的方法,doAnswer{}.whenever().getRecipes(),也很好理解,就是當(dāng)調(diào)用的到 Mock 對(duì)象的 getRecipes() 方法的時(shí)候做一些事情,在 doAnswer{} 方法體中,我們拿到了回調(diào)的對(duì)象,并執(zhí)行了 onSuccess() 回調(diào),將我們構(gòu)造的搜索結(jié)果返回回去(這個(gè)過程就叫做 Stubbing,翻譯過來就是插樁)。好了,到這里位置我們已經(jīng)構(gòu)造好了測試的前提條件,下一步就是調(diào)用 presenter 的 search() 方法了。最后就是驗(yàn)證步驟了,也很好理解,不廢話了。
前面有兩個(gè)方法沒有講到,就是 eq("eggs") 方法和 any() 方法,這兩個(gè)方法返回都是 Matcher 對(duì)象,顧名思義就是用來校驗(yàn)參數(shù)是否與預(yù)期的符合,any() 比較特殊,意思就是我們不在乎到底是什么。需要注意的是,如果在方法調(diào)用時(shí)有一個(gè)參數(shù)使用了 Matcher,所有其他參數(shù)都必須也是 Matcher,這個(gè)不需要自己記住,如果你寫錯(cuò)了,運(yùn)行時(shí)就會(huì)報(bào)錯(cuò)。
根據(jù)前面的例子,很容易就可以聯(lián)想到還可以增加 search 失敗的時(shí)候調(diào)用 view.showError(),以及 search 結(jié)果為空時(shí),調(diào)用 view.showEmpty() 的測試用例,小菜一疊是不是?
前面寫的這些測試用例都是驗(yàn)證被測試對(duì)象依賴的模塊的某些方法可以被正確調(diào)用,所以可以歸為一類叫做行為驗(yàn)證,也就是 Mockito 通常被用來做的事情。
狀態(tài)驗(yàn)證還有一類測試,叫做狀態(tài)驗(yàn)證,通常使用 JUnit 庫中的 Assert 函數(shù),我們也舉一個(gè)例子。presenter 中有一個(gè)方法 addFavorite() 是將一個(gè)食譜添加為最愛,我們來看看應(yīng)該怎么寫測試用例。
@Test
fun addFavorite_shouldUpdateRecipeStatus() {
val recipe = Recipe("id", "title", "imageUrl", "sourceUrl", false)
presenter.addFavorite(recipe)
assertThat(recipe.isFavorited, `is`(true))
}
還是很簡單,我們構(gòu)造了一個(gè)默認(rèn) favorited 屬性為 false 的 recipe,然后調(diào)用 addFavorite() 方法,然后去驗(yàn)證 recipe 對(duì)象的 isFavorited 屬性應(yīng)該是 True . 這里驗(yàn)證的時(shí)候使用了 JUnit 庫中的 assertThat() 方法,這個(gè)方法接收兩個(gè)參數(shù) ,第一個(gè)參數(shù)是驗(yàn)證的目標(biāo),第二個(gè)參數(shù)是一個(gè) Matcher,因?yàn)?kotlin 中 is 是保留關(guān)鍵字,所以需要用 ` 進(jìn)行轉(zhuǎn)義。
相似的,也可以給 presenter 的 removeFavorite() 方法添加測試用例。
完整的測試類好了,現(xiàn)在我們可以給 Presenter 編寫出一個(gè)完整的測試類了,看一下完整的代碼。
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> = 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> = 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))
}
}
這已經(jīng)是一個(gè)相對(duì)完整的測試類了,在類聲明的第一行的左邊,同樣有一個(gè)按鈕點(diǎn)擊后可以運(yùn)行整個(gè)類內(nèi)定義的所有測試用例,同樣也有快捷鍵 Ctrl + Shift + R,光標(biāo)放到類上運(yùn)行即可。執(zhí)行結(jié)果如下圖。
如何判斷測試的有效性
測試代碼很快寫完了,你可能會(huì)想,怎么才能衡量測試的有效性呢?這里就要引入另外一個(gè)概念,叫測試覆蓋率 (Code Coverage)。
測試覆蓋率有著不同的維度,比如類數(shù)量、方法數(shù)量、行數(shù)、條件分支等等,具體什么意思不在本文討論范圍,大家可以自行探索。Android Studio 內(nèi)置了工具可以幫我們進(jìn)行統(tǒng)計(jì)。
回顧前面運(yùn)行測試用例的時(shí)候,Android Studio 會(huì)幫我們創(chuàng)建一個(gè) Task,而在運(yùn)行按鈕右邊,還有一個(gè)按鈕叫 “Run [test-task-name] with coverage”,這個(gè)就是 IDE 內(nèi)置的統(tǒng)計(jì)測試覆蓋率的工具啦。
運(yùn)行之后會(huì)自動(dòng)打開一個(gè) Coverage 結(jié)果頁面窗口,點(diǎn)進(jìn)去就可看到當(dāng)前測試 task 對(duì)相關(guān)的被測試代碼的一個(gè)覆蓋情況。結(jié)果顯示我們的測試用例覆蓋了 100% 的類和方法和 88% 的行數(shù)。
點(diǎn)擊打開具體類還能看到每一行代碼有沒有執(zhí)行到,非常好用,為我們對(duì)測試用例的調(diào)整和完善提供了很好的參考價(jià)值。比如,觀察這個(gè) addFavorite() 方法,我們的測試用例沒有覆蓋到 view 的 refresh 方法調(diào)用情況。
陷阱注意!
看起來測試覆蓋率是一個(gè)很好的衡量單元測試覆蓋程度甚至是質(zhì)量的指標(biāo)注意,實(shí)際上確實(shí)有很多人也因此會(huì)追求 100% 的測試覆蓋率,但這樣真的好嗎?
“單元測試并不是越多越好,而是越有效越好?!边@句話不是我說的,而是 Kent Beck 說的,他是 TDD 和 XP 的發(fā)起者,也是敏捷開發(fā)的奠基人。說這些的意思是提醒大家不要陷入教條主義,測試的目的是為了提升對(duì)代碼質(zhì)量,只要自己和團(tuán)隊(duì)有信心,就愛怎么測試就怎么測,怎么合適怎么測,沒有必要一定要寫測試,一定要測試先行。
延伸閱讀OK,到此為止,你應(yīng)該已經(jīng)學(xué)會(huì)了編寫 Android 單元測試的基本知識(shí),如果想進(jìn)一步了解 Android 測試,建議可以閱讀以下資料:
如果相對(duì) Android 測試有系統(tǒng)的了解,可以參考 Google 官方文檔developer.android.com | testing fundamentals
如果你想理解 Android UI 測試,可以參考 Google 在 19 年 IO 上剛剛更新的 codeLab Android Testing Codelab
查看 Mockito-Kotlin 和 Mockito 的 wiki 來了解更多與 Mock 相關(guān)的知識(shí)
CoolShell 上有一篇文章討論了 “單元測試要做多細(xì)?” | 酷 殼 - CoolShell
Happy unit testing!
參考android unit testing with mockito | raywenderlich.com
“單元測試要做多細(xì)?” | 酷 殼 - CoolShell
The Way of Testivus - Unit Testing Wisdom From An Ancient Software Start-up
文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://m.specialneedsforspecialkids.com/yun/7051.html
閱讀 730·2023-04-25 19:43
閱讀 3974·2021-11-30 14:52
閱讀 3801·2021-11-30 14:52
閱讀 3865·2021-11-29 11:00
閱讀 3796·2021-11-29 11:00
閱讀 3894·2021-11-29 11:00
閱讀 3571·2021-11-29 11:00
閱讀 6154·2021-11-29 11:00