iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 29
0
自我挑戰組

Android Architecture 及 Unit Test系列 第 29

[Day 29] 測試的可維護性

  • 分享至 

  • xImage
  •  

今天繼續談談跟測試有關的東西。隨著時間過去,測試會變得更加複雜,難以維護,功能的每一次變更也要花額外的時間與心力去修正舊的測試。

如今我們已經完成了許多測試,那就要來看看這些測試好不好維護,也就是測試的可維護性。下面會提出一些個人在寫出一個好維護的測試時會注意的地方。

測試 private 或是 protected 方法

開發時把方法設定成 private 或是 protected 一般都是有原因的,通常是為了不把實作行為暴露給外部,又或者是混淆程式碼等手法。

如果測試一段 private 的程式碼,我們所測試的內容實際上是某個 class 的一個內部細節,這個細節是動態的,有可能因為一次重構而發生變化。在重構時,就算這隻程式對外的功能不變,也會因為所測試的內部細節已經改變而讓測試失敗。

所以在測試時應該始終關注在公開的方法上。

可以換一個角度想:這些私有的方法不會獨立存在,仔細追蹤一定會有一個公開的方法呼叫他,又或者是被其他私有方法調用。所以,所有的私有方法都是某一個實作的環節,而這些實作最終都會對應的某些公開的方法或是 api 上。

如果一個 private 方法需要被測試,那麼他也應該是公開的、靜態的方法,或至少是一個 internal 的方法。如果有其必要性,也可以把這些內容抽出來獨立成一個 class ,並針對這個 class 再寫一個測試程式。

這也是現在大家如此推崇 TDD 的一個原因,因為 TDD 可以讓你始終關注在測試公開方法上,細節則會拆分成各個私有方法。

避免在測試中寫邏輯

包含邏輯的測試一般會有以下的語法:

  • switch 、if、else 等判斷式
  • while 、for 、for each 等迴圈

通常包含邏輯的測試不會只驗證一件事情,同時,因為增加了許多邏輯也讓程式變得難以閱讀。

由於寫出這些測試的很有可能是原本完成這些邏輯的開發人員,他們有可能對需求或是邏輯有錯誤的認知,所以這些有邏輯的測試可能也重複了產品的邏輯程式碼,讓測試中帶著無法預知的 bug 。

那還有一些測試程式,裡面有只為測試服務的邏輯,這種情況又如何呢?其實也應該盡量避免寫出有測試邏輯的測試程式,因為他們也會帶有出現 bug 的風險,而且這些 bug 更難以追蹤,因為我們通常覺得出問題都是在產品程式上而不會是測試本身的邏輯。

如果這是必要之惡,那也需要寫一支測試這些測試邏輯的程式。而實際上,這些有測試邏輯的程式也不是單元測試,而是整合測試

始終關注在一個細節上

我們先來看看一個程式:

fun chooseTheMaxValue(first: Int, second: Int, third: Int): Int {
    // 從三個數中找出最大數
    // 假設了一個故意錯誤的邏輯
    
    if (second > third) {
        return third
    }
    
    if (first > third) {
        return third
    }
    
    ......
    
    return 正確的最大數
}

......

@Test
fun testSumAndDivide() {
    assertThat(chooseTheMaxValue(6, 2, 1), `is`(6))
    assertThat(chooseTheMaxValue(1, 6, 2), `is`(6))
    assertThat(chooseTheMaxValue(1, 2, 6), `is`(6))
}

上面的測試包含了多個測試,實際上測試了三個不同的子功能。

我們為了方便寫了這樣的測試,這會有什麼問題呢?當測試失敗時,會在第一個錯誤的地方跳出,而不會繼續執行,也就是說其他的測試根本沒有被測試到,遺漏了其他有可能也錯誤的驗證。

有時我們的確在某個驗證出問題時不需要再關心其他的驗證,但像上面的的範例,每一個驗證都是對某個結果所進行的測試,即使一個驗證錯誤了,我們還是會想要知道其他驗證的結果。

這時比較常見的解決方式是將每個驗證結果各自獨立成一個測試,另外,在一個測試中測試太多關注點也會也會衍生出以下的問題。

測試的命名

理想上一個好的測試是可以讓人易於理解的,這時測試的命名就十分重要,因為它可以讓人在第一時間就知道這個測試的內容及目的是什麼。

如同前面所寫的,當測試帶有太多的資訊,會使測試變得難懂且複雜,讓後續的開發人員必須要去閱讀實際的程式碼才能理解測試的內容,無形中增加了許多維護成本。

討論測試的命名可以將內容簡單分成兩類:方法的命名及變數的命名

方法的命名

一個測試的方法名稱通常要包含以下資訊:

  • 被測試的方法/目標:可以明確告訴開發人員所測試的方法是什麼,特別是有些 IDE 有支援智能輸入時這一點就非常關鍵
  • 測試情境:說明測試時的條件
  • 預期結果:基於目前的情境,測試後應該產生的行為或結果

讓我們改寫上面的第一個驗證:

fun selectMaxValue(first: Int, second: Int, third: Int): Int {   
    ......    
    return 正確的最大數
}

......

@Test
fun selectMaxValue_whenInputMaxValueIntoFirstParam_thenReturnMaxValue() {

    val result = chooseTheMaxValue(6, 2, 1)

    assertThat(result, `is`(6))
}

現在的測試方法能夠告訴我們許多更明確的訊息了。

變數的命名

上面的範例還有許多不足之處,我們再舉另一個 API 測試的例子:

@Test
fun BadNamingTest() {
    val service = APIService()
    
    val result = service.getSomeData("access token")
    
    assertThat(result, `is`(200))
}

這個範例的問題是出現了一個神奇數字 200 ,很顯然 200 代表了某個有意義的的值,這個值是一個異常還是有效的結果我們無從得知。這時可以這麼處理:

  • 如果他代表了一個異常訊息,則可以拋出一個異常來表示,如果可以的話讓這個異常的名字也能表示某些資訊更好,如: throw AccessTokenExpiredException()
  • 使用某個有意義的常數替代,如以下程式碼:
    @Test
    fun RefactoringTest() {
        val ACCESS_TOKEN = "access token"
        val API_RESULT_OK = 200
        val service = APIService()
    
        val result = service.getSomeData(ACCESS_TOKEN)
    
        assertThat(result, `is`(API_RESULT_OK))
    }
    

以上的內容都是為了要增加測試的可讀性,程式碼易於閱讀可以讓開發人員快速的理解程式的組成及功能的起始點。

今天就到這裡,關於可維護性還有許多細節,如果未來有時間會在我的 Medium 上繼續討論。


上一篇
[Day 28] 程式的可測試性
下一篇
[Day 30] 結語
系列文
Android Architecture 及 Unit Test30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言