學習單元測試,告別祈禱式編程

祈禱式編程

祈禱式編程

如果代碼中包含以下代碼

或者上線後進行這種活動

那麼這種編程方式就是祈禱式編程。

用流程圖表示基本就是這個樣子。

祈禱式編程有什麼危害呢?

  1. 累,每次寫完代碼還需要再祈禱
  2. 不受控,代碼運行結果主要看運氣,大仙忙的時候可能保佑不了

解決這個問題有好多種方法,單元測試是其中之一。

單元測試

什麼是單元測試

單元測試是由開發人員編寫的,用於對軟件基本單元進行測試的可執行的程序。
單元(unit)是一個應用程序中最小的課測試部分。(比如一個函數,一個類

google 把測試分成小型測試、中型測試和大型測試。單元測試基本和小型測試的作用類似,但是通常也會使用mock或者stub 的方式模擬外部服務。

理想情況下,單元測試應該是相互獨立、可自動化運行的。

目的: 通常用單元測試來驗證代碼邏輯是否符合預期。完整可靠的單元測試是代碼的安全網,可以在代碼修改或重構時驗證業務邏輯是否正確,提前發現代碼錯誤,減少調試時間。設計良好的單元測試某些情況下可以比文檔更能反應出代碼的功能和作用。

單元測試這麼多優點爲什麼有人不喜歡寫單元測試呢?

  1. 單元測試太費時間了,對於編寫單元測試不熟練的新手來說,編寫單元測試可能比寫代碼的還費時間
  2. 單元測試運行時間太長(這通常是單元測試設計不合理或者代碼可測試性較差造成的
  3. 祖傳代碼,看都看不懂怎麼寫單元測試(這個確實優點棘手。。可以考慮先給新代碼加單元測試
  4. 不會寫單元測試

這篇文章主要關注第四個問題,如何寫單元測試。

單元測試的結構

首先看一下單元測試的結構,一個完整的單元測試主要包括Arrange-Act-Assert(3A) 三部分。

  • Arrange--準備數據
  • Act--運行代碼
  • Assert--判斷結果是否符合預期

比如我們要給下面這段代碼(golang)加單元測試:


func Add(x, y int) int {
    return x + y
}

單元測試代碼如下:

import "testing"

func TestAdd(t *testing.T) {
    // arrange 準備數據
    x, y := 1, 2
    // act   運行
    got := Add(x, y)
    //assert  斷言
    if got != 3 {
        t.Errorf("Add() = %v, want %v", got, 3)
    }
}

如何編寫好的單元測試

什麼樣的單元測試纔是好的單元測試呢?

先看一個例子:

package ut

import (
    "fmt"
    "strconv"
    "strings"
)

func isNumber(num string) (int, error) {
    num = strings.TrimSpace(num)
    n, err := strconv.Atoi(num)
    return n, err
}

func multiply(x string, y int) string {
    // 如果x 去除前後的空格後是數字,返回 數字的乘積
    //     比如 x="2" y=3 return "6"
    // 如果x 去除前後的空格後不是數字,則返回字符串的x的y倍 
    //     比如 x="a" y=2 return "aa"
    num, err := isNumber(x)
    if err == nil {
        return fmt.Sprintf("%d", num*y)
    }
    result := ""
    for i := 0; i < y; i++ {
        result = fmt.Sprintf("%s%s", result, x)
    }
    return result
}

測試代碼可能是這個樣子。


// 測試方法的名字不直觀,並不能看出具體要測試什麼
func Test_multiply(t *testing.T) {
    type args struct {
        x string
        y int
    }
    // 一個測試方法中有太多的測試用例
    tests := []struct {
        name string
        args args
        want string
    }{
        {
            "return nil",
            args{
                "",
                2,
            },
            "",
        },
        {
            "return 2",
            args{
                "1",
                2,
            },
            "2",
        },
        {// 測試數據有點奇葩,不直觀
            "return aaa",
            args{
                "aaaaaaaaaa",
                6,
            },
            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := multiply(tt.args.x, tt.args.y); got != tt.want {
               // 數據錯誤的時候有明確標明測試數據,期望結果和實際結果,這一點還是有用的
                t.Errorf("multiply() = %v, want %v", got, tt.want)
            }
        })
    }
}

這個單元測試代碼有什麼問題呢?

  1. 代碼比較長(這裏只列出來了三個用例,實際上並沒有完整覆蓋全部結果)
  2. 測試方法如果出錯了並不容易定位位置(三個測試數據都在一個方法,任何一個錯誤都會指向到同一個位置
  3. 有個測試的數據比較長,不太能直觀判斷測試數據是否正確
  4. 輸入值並不完整,比如包含空格的數字字符串" 1" 、" 1 "、 "1 "並沒有測試。

結合上面我們對單元測試目的的描述,一個好的單元測試應該滿足以下幾個條件

  1. 單元測試越簡單越好,一個單元測試只做一件事
  2. 對錯誤易於追蹤,如果測試失敗,錯誤提示應該容易幫我我們定位問題
  3. 測試函數的命名符合特定的規則 Test_{被測方法}_{輸入}_{期望輸出}
  4. 有用的失敗消息
  5. 輸入簡單且能夠完整運用代碼的輸入(包含邊界值、特殊情況

比如,上邊的單元測試我們改成這樣:

// 測試特殊值 “空字符串”
func Test_multiply_empty_returnEmpty(t *testing.T) {
    // 用例簡單,只包含輸入、執行和判斷 
    x, y, want := "", 1, ""
    got := multiply(x, y)
    if got != want {
       // 有效的失敗消息
        t.Errorf("multiply() = %v, want %v", got, want)
    }
}

// 測試包含空格的數字 邊界值
func Test_multiply_numberWithSpace_returnNumber(t *testing.T) {
    x, y, want := " 2", 3, "6"
    got := multiply(x, y)
    if got != want {
        t.Errorf("multiply() = %v, want %v", got, want)
    }
}
// 測試正常數據
func Test_multiply_number_returnNumber(t *testing.T) {
    x, y, want := "2", 3, "6"
    got := multiply(x, y)
    if got != want {
        t.Errorf("multiply() = %v, want %v", got, want)
    }
}
// 測試非數字字符 
func Test_multiply_String_returnString(t *testing.T) {
    // 輸入簡單的字符串就可以測試,沒必要用太奇怪或者太長或者太大的數據數據
    x, y, want := "a", 3, "aaa"
    got := multiply(x, y)
    if got != want {
        t.Errorf("multiply() = %v, want %v", got, want)
    }
}
// 測試空格 邊界值
func Test_multiply_space_returnSpace(t *testing.T) {
    x, y, want := " ", 3, "   "
    got := multiply(x, y)
    if got != want {
        t.Errorf("multiply() = %v, want %v", got, want)
    }
}

當然這個數據也並不完整,還可以再加入:

  • 包含空格的非數字字符
  • 數字右側包含空格的字符串
  • 數字兩側都有空格的字符串

既然好的單元測試需要能完整的測試代碼,那麼有什麼方法可以保證單元測試可以完整覆蓋被測代碼呢?

基於代碼路徑進行分析編寫單元測試是一個方法。

單元測試路徑

設計測試路徑時可以使用流程圖的方式來分析,拿上邊multiply的例子進行分析,這段代碼的路徑如下:

當然,每個路徑的測試數據並不是只有一種,比如x爲前後包含空格的數字字符串這個路徑中就包含三種情況:

  • 左邊有空格
  • 右邊有空格
  • 兩邊都有空格

單元測試數據

合理的設計測試數據非常重要,測試除了符合上邊說的要簡單直觀以外還要着重考慮邊界值。

設計測試數據通常是把可能的輸入數據分成多個子集,然後從每個子集中選取具有代表性的數據作爲測試用例。
比如一段代碼的作用是計算個稅,我們就應該按照個稅不同的等級來設計測試數據,比如:

  • 年收入0-36000部分
  • 年收入36000-144000 部分
  • 年收入144000-300000部分
  • 年收入300000-420000部分
  • ...

然後在這個子集的基礎上在針對邊界值做一些檢查,比如36000、144000 等。

私有方法如何測試

通常情況下,如果私有方法在公有方法中有被調用,通過測試公有方法就已經可以間接測試到私有方法。

也有些私有方法寫的不合理,比如私有方法沒有被使用或者私有方法的功能和類的相關性不大,這個時候就建議把私有方法單獨提取成新的函數或者類來測試。

外部服務如何測試

當然現實世界中的代碼並不會這麼簡單,通常都會包含外部請求或者對於其它類的調用。
在編寫單元測試時,對於外部依賴我們通常使用Mock和Stub的方式來模擬外部依賴。

Mock和Stub 的區別:

  • Mock是在測試代碼中創建一個模擬對象,模擬被測方法的執行。測試使用模擬對象來驗證結果是否正確

  • Stub是在測試包中創建一個模擬方法,用於替換被測代碼中的方法,斷言針對被測類執行。

下面是代碼示例:

Mock

實際代碼:

//auth.go
//假設我們有一個依賴http請求的鑑權接口
type AuthService interface{    
    Login(username string,password string) (token string,e error)   
    Logout(token string) error
}

Mock代碼:

//auth_test.go
type authService struct {}
func (auth *authService) Login (username string,password string) (string,error){
    return "token", nil
}
func (auth *authService) Logout(token string) error{    
    return nil
}

在測試代碼中使用 authService實現了AuthService 接口,這樣測試時可以模擬外部的網絡的請求,解除依賴。

這裏使用的是golang 代碼,golang 不支持重載,這樣使用的問題是會產生大量重複的代碼。
如果是python、java等支持重載的面嚮對象語言,可以簡單的繼承父類,只重載包含外部請求的代碼就可以實現Mock的需求。

Stub

package ut

func notifyUser(username string){
    // 如果是管理員,發送登錄提醒郵件
}

type AuthService struct{}

func (auth *AuthService) Login(username string, password string) (string, error) {
    notifyUser(username)
    return "token", nil
}
func (auth *AuthService) Logout(token string) error {
    return nil
}

對於這段代碼想要測試其實是比較困難的,因爲Login 中調用了notifyUser,如果想測試這段代碼:

  • 一個方式是使用Mock的形式,定義authService 接口,然後實現接口 TestAuthService,在 TestAuthService Login中 替換掉notifyUser。這種做法改動比較大,同時重複代碼也比較多(當然如果是python java等支持重載的語言可以只重載Login接口即可。
  • 還有一種方法就是重構Login方法,把notifyUser 作爲參數傳入其中,這樣,我們只需在測試代碼中重新定義notifyUser,然後作爲參數傳入到Login即可模擬發送郵件提醒的功能。

第二種就是stub 的方式。

通過這個例子我們也可以看到,如果想要代碼容易測試,代碼在設計時就應該考慮可測試性。

編寫可測試代碼

Writing Testable Code 中提到一個非常實用的觀點:在開發時,多想想如何使得自己的代碼更方便去測試。如果考慮到這些,那麼通常你的代碼設計也不會太差。

如果代碼中出現了以下情況,那麼通常是不易於測試的:

  1. 在構造函數或成員變量中出現new關鍵字
  2. 在構造函數或成員變量中使用static方法
  3. 在構造函數中有除了字段賦值外的其它操作
  4. 在構造函數中使用條件語句或者循環
  5. 在構造函數中沒有使用builder或factory方法,二十使用object graph來構造
  6. 增加或使用初始化代碼

這篇文章地址爲:http://misko.hevery.com/attac... 推薦閱讀。

也可以在公號回覆 「test」 獲取pdf

總結

總結一下就是編寫可測試代碼,使用高質量單元測試(命名清晰、功能簡單、路徑完整、數據可靠)保證代碼質量。

參考文章


最後,感謝女朋友支持和包容,比❤️

也可以在公號輸入以下關鍵字獲取歷史文章:公號&小程序 | 設計模式 | 併發&協程

掃碼關注

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章