編寫你的第一個 Android 單元測試

有風度開荒隊發表於2019-05-21

TL;DR: 本文主要面向單元測試新手,首先簡單介紹了什麼是單元測試,為什麼要寫單元測試,討論了一下 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 框架有任何依賴。在這個條件下,一般適合進行單元測試的程式碼就是:

  1. MVP 結構中的 Presenter 或者 MVVM 結構中的 ViewModel
  2. Helper 或者 Utils 工具類
  3. 公共基礎模組,比如網路庫、資料庫等

如果你的專案中程式碼與 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 目錄下喲(也可以手動定位到相應目錄,建立一個新的檔案,但會慢很多)。

編寫你的第一個 Android 單元測試

編寫測試程式碼

行為驗證

首先新增如下程式碼

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)
  }
複製程式碼

解釋一下,這裡可能比較陌生的程式碼有兩處:

  1. @Before 註解

這個註解是 Junit 測試框架的一部分,當前測試類中的每一個測試用例都會先呼叫 @Before 註解的方法,所以可以用來做一些公共的 setup 的操作。具體在這裡,我們要測試的是 Presenter,所以就是建立好了一個 Presenter 例項,並配置了需要與 Presenter 互動的 View / Repository 等外部物件。與 Before 對應,還有一個 @After 註解,可以標註一個方法,用來在每個用例執行完畢後做一些清理操作,如果不需要的話 ,也可以省略不寫。

  1. 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)。

編寫你的第一個 Android 單元測試

我們再來寫一個比較複雜的測試用例,這次我們要驗證一下 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,游標放到類上執行即可。執行結果如下圖。

編寫你的第一個 Android 單元測試

如何判斷測試的有效性

測試程式碼很快寫完了,你可能會想,怎麼才能衡量測試的有效性呢?這裡就要引入另外一個概念,叫測試覆蓋率 (Code Coverage)。

測試覆蓋率有著不同的維度,比如類數量、方法數量、行數、條件分支等等,具體什麼意思不在本文討論範圍,大家可以自行探索。Android Studio 內建了工具可以幫我們進行統計。

回顧前面執行測試用例的時候,Android Studio 會幫我們建立一個 Task,而在執行按鈕右邊,還有一個按鈕叫 “Run [test-task-name] with coverage”,這個就是 IDE 內建的統計測試覆蓋率的工具啦。

編寫你的第一個 Android 單元測試

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

編寫你的第一個 Android 單元測試

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

編寫你的第一個 Android 單元測試

陷阱注意!

看起來測試覆蓋率是一個很好的衡量單元測試覆蓋程度甚至是測試質量的指標,實際上確實有很多開發者也因此會追求 100% 的測試覆蓋率,但這樣真的好嗎?

“單元測試並不是越多越好,而是越有效越好。” 這句話不是我說的,而是 Kent Beck 說的,他是 TDD 和 XP 的發起者,也是敏捷開發的奠基人。說這些的意思是提醒大家不要陷入教條主義,測試的目的是為了提升對程式碼質量,只要自己和團隊有信心,就愛怎麼測試就怎麼測,怎麼合適怎麼測,沒有必要一定要寫測試,一定要測試先行。

延伸閱讀

OK,到此為止,你應該已經學會了編寫 Android 單元測試的基本知識,如果想進一步瞭解 Android 測試,建議可以閱讀以下資料:

Happy unit testing!

參考

相關文章