文末有彩蛋。
作者: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. testifytestify 提供了 assert 和 require,讓你可以簡潔地寫出if xxx { t.Fail() }
3.1. assertfunc 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. requirerequire 和 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{}) booltestify 的更多用法
三、Stub/Mock 框架Golang 有以下 Stub/Mock 框架:
一般來說,GoConvey 可以和 GoStub、GoMock、Monkey 中的一個或多個搭配使用。
Testify 本身有自己的 Mock 框架,可以用自己的也可以和這裡列出來的 Stub/Mock 框架搭配使用。
1. GoStubGoStub 框架的使用場景很多,依次為:
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. GoMockGoMock 是由 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:MonkeyMonkey 用於對依賴的函數進行 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:Monkeymonkey.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. 資料庫行為 Mockfunc 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. 推薦閱讀