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

祈禱式編程

若是代碼中包含如下代碼java

或者上線後進行這種活動python

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

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

推薦拜邊城大神沈從文小程序

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

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

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

單元測試

什麼是單元測試

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

google 把測試分紅小型測試、中型測試和大型測試。單元測試基本和小型測試的做用相似,可是一般也會使用mock或者stub 的方式模擬外部服務。併發

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

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

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

  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. 增長或使用初始化代碼

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

也能夠在公號回覆 「test」 獲取pdf

總結

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

參考文章


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

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

掃碼關注
相關文章
相關標籤/搜索