Golang 單元測試詳盡指引

2022-01-26 騰訊技術工程


文末有彩蛋。

作者:yukkizhang,騰訊 CSIG 專項技術測試工程師

本篇文章站在測試的角度,旨在給行業平臺乃至其他團隊的開發同學,進行一定程度的單元測試指引,讓其能夠快速的明確單元測試的方式方法。 本文主要從單元測試出發,對Golang的單元測試框架、Stub/Mock框架進行簡單的介紹和選型推薦,列舉出幾種針對於Mock場景的最佳實踐,並以具體代碼示例進行說明。一、單元測試1. 單元測試是什麼

單元是應用的最小可測試部件。在過程化編程中,一個單元就是單個程序、函數、過程等;對於面向對象編程,最小單元就是方法,包括基類、超類、抽象類等中的方法。單元測試就是軟體開發中對最小單位進行正確性檢驗的測試工作。

不同地方對單元測試有的定義可能會有所不同,但有一些基本共識:

2. 單元測試的意義提高代碼質量。代碼測試都是為了幫助開發人員發現問題從而解決問題,提高代碼質量。儘早發現問題。問題越早發現,解決的難度和成本就越低。保證重構正確性。隨著功能的增加,重構(修改老代碼)幾乎是無法避免的。很多時候我們不敢重構的原因,就是擔心其它模塊因為依賴它而不工作。有了單元測試,只要在改完代碼後運行一下單測就知道改動對整個系統的影響了,從而可以讓我們放心的重構代碼。簡化調試過程。單元測試讓我們可以輕鬆地知道是哪一部分代碼出了問題。簡化集成過程。由於各個單元已經被測試,在集成過程中進行的後續測試會更加容易。優化代碼設計。編寫測試用例會迫使開發人員仔細思考代碼的設計和必須完成的工作,有利於開發人員加深對代碼功能的理解,從而形成更合理的設計和結構。單元測試是最好的文檔。單元測試覆蓋了接口的所有使用方法,是最好的示例代碼。而真正的文檔包括注釋很有可能和代碼不同步,並且看不懂。3. 單元測試用例編寫的原則3.1 理論原則。單元測試是回歸測試,可以在開發過程的任何時候運行,因此運行速度必須快一致性。代碼沒有改變的情況下,每次運行得結果應該保持確定且一致用例獨立。執行順序不影響;用例間沒有狀態共享或者依賴關係;用例沒有副作用(執行前後環境狀態一致)隔離。功能可能依賴於資料庫、web 訪問、環境變量、系統時間等;一個單元可能依賴於另一部分代碼,用例應該解除這些依賴可讀性。用例的名稱、變量名等應該具有可讀性,直接表現出該測試的目標自動化。單元測試需要全自動執行。測試程序不應該有用戶輸入;測試結果應該能直接被電腦獲取,不應該由人來判斷。3.2 規約原則

在實際編寫代碼過程中,不同的團隊會有不同團隊的風格,只要團隊內部保持有一定的規約即可,比如:

單元測試文件名必須以 xxx_test.go 命名方法必須是 TestXxx 開頭,建議風格保持一致(駝峰或者下劃線)3.3 衡量原則

單元測試是要寫額外的代碼的,這對開發同學的也是一個不小的工作負擔,在一些項目中,我們合理的評估單元測試的編寫,我認為我們不能走極端,當然理論上來說全寫肯定時好的,但是從成本,效率上來說我們必須做出權衡,衡量原則如下:

邏輯類似的組件如果存在多個,優先編寫其中一種邏輯組件的測試用例發現 Bug 時一定先編寫測試用例進行 Debug關鍵 util 工具類要編寫測試用例,這些 util 工具適用的很頻繁,所以這個原則也叫做熱點原則,和第 1 點相呼應。測試用戶應該獨立,一個文件對應一個,而且不同的測試用例之間不要互相依賴。4. 單元測試用例設計方法4.1 規範(規格)導出法

規範(規格)導出法將需求」翻譯「成測試用例。

例如,一個函數的設計需求如下:

函數:一個計算平方根的函數輸入:實數輸出:實數要求:當輸入一個 0 或者比 0 大的實數時,返回其正的平方根;當輸入一個小於 0 的實數時,顯示錯誤信息「平方根非法—輸入之小於 0」,並返回 0;庫函數printf()可以用來輸出錯誤信息。

在這個規範中有 3 個陳述,可以用兩個測試用例來對應:

4.2 等價類劃分法

等價類劃分法假定某一特定的等價類中的所有值對於測試目的來說是等價的,所以在每個等價類中找一個之作為測試用例。

按照 [輸入條件][有效等價類][無效等價類] 建立等價類表,列出所有劃分出的等價類設計一個新的測試用例,使其儘可能多地覆蓋尚未被覆蓋地有效等價類。重複這一步,直到所有的有效等價類都被覆蓋為止設計一個新的測試用例,使其僅覆蓋一個尚未被覆蓋的無效等價類。重複這一步,直到所有的無效等價類都被覆蓋為止

例如,註冊郵箱時要求用 6~18 個字符,可使用字母、數字、下劃線,需以字母開頭。

測試用例:

4.3 邊界值分析法

邊界值分析法使用與等價類測試方法相同的等價類劃分,只是邊界值分析假定錯誤更多地存在於兩個劃分的邊界上。

邊界值測試在軟體變得複雜的時候也會變得不實用。邊界值測試對於非向量類型的值(如枚舉類型的值)也沒有意義。

例如,和4.1相同的需求:劃分(ii)的邊界為 0 和最大正實數;劃分(i)的邊界為最小負實數和 0。由此得到以下測試用例:

4.4 基本路徑測試法

基本路徑測試法是在程序控制流圖的基礎上,通過分析控制構造的環路複雜性,導出基本可執行路徑集合,從而設計測試用例的方法。設計出的測試用例要保證在測試中程序的每個可執行語句至少執行一次。

基本路徑測試法的基本步驟:

程序圈複雜度:McCabe 複雜性度量。從程序的環路複雜性可導出程序基本路徑集合中的獨立路徑條數,這是確定程序中每個可執行語句至少執行一次所必須的測試用例數目的上界。導出測試用例:根據圈複雜度和程序結構設計用例數據輸入和預期結果。準備測試用例:確保基本路徑集中的每一條路徑的執行。二、Golang 的測試框架

Golang 有這幾種比較常見的測試框架:

從測試用例編寫的簡易難度上來說:testify 比 GoConvey 簡單;GoConvey 比 Go 自帶的 testing 包簡單。  但在測試框架的選擇上,我們更推薦 GoConvey。因為:

GoConvey 和其他 Stub/Mock 框架的兼容性相比 Testify 更好。Testify 自帶 Mock 框架,但是用這個框架 Mock 類需要自己寫。像這樣重複有規律的部分在 GoMock 中是一鍵自動生成的。1. Go 自帶的 testing 包

testing 為 Go 語言 package 提供自動化測試的支持。通過 go test 命令,能夠自動執行如下形式的任何函數:

func TestXxx(*testing.T)

注意:Xxx 可以是任何字母數字字符串,但是第一個字母不能是小寫字母。

在這些函數中,使用 Error、Fail 或相關方法來發出失敗信號。

要編寫一個新的測試套件,需要創建一個名稱以 _test.go 結尾的文件,該文件包含 TestXxx 函數,如上所述。將該文件放在與被測試文件相同的包中。該文件將被排除在正常的程序包之外,但在運行 go test 命令時將被包含。有關詳細信息,請運行 go help test 和 go help testflag 了解。

1.1 第一個例子

被測代碼:

func Fib(n int) int {
        if n < 2 {
                return n
        }
        return Fib(n-1) + Fib(n-2)
}

測試代碼:

func TestFib(t *testing.T) {
    var (
        in       = 7
        expected = 13
    )
    actual := Fib(in)
    if actual != expected {
        t.Errorf("Fib(%d) = %d; expected %d", in, actual, expected)
    }
}

執行 go test . ,輸出:

$ go test .
ok      chapter09/testing    0.007s

表示測試通過。我們將 Sum 函數改為:

func Fib(n int) int {
        if n < 2 {
                return n
        }
        return Fib(n-1) + Fib(n-1)
}

再執行 go test . ,輸出:

$ go test .
--- FAIL: TestSum (0.00s)
    t_test.go:16: Fib(10) = 64; expected 13
FAIL
FAIL    chapter09/testing    0.009s

1.2 Table-Driven 測試

Table-Driven 的方式將多個 case 在同一個測試函數中測到:

func TestFib(t *testing.T) {
   var fibTests = []struct {
       in       int // input
       expected int // expected result
   }{
       {1, 1},
       {2, 1},
       {3, 2},
       {4, 3},
       {5, 5},
       {6, 8},
       {7, 13},
   }

   for _, tt := range fibTests {
       actual := Fib(tt.in)
       if actual != tt.expected {
           t.Errorf("Fib(%d) = %d; expected %d", tt.in, actual, tt.expected)
       }
   }
}

Go 自帶 testing 包的更多用法

2. GoConvey:簡單斷言

Convey 適用於書寫單元測試用例,並且可以兼容到 testing 框架中,go test命令或者使用goconvey命令訪問localhost:8080的 Web 測試界面都可以查看測試結果。

Convey("Convey return : ", t, func() {
        So(...)
})

一般 Convey 用So來進行斷言,斷言的方式可以傳入一個函數,或者使用自帶的ShouldBeNil、ShouldEqual、ShouldNotBeNil函數等。

2.1. 基本用法

被測代碼:

func StringSliceEqual(a, b []string) bool {
    if len(a) != len(b) {
        return false
    }

    if (a == nil) != (b == nil) {
        return false
    }

    for i, v := range a {
        if v != b[i] {
            return false
        }
    }
    return true
}

測試代碼

import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
)

func TestStringSliceEqual(t *testing.T) {
    Convey("TestStringSliceEqual的描述", t, func() {
        a := []string{"hello", "goconvey"}
        b := []string{"hello", "goconvey"}
        So(StringSliceEqual(a, b), ShouldBeTrue)
    })
}

2.2. 雙層嵌套
import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
)

func TestStringSliceEqual(t *testing.T) {
    Convey("TestStringSliceEqual", t, func() {
        Convey("true when a != nil  && b != nil", func() {
            a := []string{"hello", "goconvey"}
            b := []string{"hello", "goconvey"}
            So(StringSliceEqual(a, b), ShouldBeTrue)
        })

        Convey("true when a == nil  && b == nil", func() {
            So(StringSliceEqual(nil, nil), ShouldBeTrue)
        })
    })
}

內層的 Convey 不需要再傳入 t *testing.T 參數

GoConvey 的更多用法

3. testify

testify 提供了 assert 和 require,讓你可以簡潔地寫出if xxx { t.Fail() }

3.1. assert
func TestSomething(t *testing.T) {

  //斷言相等
  assert.Equal(t, 123, 123, "they should be equal")

  //斷言不相等
  assert.NotEqual(t, 123, 456, "they should not be equal")

  //對於nil的斷言
  assert.Nil(t, object)

  //對於非nil的斷言
  if assert.NotNil(t, object) {
 // now we know that object isn't nil, we are safe to make
 // further assertions without causing any errors
 assert.Equal(t, "Something", object.Value)
  }

3.2. require

require 和 assert 失敗、成功條件完全一致,區別在於 assert 只是返回布爾值(true、false),而 require 不符合斷言時,會中斷當前運行

3.3. 常用的函數
func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
func NotEqual(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool

func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool

func Empty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
func NotEmpty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool

func NoError(t TestingT, err error, msgAndArgs ...interface{}) bool
func Error(t TestingT, err error, msgAndArgs ...interface{}) bool

func Zero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool
func NotZero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool

func True(t TestingT, value bool, msgAndArgs ...interface{}) bool
func False(t TestingT, value bool, msgAndArgs ...interface{}) bool

func Len(t TestingT, object interface{}, length int, msgAndArgs ...interface{}) bool

func NotContains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool
func NotContains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool
func Subset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool)
func NotSubset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool)

func FileExists(t TestingT, path string, msgAndArgs ...interface{}) bool
func DirExists(t TestingT, path string, msgAndArgs ...interface{}) bool

testify 的更多用法

三、Stub/Mock 框架

Golang 有以下 Stub/Mock 框架:

一般來說,GoConvey 可以和 GoStub、GoMock、Monkey 中的一個或多個搭配使用。

Testify 本身有自己的 Mock 框架,可以用自己的也可以和這裡列出來的 Stub/Mock 框架搭配使用。

1. GoStub

GoStub 框架的使用場景很多,依次為:

1.1. 為一個全局變量打樁

假設 num 為被測函數中使用的一個全局整型變量,當前測試用例中假定 num 的值大於 100,比如為 150,則打樁的代碼如下:

stubs := Stub(&num, 150)
defer stubs.Reset()

stubs 是 GoStub 框架的函數接口 Stub 返回的對象,該對象有 Reset 操作,即將全局變量的值恢復為原值。

1.2. 為一個函數打樁

假設我們產品的既有代碼中有下面的函數定義:

func Exec(cmd string, args ...string) (string, error) {
    ...
}

我們可以對 Exec 函數打樁,代碼如下所示:

stubs := StubFunc(&Exec,"xxx-vethName100-yyy", nil)
defer stubs.Reset()

1.3. 為一個過程打樁

當一個函數沒有返回值時,該函數我們一般稱為過程。很多時候,我們將資源清理類函數定義為過程。

我們對過程 DestroyResource 的打樁代碼為:

stubs := StubFunc(&DestroyResource)
defer stubs.Reset()

GoStub 的更多用法以及 GoStub+GoConvey 的組合使用方法

2. GoMock

GoMock 是由 Golang 官方開發維護的測試框架,實現了較為完整的基於 interface 的 Mock 功能,能夠與 Golang 內置的 testing 包良好集成,也能用於其它的測試環境中。GoMock 測試框架包含了 GoMock 包和 mockgen 工具兩部分,其中 GoMock 包完成對樁對象生命周期的管理,mockgen 工具用來生成 interface 對應的 Mock 類源文件。

2.1. 定義一個接口

我們先定義一個打算 mock 的接口 Repository。

Repository 是領域驅動設計中戰術設計的一個元素,用來存儲領域對象,一般將對象持久化在資料庫中,比如 Aerospike,Redis 或 Etcd 等。對於領域層來說,只知道對象在 Repository 中維護,並不 care 對象到底在哪持久化,這是基礎設施層的職責。微服務在啟動時,根據部署參數實例化 Repository 接口,比如 AerospikeRepository,RedisRepository 或 EtcdRepository。

package db

type Repository interface {
    Create(key string, value []byte) error
    Retrieve(key string) ([]byte, error)
    Update(key string, value []byte) error
    Delete(key string) error
}

2.2. 生成 mock 類文件

這下該 mockgen 工具登場了。mockgen 有兩種操作模式:源文件和反射。

源文件模式通過一個包含 interface 定義的文件生成 mock 類文件,它通過 -source 標識生效,-imports 和 -aux_files 標識在這種模式下也是有用的。舉例:

mockgen -source=foo.go [other options]

反射模式通過構建一個程序用反射理解接口生成一個 mock 類文件,它通過兩個非標誌參數生效:導入路徑和用逗號分隔的符號列表(多個 interface)。舉例:

mockgen database/sql/driver Conn,Driver

生成的 mock_repository.go 文件:

// Automatically generated by MockGen. DO NOT EDIT!
// Source: infra/db (interfaces: Repository)

package mock_db

import (
    gomock "github.com/golang/mock/gomock"
)

// MockRepository is a mock of Repository interface
type MockRepository struct {
    ctrl     *gomock.Controller
    recorder *MockRepositoryMockRecorder
}

// MockRepositoryMockRecorder is the mock recorder for MockRepository
type MockRepositoryMockRecorder struct {
    mock *MockRepository
}

// NewMockRepository creates a new mock instance
func NewMockRepository(ctrl *gomock.Controller) *MockRepository {
    mock := &MockRepository{ctrl: ctrl}
    mock.recorder = &MockRepositoryMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockRepository) EXPECT() *MockRepositoryMockRecorder {
    return _m.recorder
}

// Create mocks base method
func (_m *MockRepository) Create(_param0 string, _param1 []byte) error {
    ret := _m.ctrl.Call(_m, "Create", _param0, _param1)
    ret0, _ := ret[0].(error)
    return ret0
}
...

2.3. 使用 mock 對象進行打樁測試2.3.1. 導入 mock 相關的包
import (
    "testing"
    . "github.com/golang/mock/gomock"
    "test/mock/db"
    ...
)

2.3.2. mock 控制器

mock 控制器通過 NewController 接口生成,是 mock 生態系統的頂層控制,它定義了 mock 對象的作用域和生命周期,以及它們的期望。多個協程同時調用控制器的方法是安全的。當用例結束後,控制器會檢查所有剩餘期望的調用是否滿足條件。

控制器的代碼如下所示:

ctrl := NewController(t)
defer ctrl.Finish()

mock 對象創建時需要注入控制器,如果有多個 mock 對象則注入同一個控制器,如下所示:

ctrl := NewController(t)
defer ctrl.Finish()
mockRepo := mock_db.NewMockRepository(ctrl)
mockHttp := mock_api.NewHttpMethod(ctrl)

2.3.3. mock 對象的行為注入

對於 mock 對象的行為注入,控制器是通過 map 來維護的,一個方法對應 map 的一項。因為一個方法在一個用例中可能調用多次,所以 map 的值類型是數組切片。當 mock 對象進行行為注入時,控制器會將行為 Add。當該方法被調用時,控制器會將該行為 Remove。

假設有這樣一個場景:先 Retrieve 領域對象失敗,然後 Create 領域對象成功,再次 Retrieve 領域對象就能成功。這個場景對應的 mock 對象的行為注入代碼如下所示:

mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny)
mockRepo.EXPECT().Create(Any(), Any()).Return(nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil)

objBytes 是領域對象的序列化結果,比如:

obj := Movie{...}
objBytes, err := json.Marshal(obj)
...

當批量 Create 對象時,可以使用 Times 關鍵字:

mockRepo.EXPECT().Create(Any(), Any()).Return(nil).Times(5)

當批量 Retrieve 對象時,需要注入多次 mock 行為:

mockRepo.EXPECT().Retrieve(Any()).Return(objBytes1, nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes2, nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes3, nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes4, nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes5, nil)

GoMock 的更多用法以及 GoStub+GoConvey+GoMock 的組合使用方法

3. Monkey

至此,我們已經知道:

interface 可通過 GoMock 框架打樁

但還有兩個問題比較棘手:

方法(成員函數)無法通過 GoStub 框架打樁,當產品代碼的 OO 設計比較多時,打樁點可能離被測函數比較遠,導致 UT 用例寫起來比較痛過程或函數通過 GoStub 框架打樁時,對產品代碼有侵入性

Monkey 是 Golang 的一個猴子補丁(monkeypatching)框架,在運行時通過彙編語句重寫可執行文件,將待打樁函數或方法的實現跳轉到樁實現,原理和熱補丁類似。通過 Monkey,我們可以解決函數或方法的打樁問題,但 Monkey 不是線程安全的,不要將 Monkey 用於並發的測試中。

Monkey 框架的使用場景很多,依次為:

另有 GoMonkey 框架https://github.com/agiledragon/gomonkey,對比Monkey來說,寫法更簡單,有興趣的讀者可以嘗試使用。

3.1. 為一個函數打樁

Exec 是 infra 層的一個操作函數,實現很簡單,代碼如下所示:

func Exec(cmd string, args ...string) (string, error) {
    cmdpath, err := exec.LookPath(cmd)
    if err != nil {
        fmt.Errorf("exec.LookPath err: %v, cmd: %s", err, cmd)
        return "", infra.ErrExecLookPathFailed
    }

    var output []byte
    output, err = exec.Command(cmdpath, args...).CombinedOutput()
    if err != nil {
        fmt.Errorf("exec.Command.CombinedOutput err: %v, cmd: %s", err, cmd)
        return "", infra.ErrExecCombinedOutputFailed
    }
    fmt.Println("CMD[", cmdpath, "]ARGS[", args, "]OUT[", string(output), "]")
    return string(output), nil
}

Monkey 的 API 非常簡單和直接,我們直接看打樁代碼:

import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
    . "github.com/bouk/monkey"
    "infra/osencap"
)

const any = "any"

func TestExec(t *testing.T) {
    Convey("test has digit", t, func() {
        Convey("for succ", func() {
            outputExpect := "xxx-vethName100-yyy"
            guard := Patch(
             osencap.Exec,
             func(_ string, _ ...string) (string, error) {
                 return outputExpect, nil
             })
            defer guard.Unpatch()
            output, err := osencap.Exec(any, any)
            So(output, ShouldEqual, outputExpect)
            So(err, ShouldBeNil)
        })
    })
}

Patch 是 Monkey 提供給用戶用於函數打樁的 API:

第二個參數是樁函數的函數名,習慣用法是匿名函數或閉包返回值是一個 PatchGuard 對象指針,主要用於在測試結束時刪除當前的補丁3.2. 為一個過程打樁

當一個函數沒有返回值時,該函數我們一般稱為過程。很多時候,我們將資源清理類函數定義為過程。我們對過程 DestroyResource 的打樁代碼為:

guard := Patch(DestroyResource, func(_ string) {

})
defer guard.Unpatch()

3.3. 為一個方法打樁

當微服務有多個實例時,先通過 Etcd 選舉一個 Master 實例,然後 Master 實例為所有實例較均勻的分配任務,並將任務分配結果 Set 到 Etcd,最後 Master 和 Node 實例 Watch 到任務列表,並過濾出自身需要處理的任務列表。

我們用類 Etcd 的方法 Get 來模擬獲取任務列表的功能,入參為 instanceId:

type Etcd struct {

}

func (e *Etcd) Get(instanceId string) []string {
    taskList := make([]string, 0)
    ...
    return taskList

我們對 Get 方法的打樁代碼如下:

var e *Etcd
guard := PatchInstanceMethod(
 reflect.TypeOf(e),
 "Get",
 func(_ *Etcd, _ string) []string {
  return []string{"task1", "task5", "task8"}
 })
defer guard.Unpatch()

PatchInstanceMethod API 是 Monkey 提供給用戶用於方法打樁的 API:

返回值是一個 PatchGuard 對象指針,主要用於在測試結束時刪除當前的補丁

Monkey 的更多用法以及 Monkey 和前幾種框架的組合使用方法

四、Mock 場景最佳實踐1. 實例函數 Mock:Monkey

Monkey 用於對依賴的函數進行 Mock 替換,從而可以完成僅針對當前模塊的單元測試。

例子:

test包是真實的函數mock_test包是即將用於 mock 的函數

test.go:

package test

import "fmt"

func PrintAdd(a, b uint32) string {
 return fmt.Sprintf("a:%v+b:%v", a, b)
}


type SumTest struct {
}

func (*SumTest)PrintSum(a, b uint32) string {
 return fmt.Sprintf("a:%v+b:%v", a, b)
}

mock_test.go:

package mock_test

import "fmt"
import "test/24_mock/test"

func PrintAdd(a, b uint32) string {
 return fmt.Sprintf("a:%v+b:%v=%v", a, b, a+b)
}

//對應test文件夾下的PrintSum
func PrintSum(_ *test.SumTest, a, b uint32) string {
 return fmt.Sprintf("a:%v+b:%v=%v", a, b,a+b)
}

main.go:

func test1() {
     monkey.Patch(test.PrintAdd, mock_test.PrintAdd)
     p := test.PrintAdd(1, 2)
     fmt.Println(p)
     monkey.UnpatchAll() //解除所有替換
     p = test.PrintAdd(1, 2)
     fmt.Println(p)
}

func test2() {
     structSum := &test.SumTest{}
     //para1:獲取實例的反射類型,para2:被替換的方法名,para3:替換方法
     monkey.PatchInstanceMethod(reflect.TypeOf(structSum), "PrintSum", mock_test.PrintSum)
     p := structSum.PrintSum(1, 2)
     fmt.Println(p)
     monkey.UnpatchAll() //解除所有替換
     p = structSum.PrintSum(1, 2)
     fmt.Println(p)
}

2. 未實現函數 Mock:GoMock

假設場景:Company公司、Person人。

公司內部的人繼承了Talker討論者接口,擁有SayHello說話的方法。

假如現在要測試這個場景,在所有類都實現的情況下,測試應該是這樣的:

//正常測試
func TestCompany_Meeting(t *testing.T) {
//直接調用Person類的New方法,創建一個Person對象
 talker := NewPerson("小微", "語音服務助手")
 company := NewCompany(talker)
 t.Log(company.Meeting("lyt", "intern"))
}

但現在Person類並未實現,則可以通過 GoMock 工具來模擬一個Person對象。

定義一個Talker.go

package pojo

type Talker interface {
   SayHello(word, role string) (response string)
}

根據該接口,用mockgen命令生成一個 Mock 對象

mockgen [-source] [-destination] [-package] ... Talker.go

接著進行測試用例的編寫:

NewController(): 新建 Mock 控制器NewMockXXX(): 新建 Mock 對象,這裡是調用 NewMockTalker()talker.EXPECT().XXX().XXX()..:撰寫一些斷言測試
//通過Mock測試
func TestCompany_Meeting2(t *testing.T) {

   //新建Mock控制器
   ctrl := gomock.NewController(t)
   //新建Mock對象-Talker
   talker := mock_pojo.NewMockTalker(ctrl)

   //斷言
   talker.EXPECT().SayHello(gomock.Eq("震天嚎"), gomock.Eq("學生")).Return("Hello Faker(身份:學生), welcome to GoLand IDE. My name is 震天嚎")
   //mock對象傳入方法
   company := NewCompany(talker)

   //Pass的例子
   t.Log(company.Meeting("震天嚎", "學生"))

   //報錯的例子
   //t.Log(company.Meeting("小白", "學生"))

}

3. 系統內置函數 Mock:Monkey

monkey.Patch(json.Unmarshal, mockUnmarshal),用 Monkey 的 patch 來 mock 系統內置函數

func mockUnmarshal(b []byte, v interface{}) error{
   v = &Common.LoginMessage{
      UserId: 1,
      UserName: "admin",
      UserPwd: "admin",
   }
   return nil
}

如果需要取消替換,可以使用

monkey.UnPatch(target interface{}) //解除單個Patch
monkey.UnPatchAll()        //解除所有Patch

4. 資料庫行為 Mock
func TestSql(t *testing.T) {
   db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
   if err != nil {
      fmt.Println("fail to open sqlmock db: ", err)
   }
   defer db.Close()
   rows := sqlmock.NewRows([]string{"id", "pwd"}).
      AddRow(1, "apple").
      AddRow(2, "banana")
   mock.ExpectQuery("SELECT id, pwd FROM users").WillReturnRows(rows)
   res, err := db.Query("SELECT id, pwd FROM users")
   if err != nil {
      fmt.Println("fail to match expected sql.")
      return
   }
   defer res.Close()
   for res.Next() {
      var id int
      var pwd string
      res.Scan(&id, &pwd)
      fmt.Printf("Sql Result:\tid = %d, password = %s.\n",id, pwd)
   }
   if res.Err() != nil {
      fmt.Println("Result Return Error!", res.Err())
   }
}

5. 伺服器行為 Mock:Monkey

使用 net/http/httptest 模擬伺服器行為

func TestHttp(t *testing.T) {
   handler := func(w http.ResponseWriter, r *http.Request) {
      io.WriteString(w, "{ \"status\": \"expected service response\"}")
   }

   req := httptest.NewRequest("GET", "https://test.net", nil)
   w := httptest.NewRecorder()
   handler(w, req)//處理該Request

   resp := w.Result()
   body, _ := ioutil.ReadAll(resp.Body)
   fmt.Println(resp.StatusCode)
   fmt.Println(resp.Header.Get("Content-Type"))
   fmt.Println(string(body))
}

這裡只使用了 Monkey 的 Patch 進行簡單測試,但在更一般的情況下,更多的函數還是通過實例函數來編寫的,對這部分函數要用PatchInstanceMethod才可以進行替換。

func PatchInstanceMethod(target reflect.Type, methodName string, replacement interface{})接收三個參數:

reflect.Tpye通過新建一個待測實例對象,調用 reflect 包的TypeOf()方法就可以得到

實現如下:

var ts *utils.Transfer
monkey.PatchInstanceMethod(reflect.TypeOf(ts), "WritePkg", func(_ *utils.Transfer, _ []byte) error {
      return nil
})

假設有如下一個函數ServerProcessLogin,用於接收用戶名密碼,向當前連接的伺服器請求登陸,測試如下:

func TestServerProcessLogin(t *testing.T) {
   mess := &Common.Message{
      Type: Common.LoginMesType,
      Data: "default",
   }
   user := &UserProcess{
      Conn: nil,
   }

   //對涉及到的單元以外系統函數打Patch
   monkey.Patch(json.Unmarshal, mockUnmarshal)
   monkey.Patch(json.Marshal, mockMarshal)

   //單元測試不涉及實際伺服器,故對實例函數Login,WritePkg打Patch
   var udao *model.UserDao
   monkey.PatchInstanceMethod(reflect.TypeOf(udao), "Login", func(_ *model.UserDao, _ int, _ string) (*Common.User,error) {
      return &Common.User{
         UserId: 1,
         UserName: "admin",
         UserPwd: "admin",
      }, nil
   })

   var ts *utils.Transfer
   monkey.PatchInstanceMethod(reflect.TypeOf(ts),"WritePkg", func(_ *utils.Transfer, _ []byte) error {
      return nil
   })

   //執行測試
   convey.Convey("Test Server Login.", t, func() {
      err := user.ServerProcessLogin(mess)
      convey.So(err, convey.ShouldBeNil)
   })

   monkey.UnpatchAll()
   return
}

//用於替換的函數
func mockUnmarshal(b []byte, v interface{}) error{
   v = &Common.LoginMessage{
      UserId: 1,
      UserName: "admin",
      UserPwd: "admin",
   }
   return nil
}

func mockMarshal(v interface{}) ([]byte, error) {
   var rer = []byte{
      'a','d','m','i','n',
   }
   return rer, nil
}

五、具體案例:聊天室1. 概覽

該項目是一個具有登錄、查看在線用戶、私聊、群聊等功能的命令行聊天室 Demo。

項目分為 Client、Server 子項目,都通過 model、Controllor(Processor)、View(Main)來進行功能劃分。還有一個 Common 包放置通用性的工具類。

├─Client
│ ├─main
│ ├─model
│ ├─processor
│ └─utils
├─Common
└─Server
├─main
├─model
├─processor
└─utils

預期目的:對實現的功能模塊補充單元測試代碼,度量確保每一個模塊的功能的正確性、完整性、健壯性,並在未來修改代碼後也能第一時間自測驗收。

單元測試應包括模塊接口測試、模塊局部數據結構測試、模塊異常處理測試。

對於接口測試,應對接口的傳入參數測試樣例設計進行全面的考察,判斷每一個參數是否有是有必要的,參數間有沒有冗餘,進入函數體前引用的指針是否有錯等等。

對於局部數據結構測試,應檢查局部數據結構是為了保證臨時存儲在模塊內的數據在程序執行過程中完整性、正確性。局部數據結構往往是錯誤的根源,應仔細設計測試用例。

對於異常處理,主要有如下幾種常見錯誤:

以上幾種錯誤,都是模塊中經常會出現的錯誤,要針對這些錯誤來進行邊界條件測試檢查,只有異常處理機制正確,日後軟體的維護和迭代才會更加高效。

在本案例中,Model 層對服務層提供的接口不多,就WritePkg,ReadPkg兩個核心函數,在服務層對其進行封裝抽象為具體的業務邏輯。由於涉及網絡連接,所以對其進行的測試必須編寫樁函數。在服務層,涉及到對多個網絡連接調用、資料庫調用其它模塊依賴,所以也要為其進行 Mock。

由於涉及 Mock 和樁函數編寫,可以使用GoStub、Monkey兩個包進行這些工作,它們較簡潔地實現了很多實用的測試方式,只需要用戶編寫依賴的接口文件、用於替換的 Mock 函數,就可以僅在測試過程中替換掉系統函數或者其它依賴的功能模塊,使得單元測試起到它應有的作用。

2. Model 層、資料庫相關測試

由於是單元測試,所以需要獲取一個 Mock 資料庫實例,測試增刪改查 SQL 語句是否可執行。userDao_test.go代碼如下:

const (
   sql1 = "SELECT id, pwd FROM users"
   sql2 = "DELETE FROM users where id > 600 and id < 700"
   sql3 = "update users set pwd = newPwd where id = 1 and id = 2"
   sql4 = "INSERT INTO users (id, pwd) VALUES (405, 'Lyt')"
)

func TestGetUserById(t *testing.T) {
   db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
   if err != nil {
      fmt.Println("fail to open sqlmock db: ", err)
   }
   defer db.Close()
   rows1 := sqlmock.NewRows([]string{"id", "pwd"}).
      AddRow(1, "apple").
      AddRow(2, "banana")
   rows2 := sqlmock.NewRows([]string{"id", "pwd"}).
      AddRow(601, "goland").
      AddRow(602, "java")
   rows3 := sqlmock.NewRows([]string{"id", "pwd"}).
      AddRow(1, "newPwd").
      AddRow(2, "newPwd")
   rows4 := sqlmock.NewRows([]string{"id", "pwd"}).
      AddRow(405, "Lyt")

   mock.ExpectQuery(sql1).WillReturnRows(rows1)
   mock.ExpectQuery(sql2).WillReturnRows(rows2)
   mock.ExpectQuery(sql3).WillReturnRows(rows3)
   mock.ExpectQuery(sql4).WillReturnRows(rows4)

   assert.New(t)
   var tests = []struct{
      inputSql string
      expected interface{}
   } {
      {sql1,nil},
      {sql2,nil},
      {sql3,nil},
      {sql4, nil},
   }

   for _, test := range tests {
      res, err := db.Query(test.inputSql)
      assert.Equal(t, err, test.expected)

      for res.Next() {
         var id int
         var pwd string
         res.Scan(&id, &pwd)
         fmt.Printf("Sql Result:\tid = %d, password = %s.\n",id, pwd)
      }
      assert.Equal(t, res.Err(), test.expected)
   }
}

3. 私聊功能測試

由於涉及底層資料庫交互時需要發送 JSON 轉碼字符串(WritePkg函數),因此將其 Mock 處理,只需關注本函數邏輯是否正確即可。smsProcess_test.go如下:

func TestSmsProcess_SendOnePerson(t *testing.T) {
   var conn net.Conn
   tf := &utils.Transfer{
      Conn: conn,
   }
   monkey.PatchInstanceMethod(reflect.TypeOf(tf), "WritePkg", func(_ *utils.Transfer,_ []byte) error{
      return nil
   })
   convey.Convey("test send one person:", t, func() {
      err := tf.WritePkg([]byte{})
      convey.So(err, convey.ShouldBeNil)
      fmt.Println("OK.")
   })
}

4. 登錄功能測試

登錄涉及伺服器連接操作,伺服器的連接邏輯可通過httptest包來進行檢測,Mock 一個 HTTP 連接,示例代碼如下:

func TestHttp(t *testing.T) {
   handler := func(w http.ResponseWriter, r *http.Request) {
      // here we write our expected response, in this case, we return a
      // JSON string which is typical when dealing with REST APIs
      io.WriteString(w, "{ \"status\": \"expected service response\"}")
   }

   req := httptest.NewRequest("GET", "https://test.net", nil)
   w := httptest.NewRecorder()
   handler(w, req)

   resp := w.Result()
   body, _ := ioutil.ReadAll(resp.Body)
   fmt.Println(resp.StatusCode)
   fmt.Println(resp.Header.Get("Content-Type"))
   fmt.Println(string(body))
}

為登錄模塊編寫用於測試替換的函數以及單元測試主體,userProcess_test.go代碼如下:

func mockUnmarshal(b []byte, v interface{}) error {
   v = &Common.LoginMessage{
      UserId:   1,
      UserName: "admin",
      UserPwd:  "admin",
   }
   return nil
}

func mockMarshal(v interface{}) ([]byte, error) {
   var rer = []byte{
      'a', 'd', 'm', 'i', 'n',
   }
   return rer, nil
}

func TestServerProcessLogin(t *testing.T) {
   mess := &Common.Message{
      Type: Common.LoginMesType,
      Data: "default",
   }
   user := &UserProcess{
      Conn: nil,
   }

   //對涉及到的單元以外系統函數打Patch
   monkey.Patch(json.Unmarshal, mockUnmarshal)
   monkey.Patch(json.Marshal, mockMarshal)

   //對實例函數打Patch
   var udao *model.UserDao
   monkey.PatchInstanceMethod(reflect.TypeOf(udao), "Login", func(_ *model.UserDao, _ int, _ string) (*Common.User, error) {
      return &Common.User{
         UserId:   1,
         UserName: "admin",
         UserPwd:  "admin",
      }, nil
   })

   var ts *utils.Transfer
   monkey.PatchInstanceMethod(reflect.TypeOf(ts), "WritePkg", func(_ *utils.Transfer, _ []byte) error {
      return nil
   })
   //執行測試
   convey.Convey("Test Server Login.", t, func() {
      err := user.ServerProcessLogin(mess)
      convey.So(err, convey.ShouldBeNil)
   })
   monkey.UnpatchAll()
   return
}

5. 工具類測試
utils_test.go
func mockRead(conn net.Conn, _ []byte) (int, error) {
   return 4, nil
}

func mockMarshal(v interface{}) ([]byte, error) {
   return []byte{'a', 'b', 'c', 'd'}, nil
}

func mockUnmarshal(data []byte, v interface{}) error {
   return nil
}

func TestTransfer_ReadPkg(t *testing.T) {

   monkey.Patch(net.Conn.Read, mockRead)
   monkey.Patch(json.Marshal, mockMarshal)
   monkey.Patch(json.Unmarshal, mockUnmarshal)

   listen, _ := net.Listen("tcp", "localhost:8888")
   defer listen.Close()
   go net.Dial("tcp", "localhost:8888")
   var c net.Conn
   for {

      c, _ = listen.Accept()
      if c != nil {
         break
      }
   }


   transfer := &Transfer{
      Conn: c,
      Buf : [8096]byte{'a', 'b', 'c', 'd'},
   }
   convey.Convey("test ReadPkg", t, func() {
      mes, err := transfer.ReadPkg()
      convey.So(err, convey.ShouldBeNil)
      convey.So(mes, convey.ShouldEqual, "ab")
   })
   monkey.UnpatchAll()

}

func TestTransfer_WritePkg(t *testing.T) {

   monkey.Patch(json.Marshal, mockMarshal)
   monkey.Patch(json.Unmarshal, mockUnmarshal)
   transfer := &Transfer{
      Conn: nil,
      Buf : [8096]byte{},
   }
   convey.Convey("test ReadPkg", t, func() {
      err := transfer.WritePkg([]byte{'a', 'b'})
      convey.So(err, convey.ShouldBeNil)
   })
   monkey.UnpatchAll()
}

6. 項目總結

在編寫樁模塊時會發現,模塊之間的調用關係在工程規模並不大的本案例中,也依然比較複雜,需要開發相應樁函數,代碼量會增加許多,也會消耗一些開發人員的時間,因此反推到之前流程的開發實踐中,可以得出結論就是提高模塊的內聚度可簡化單元測試,如果每個模塊只能完成一個,所需測試用例數目將顯著減少,模塊中的錯誤也更容易發現。

Go 單元測試框架是相當易用的,其它的第三方庫基本都是建立在 testing 原生框架的基礎上進行的增補擴充,在日常開發中,原生包可以滿足基本需求,但同樣也有缺陷,原生包不提供斷言的語法使得代碼中的這種片段非常多:

if err != nil{
 //...
}

所以引入了 convey、assert 包的斷言語句,用於簡化判斷邏輯,使得程序更加易讀。

在完成項目單測時,遇到了不少問題,比較重要的比如由於架構分層不夠清晰,還是有部分耦合代碼,導致單測時需要屏蔽的模塊太多,代碼寫起來不便。因此還是需要倒推到開發模塊之前,就要設計更好的結構,在開發的過程中遵循相應的規則,通過測試先行的思想,使開發的工程具有更好的可測試性。

開發過程中遇到的場景肯定不局限於本文所討論的範圍,有關更豐富的最佳實踐案例可以參照:

六、結語1. 實踐小結

單元測試大多是由開發人員進行編寫,本篇文章旨在指引,不在於面面俱到,具體的單元測試框架的使用語法,開發同學可以自行 Google。

以測試的角度,推行單元測試是不易的,最佳的方式莫過於開發人員,在一定的指引之後,以實際項目出發進行實踐,然後自行總結具體的 case,有針對性、有感染力進行內部分享,測試同學及時提供測試用例的指引和規範的約束。

2. 特別鳴謝兩位實習生羅宇韜和鍾梓軒,在暑假實習期間,協助整理了 Golang 單測的代碼示例。3. 推薦閱讀

相關焦點

  • Golang後臺單元測試實踐
    Why單元測試新功能的增加,代碼複雜性的提高,優化代碼的需要,或新技術的出現都會導致重構代碼的需求。在沒有寫單元測試的情況下,對代碼進行大規模修改,是一件不敢想像的事情,因為寫錯的概率實在太大了。而如果原代碼有單元測試,即使修改了代碼單測依然通過,說明沒有破壞程序正確性,一點都不慌!
  • ​手把手教你如何進行 Golang 單元測試
    作者:stevennzhou,騰訊 PCG 前端開發工程師本篇是對單元測試的一個總結,通過完整的單元測試手把手教學,能夠讓剛接觸單元測試的開發者從整體上了解一個單元測試編寫的全過程。最終通過兩個問題,也能讓寫過單元測試的開發者收穫單測執行時的一些底層細節知識。引入隨著工程化開發在司內大力的推廣,單元測試越來越受到廣大開發者的重視。在學習的過程中,發現網上針對 Golang 單元測試大多從理論角度出發介紹,缺乏完整的實例說明,晦澀難懂的 API 讓初學接觸者難以下手。
  • Task12: 單元測試
    11.單元測試本節代碼樣例見code/utest文件夾在日常開發中,我們通常需要針對現有的功能進行單元測試,以驗證開發的正確性。在go標準庫中有一個叫做testing的測試框架,可以進行單元測試,命令是go test xxx。測試文件通常是以xx_test.go命名,放在同一包下面。
  • Golang 單元測試:有哪些誤區和實踐?
    阿里妹導讀:單元測試作為開發的有力武器,應該在軟體開發的各個流程中發揮它的價值。
  • Golang 性能測試 (1)
    編寫完代碼除了跑必要的單元測試外
  • golang 調試分析的高階技巧
    golang 作為一門現代化語音,出生的時候就自帶完整的 debug 手段:golang tools 是直接集成在語言工具裡,支持內存分析,cpu分析,阻塞鎖分析等;delve,gdb 作為最常用的 debug 工具,讓你能夠更深入的進入程序調試;delve 當前是最友好的 golang 調試程序,ide 調試其實也是調用 dlv 而已,比如 goland;單元測試的設計深入到語言設計級別
  • Golang 調試分析的高階技巧
    golang 作為一門現代化語音,出生的時候就自帶完整的 debug 手段:golang tools 是直接集成在語言工具裡,支持內存分析,cpu分析,阻塞鎖分析等;delve,gdb 作為最常用的 debug 工具,讓你能夠更深入的進入程序調試;delve 當前是最友好的 golang 調試程序,ide 調試其實也是調用 dlv 而已,比如 goland;單元測試的設計深入到語言設計級別
  • golang性能優化及測試用例編寫
    前言在日常開發中,我們有時候會寫到測試用例,但是對於新手來說,並不知道測試用例該如何編寫,也不知道測試用例的用途。
  • Golang 在即刻後端的實踐
    在此過程中我們可以驗證在同一個業務上兩種語言的差異,並且可以完善 Go 相關的配套設施。改造成果截至目前,即刻部分非核心服務已經通過 Go 重寫並上線。相比原始服務,新版服務的開銷顯著降低:接口響應時長降低 50%舊服務響應時間
  • golang 編程風格最佳實踐
    /doc/effective_go.htmluber golang 代碼規範 https://github.com/uber-go/guideuber golang 代碼規範中文 https://github.com/xxjwxc/uber_go_guide_cn代碼目錄規範
  • Golang 中的四種類型轉換總結
    收錄於話題 #golang 本文詳盡地介紹了在 Golang 進行類型轉換的多種方法,建議閱讀。
  • golang mod 入門
    go modules 是 golang 1.11 新加的特性。現在1.12 已經發布了,是時候用起來了。Modules官方定義為:模塊是相關Go包的集合。modules是原始碼交換和版本控制的單元。 go命令直接支持使用modules,包括記錄和解析對其他模塊的依賴性。modules替換舊的基於GOPATH的方法來指定在給定構建中使用哪些源文件。
  • golang 基準測試和性能測試總結
    1、測試類型在*_test.go文件中有三種類型的函數,單元測試函數、基準測試函數和示例函數。
  • 2021年軟體測試工具總結——單元測試工具
    單元測試主要關注獨立模塊的功能正確性,目的是確保每個單元都按照預期的方式運行。要進行單元測試,開發人員需要編寫測試代碼。單元測試有手動和自動化測試兩種類型,自動化通常是首選的方法,可以為開發人員節省大量的時間和精力。
  • Golang的字符編碼與regexp
    byte 是最簡單的字節類型(uint8),string 是固定長度的字節序列,其定義和初始化在 https://github.com/golang/go/blob/master/src/runtime/string.go,可以看到 string 底層就是使用 []byte 實現的:rune 類型則是 Golang 中用來處理 UTF-8 編碼的類型,實際類型為 int32,存儲的值是字符的 Unicode
  • 走進Golang之編譯器原理
    首先先來認識一下go的代碼源文件分類命令源碼文件:簡單說就是含有 main 函數的那個文件,通常一個項目一個該文件,我也沒想過需要兩個命令源文件的項目測試源碼文件:就是我們寫的單元測試的代碼,都是以 _test.go 結尾庫源碼文件:沒有上面特徵的就是庫源碼文件,像我們使用的很多第三方包都屬於這部分go build 命令就是用來編譯這其中的
  • Android單元測試實踐
    為什麼要引入單元測試  一般來說我們都不會寫單元測試,為什麼呢?因為要寫多餘的代碼,而且還要進行一些學習,入門有些門檻,所以一般在工程中都不會寫單元測試。那麼為什麼我決定要寫單元測試。  這篇文章看完並不會讓你完全掌握單元測試,但是會給你在單元測試的開始有一個好的指引  大大提高工作效率  單元的概念比較模糊,可以是一個方法,可以是一個時機,但是不是一整套環節,一整套環節那就是集成測試了。為什麼說大大提高了工作效率。
  • Golang 性能分析工具簡要介紹
    接下來開始介紹比較枯燥的基礎知識了,要注意聽啦~pprof 提供了三種方式的使用Benchmark基於基準測試的 pprof,對於已經寫好的算法包來說,可以利用基準測試和 pprof 來校驗算法是否高效、內存消耗是否合理。
  • Golang基準測試
    目錄1、基本使用2、bench 的工作原理3、傳入 cpu num 進行測試4、count 多次運行基準測試5、benchtime 指定運行秒數6、ResetTimer 重置定時器7、benchmem 展示內存消耗1、基本使用
  • golang標準庫template
    "text/template")type Person struct { Name string當然,也可以是一個命令,例如:計算表達式的值{{.}}、{{.Name}},或者是一個函數調用或者方法調用。可以使用管道符號|連結多個命令,用法和unix下的管道類似:|前面的命令將運算結果(或返回值)傳遞給後一個命令的最後一個位置。需要注意的是,並非只有使用了|才是pipeline。