每個嚴謹的項目都應該有單元測試,發現程序中的問題,保障程序現在和未來的正確性。我們新加入一個項目時,常被要求給現有代碼加一些單元測試;自己的代碼寫到一定程度後,也希望加一些單元測試看看有沒有問題。這時往往發現沒法在不改動現有代碼的情況下添加單元測試,這就引出一個很尷尬的問題~~ 不是所有代碼都可以方便測試的~~

比如這個例子:

func AddPerson(name string) error {
db, _ := sqlx.Open("mysql", "...dsn...")
_, err := db.Exec("INSERT INTO person (name) VALUES (?)", name)
return err
}

在函數中寫死了 MySQL 的連接方式,硬要寫單元測試的話,會污染生產環境的資料庫。

還有其它一些情況,比如從很多外部依賴獲取數據並處理,輸入和結果過於複雜。

一般來說,沒法測試的代碼都是不太好的代碼,它們往往沒有合理組織,不靈活,甚至錯誤百出。直接說明怎樣的代碼可方便測試有點難,但我們可以通過看看各種情況下怎樣合理地測試,反推怎樣寫出方便測試的代碼。

本文主要說明 Golang 單元測試用到的工具以及一些方法,包括:

  • 使用 Table Driven 的方式寫測試代碼
  • 使用 testify/assert 簡化條件判斷
  • 使用 testify/mock 隔離第三方依賴或者複雜調用
  • mock http request
  • stub redis
  • stub MySQL

使用 Table Driven 的方式寫測試代碼

測試一個 routine 分幾個步驟:準備數據,調用 routine,判斷返回。還要測試不同的情況。如果每種情況都手工寫一次代碼的話,會很繁瑣,使用 Table Driven 的方式能讓測試代碼看起來簡潔易懂不少。

比如要測試一個取模運算的 routine:

func Mod(a, b int) (r int, err error) {
if b == 0 {
return 0, fmt.Errorf("mod by zero")
}
return a%b, nil
}

可以這樣測試:

func TestMod(t *testing.T) {
tests := []struct {
a int
b int
r int
hasErr bool
}{
{a: 42, b: 9, r: 6, hasErr: false},
{a: -1, b: 9, r: 8, hasErr: false},
{a: -1, b: -9, r: -1, hasErr: false},
{a: 42, b: 0, r: 0, hasErr: true},
}

for row, test := range tests {
r, err := Mod(test.a, test.b)
if test.hasError {
if err == nil {
t.Errorf("should have error, row: %d", row)
}
continue
}
if err != nil {
t.Errorf("should not have error, row: %d", row)
}
if r != test.r {
t.Errorf("r is expected to be %d but now %d, row: %d", test.r, r, row)
}
}
}

以後有新的邊緣情況,也可以很方便地添加到測試用例。

使用 testify/assert 簡化條件判斷

上面例子中很多 if xxx { t.Errorf(...) } 的代碼,複雜,語義不清晰。使用 stretchr/testify 的 assert 可以簡化這些代碼。上面的 for 循環可以簡化成下面這樣:

import "github.com/stretchr/testify/assert"

for row, test := range tests {
r, err := Mod(test.a, test.b)
if test.hasError {
assert.Error(t, err, "row %d", row)
continue
}
assert.NoError(t, err, "row %d", row)
assert.Equal(t, test.r, r, "row %d", row)
}

除了 Equal Error NoError,assert 還提供其它很多意義明確的判斷方法,如:NotNil, NotEmpty, HTTPSucess 等。

使用 testify/mock 隔離第三方依賴或者複雜調用

很多時候,測試環境不具備 routine 執行的必要條件。比如查詢 consul 裏的 KV,即使準備了測試consul,也要先往裡面塞測試數據,十分麻煩。又比如查詢 AWS S3 的文件列表,每個開發人員一個測試 bucket 太混亂,大家用同一個測試 bucket 更混亂。必須找個方式偽造 consul client 和 AWS S3 client。通過偽造 consul client 查詢 KV 的方法,免去連接 consul, 直接返回預設的結果。

首先考慮一下怎樣偽造 client。假設 client 被定義為 var client *SomeClient。當 SomeClient 是 type SomeClient struct{...} 時,我們永遠沒法在 test 環境修改 client 的行為。當是 type SomeClient interface{...} 時,我們可以在測試代碼中實現一個符合 SomeClient interface 的 struct,用這個 struct 的實例替換原來的 client。

假設一個 IP 限流程序從 consul 獲取閾值並更新:

type SettingGetter interface {
Get(key string) ([]byte, error)
}

type ConsulKV struct {
kv *consul.KV
}

func (ck *ConsulKV) Get(key string) (value []byte, err error) {
pair, _, err := ck.kv.Get(key, nil)
if err != nil {
return nil, err
}
return pair.Value, nil
}

type IPLimit struct {
Threshold int64
SettingGetter SettingGetter
}

func (il *IPLimit) UpdateThreshold() error {
value, err := il.SettingGetter.Get(KeyIPRateThreshold)
if err != nil {
return err
}

threshold, err := strconv.Atoi(string(value))
if err != nil {
return err
}

il.Threshold = int64(threshold)
return nil
}

因為 consul.KV 是個 struct,沒法方便替換,而我們只用到它的 Get 功能,所以簡單定義一個 SettingGetter,ConsulKV 實現了這個介面,IPLimit 通過 SettingGetter 獲得值,轉換並更新。

在測試的時候,我們不能使用 ConsulKV,需要偽造一個 SettingGetter,像下面這樣:

type MockSettingGetter struct {}

func (m *MockSettingGetter) Get(key string) ([]byte, error) {
if key == "threshold" {
return []byte("100"), nil
}
if key == "nothing" {
return nil, fmt.Errorf("notfound")
}
...
}

ipLimit := &IPLimit{SettingGetter: &MockSettingGetter{}}
// ... test with ipLimit

這樣的確可以隔離 test 對 consul 的訪問,但不方便 Table Driven。可以使用 testfiy/mock 改造一下,變成下面這樣子:

import "github.com/stretchr/testify/mock"

type MockSettingGetter struct {
mock.Mock
}

func (m *MockSettingGetter) Get(key string) (value []byte, err error) {
args := m.Called(key)
return args.Get(0).([]byte), args.Error(1)
}

func TestUpdateThreshold(t *testing.T) {
tests := []struct {
v string
err error
rs int64
hasErr bool
}{
{v: "1000", err: nil, rs: 1000, hasErr: false},
{v: "a", err: nil, rs: 0, hasErr: true},
{v: "", err: fmt.Errorf("consul is down"), rs: 0, hasErr: true},
}

for idx, test := range tests {
mockSettingGetter := new(MockSettingGetter)
mockSettingGetter.On("Get", mock.Anything).Return([]byte(test.v), test.err)

limiter := &IPLimit{SettingGetter: mockSettingGetter}
err := limiter.UpdateThreshold()
if test.hasErr {
assert.Error(t, err, "row %d", idx)
} else {
assert.NoError(t, err, "row %d", idx)
}
assert.Equal(t, test.rs, limiter.Threshold, "thredshold should equal, row %d", idx)
}
}

testfiy/mock 使得偽造對象的輸入輸出值可以在運行時決定。更多技巧可看 testify/mock 的文檔。

再說到上面提到的 AWS S3,AWS 的 Go SDK 已經給我們定義好了 API 的 interface,每個服務下都有個 xxxiface 目錄,比如 S3 的是 github.com/aws/aws-sdk-go/service/s3/s3iface,如果查看它的源碼,會發現它的 API interface 列了一大堆方法,將這幾十個方法都偽造一次而實際中只用到一兩個顯得很蠢。要想沒那麼蠢,一個方法是將 S3 的 API 像上面那樣再封裝一下,另一個方法可以像下面這樣:

import (
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3iface"
)

type MockS3API struct {
s3iface.S3API
mock.Mock
}

func (m *MockS3API) ListObjects(input *s3.ListObjectsInput) (*s3.ListObjectsOutput, error) {
args := m.Called(input)
return args.Get(0).(*s3.ListObjectsOutput), args.Error(1)
}

struct 裏內嵌一個匿名 interface,免去定義無關方法的苦惱。

mock http request

單元測試中還有個難題是如何偽造 HTTP 請求的結果。如果像上面那樣封裝一下,可能會漏掉一些極端情況的測試,比如連接網路出錯,失敗的狀態碼。Golang 有個 httptest 庫,可以在 test 時創建一個 server,讓 client 連上 server。這樣做會有點繞,事實上 Golang 的 http.Client 有個 Transport 成員,輸入輸出都通過它,通過篡改 Transport 就可以返回我們需要的數據。

以一段獲得本機外網 IP 的代碼為例:

type IPApi struct {
Client *http.Client
}

// MyIP return public ip address of current machine
func (ia *IPApi) MyIP() (ip string, err error) {
resp, err := ia.Client.Get(MyIPUrl)
if err != nil {
return "", err
}
defer resp.Body.Close()

body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}

if resp.StatusCode != 200 {
return "", fmt.Errorf("status code: %d", resp.StatusCode)
}

infos := make(map[string]string)
err = json.Unmarshal(body, &infos)
if err != nil {
return "", err
}

ip, ok := infos["ip"]
if !ok {
return "", ErrInvalidRespResult
}
return ip, nil
}

可以這樣寫單元測試:

// RoundTripFunc .
type RoundTripFunc func(req *http.Request) *http.Response

// RoundTrip .
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}

// NewTestClient returns *http.Client with Transport replaced to avoid making real calls
func NewTestClient(fn RoundTripFunc) *http.Client {
return &http.Client{
Transport: RoundTripFunc(fn),
}
}

func TestMyIP(t *testing.T) {
tests := []struct {
code int
text string
ip string
hasError bool
}{
{code: 200, text: "{"ip":"1.2.3.4"}", ip: "1.2.3.4", hasError: false},
{code: 403, text: "", ip: "", hasError: true},
{code: 200, text: "abcd", ip: "", hasError: true},
}

for row, test := range tests {
client := NewTestClient(func(req *http.Request) *http.Response {
assert.Equal(t, req.URL.String(), MyIPUrl, "ip url should match, row %d", row)
return &http.Response{
StatusCode: test.code,
Body: ioutil.NopCloser(bytes.NewBufferString(test.text)),
Header: make(http.Header),
}
})
api := &IPApi{Client: client}

ip, err := api.MyIP()
if test.hasError {
assert.Error(t, err, "row %d", row)
} else {
assert.NoError(t, err, "row %d", row)
}
assert.Equal(t, test.ip, ip, "ip should equal, row %d", row)
}
}

stub redis

假如程序裏用到 Redis,要偽造一個 Redis Client 用之前的辦法也是可以的,但因為有 miniredis 的存在,我們有更好的辦法。miniredis 是在 Golang 程序中運行的 Redis Server,它實現了大部分原裝 Redis 的功能,測試的時候 miniredis.Run() 然後將 Redis Client 連向 miniredis 就可以了。

這種方式稱為 stub,和 mock 有一些微妙的差別,可參考 stackoverflow 的討論。

miniredis 使用方式如下,主要需要考慮保障每個測試都有個乾淨的 redis 資料庫。

var testRdsSrv *miniredis.Miniredis

func TestMain(m *testing.M) {
s, err := miniredis.Run()
if err != nil {
panic(err)
}
defer s.Close()
os.Exit(m.Run()
}

func TestSomeRedis(t *testing.T) {
tests := []struct {...}{...}
for row, test := range tests {
testRdsSrv.FlushAll()
rClient := redis.NewClient(&redis.Options{
Addr: testRdsSrv.Addr(),
})
// do something with rClient
}
testRdsSrv.FlushAll()
}

stub MySQL

要測試用到關係資料庫的代碼更加麻煩,因為很多時候看程序正確與否就看它寫入到資料庫裏的數據對不對,關係資料庫的操作不能簡單 mock 一下,測試的時候需要一個真的資料庫。

MySQL 或者其它關係資料庫沒有類似 miniredis 的解決方案,我們在測試之前要搭好一個乾淨的 MySQL 測試 Server,裡面的表也要建好。這些條件沒法只靠寫 Go 代碼實現,需要使用一些工具,以及在代碼工程裏做一點約定。

我想到的一個方案是,工程裏有個 sql 文件,裡面有建庫建表語句,編寫一個 docker-compose 配置,用於創建 MySQL Server,執行建庫建表語句,編寫 Makefile 將「啟動 MySQL」,「建表」,「go test」,「關閉 MySQL」 組織起來。

我試了一下,實現了整個流程後測試挺順暢的,相關配置代碼太多就不在這裡貼了,有興趣可看 euclidr/testingo

實現過程中主要遇到兩個問題,一個是需要確認 MySQL 的 docker 真正正常運行後才能建庫建表,一個是考慮修改默認 storage-engine 為 Memory 以加快測試速度。

參考資料

  1. 以上所有測試的詳細例子
  2. testing
  3. testify
  4. Unit Testing http client in Go
  5. Integration Test With Database in Golang
  6. miniredis

推薦閱讀:

相關文章