testify
能夠說是最流行的(從 GitHub star 數來看)Go 語言測試庫了。testify
提供了不少方便的函數幫助咱們作assert
和錯誤信息輸出。使用標準庫testing
,咱們須要本身編寫各類條件判斷,根據判斷結果決定輸出對應的信息。git
testify
核心有三部份內容:github
assert
:斷言;mock
:測試替身;suite
:測試套件。本文代碼使用 Go Modules。golang
建立目錄並初始化:數據庫
$ mkdir -p testify && cd testify $ go mod init github.com/darjun/go-daily-lib/testify
安裝testify
庫:json
$ go get -u github.com/stretchr/testify
assert
assert
子庫提供了便捷的斷言函數,能夠大大簡化測試代碼的編寫。總的來講,它將以前須要判斷 + 信息輸出的模式
:segmentfault
if got != expected { t.Errorf("Xxx failed expect:%d got:%d", got, expected) }
簡化爲一行斷言代碼:數組
assert.Equal(t, got, expected, "they should be equal")
結構更清晰,更可讀。熟悉其餘語言測試框架的開發者對assert
的相關用法應該不會陌生。此外,assert
中的函數會自動生成比較清晰的錯誤描述信息:服務器
func TestEqual(t *testing.T) { var a = 100 var b = 200 assert.Equal(t, a, b, "") }
使用testify
編寫測試代碼與testing
同樣,測試文件爲_test.go
,測試函數爲TestXxx
。使用go test
命令運行測試:微信
$ go test --- FAIL: TestEqual (0.00s) assert_test.go:12: Error Trace: Error: Not equal: expected: 100 actual : 200 Test: TestEqual FAIL exit status 1 FAIL github.com/darjun/go-daily-lib/testify/assert 0.107s
咱們看到信息更易讀。網絡
testify
提供的assert
類函數衆多,每種函數都有兩個版本,一個版本是函數名不帶f
的,一個版本是帶f
的,區別就在於帶f
的函數,咱們須要指定至少兩個參數,一個格式化字符串format
,若干個參數args
:
func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) func Equalf(t TestingT, expected, actual interface{}, msg string, args ...interface{})
實際上,在Equalf()
函數內部調用了Equal()
:
func Equalf(t TestingT, expected interface{}, actual interface{}, msg string, args ...interface{}) bool { if h, ok := t.(tHelper); ok { h.Helper() } return Equal(t, expected, actual, append([]interface{}{msg}, args...)...) }
因此,咱們只須要關注不帶f
的版本便可。
Contains
函數類型:
func Contains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool
Contains
斷言s
包含contains
。其中s
能夠是字符串,數組/切片,map。相應地,contains
爲子串,數組/切片元素,map 的鍵。
DirExists
函數類型:
func DirExists(t TestingT, path string, msgAndArgs ...interface{}) bool
DirExists
斷言路徑path
是一個目錄,若是path
不存在或者是一個文件,斷言失敗。
ElementsMatch
函數類型:
func ElementsMatch(t TestingT, listA, listB interface{}, msgAndArgs ...interface{}) bool
ElementsMatch
斷言listA
和listB
包含相同的元素,忽略元素出現的順序。listA/listB
必須是數組或切片。若是有重複元素,重複元素出現的次數也必須相等。
Empty
函數類型:
func Empty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
Empty
斷言object
是空,根據object
中存儲的實際類型,空的含義不一樣:
nil
;""
;EqualError
函數類型:
func EqualError(t TestingT, theError error, errString string, msgAndArgs ...interface{}) bool
EqualError
斷言theError.Error()
的返回值與errString
相等。
EqualValues
函數類型:
func EqualValues(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
EqualValues
斷言expected
與actual
相等,或者能夠轉換爲相同的類型,而且相等。這個條件比Equal
更寬,Equal()
返回true
則EqualValues()
確定也返回true
,反之則否則。實現的核心是下面兩個函數,使用了reflect.DeapEqual()
:
func ObjectsAreEqual(expected, actual interface{}) bool { if expected == nil || actual == nil { return expected == actual } exp, ok := expected.([]byte) if !ok { return reflect.DeepEqual(expected, actual) } act, ok := actual.([]byte) if !ok { return false } if exp == nil || act == nil { return exp == nil && act == nil } return bytes.Equal(exp, act) } func ObjectsAreEqualValues(expected, actual interface{}) bool { // 若是`ObjectsAreEqual`返回 true,直接返回 if ObjectsAreEqual(expected, actual) { return true } actualType := reflect.TypeOf(actual) if actualType == nil { return false } expectedValue := reflect.ValueOf(expected) if expectedValue.IsValid() && expectedValue.Type().ConvertibleTo(actualType) { // 嘗試類型轉換 return reflect.DeepEqual(expectedValue.Convert(actualType).Interface(), actual) } return false }
例如我基於int
定義了一個新類型MyInt
,它們的值都是 100,Equal()
調用將返回 false,EqualValues()
會返回 true:
type MyInt int func TestEqual(t *testing.T) { var a = 100 var b MyInt = 100 assert.Equal(t, a, b, "") assert.EqualValues(t, a, b, "") }
Error
函數類型:
func Error(t TestingT, err error, msgAndArgs ...interface{}) bool
Error
斷言err
不爲nil
。
ErrorAs
函數類型:
func ErrorAs(t TestingT, err error, target interface{}, msgAndArgs ...interface{}) bool
ErrorAs
斷言err
表示的 error 鏈中至少有一個和target
匹配。這個函數是對標準庫中errors.As
的包裝。
ErrorIs
函數類型:
func ErrorIs(t TestingT, err, target error, msgAndArgs ...interface{}) bool
ErrorIs
斷言err
的 error 鏈中有target
。
上面的斷言都是它們的逆斷言,例如NotEqual/NotEqualValues
等。
觀察到上面的斷言都是以TestingT
爲第一個參數,須要大量使用時比較麻煩。testify
提供了一種方便的方式。先以*testing.T
建立一個*Assertions
對象,Assertions
定義了前面全部的斷言方法,只是不須要再傳入TestingT
參數了。
func TestEqual(t *testing.T) { assertions := assert.New(t) assertion.Equal(a, b, "") // ... }
順帶提一句TestingT
是一個接口,對*testing.T
作了一個簡單的包裝:
type TestingT interface{ Errorf(format string, args ...interface{}) }
require
提供了和assert
一樣的接口,可是遇到錯誤時,require
直接終止測試,而assert
返回false
。
testify
提供了對 Mock 的簡單支持。Mock 簡單來講就是構造一個仿對象,仿對象提供和原對象同樣的接口,在測試中用仿對象來替換原對象。這樣咱們能夠在原對象很難構造,特別是涉及外部資源(數據庫,訪問網絡等)。例如,咱們如今要編寫一個從一個站點拉取用戶列表信息的程序,拉取完成以後程序顯示和分析。若是每次都去訪問網絡會帶來極大的不肯定性,甚至每次返回不一樣的列表,這就給測試帶來了極大的困難。咱們可使用 Mock 技術。
package main import ( "encoding/json" "fmt" "io/ioutil" "net/http" ) type User struct { Name string Age int } type ICrawler interface { GetUserList() ([]*User, error) } type MyCrawler struct { url string } func (c *MyCrawler) GetUserList() ([]*User, error) { resp, err := http.Get(c.url) if err != nil { return nil, err } defer resp.Body.Close() data, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } var userList []*User err = json.Unmarshal(data, &userList) if err != nil { return nil, err } return userList, nil } func GetAndPrintUsers(crawler ICrawler) { users, err := crawler.GetUserList() if err != nil { return } for _, u := range users { fmt.Println(u) } }
Crawler.GetUserList()
方法完成爬取和解析操做,返回用戶列表。爲了方便 Mock,GetAndPrintUsers()
函數接受一個ICrawler
接口。如今來定義咱們的 Mock 對象,實現ICrawler
接口:
package main import ( "github.com/stretchr/testify/mock" "testing" ) type MockCrawler struct { mock.Mock } func (m *MockCrawler) GetUserList() ([]*User, error) { args := m.Called() return args.Get(0).([]*User), args.Error(1) } var ( MockUsers []*User ) func init() { MockUsers = append(MockUsers, &User{"dj", 18}) MockUsers = append(MockUsers, &User{"zhangsan", 20}) } func TestGetUserList(t *testing.T) { crawler := new(MockCrawler) crawler.On("GetUserList").Return(MockUsers, nil) GetAndPrintUsers(crawler) crawler.AssertExpectations(t) }
實現GetUserList()
方法時,須要調用Mock.Called()
方法,傳入參數(示例中無參數)。Called()
會返回一個mock.Arguments
對象,該對象中保存着返回的值。它提供了對基本類型和error
的獲取方法Int()/String()/Bool()/Error()
,和通用的獲取方法Get()
,通用方法返回interface{}
,須要類型斷言爲具體類型,它們都接受一個表示索引的參數。
crawler.On("GetUserList").Return(MockUsers, nil)
是 Mock 發揮魔法的地方,這裏指示調用GetUserList()
方法的返回值分別爲MockUsers
和nil
,返回值在上面的GetUserList()
方法中被Arguments.Get(0)
和Arguments.Error(1)
獲取。
最後crawler.AssertExpectations(t)
對 Mock 對象作斷言。
運行:
$ go test &{dj 18} &{zhangsan 20} PASS ok github.com/darjun/testify 0.258s
GetAndPrintUsers()
函數功能正常執行,而且咱們經過 Mock 提供的用戶列表也能正確獲取。
使用 Mock,咱們能夠精確斷言某方法以特定參數的調用次數,Times(n int)
,它有兩個便捷函數Once()/Twice()
。下面咱們要求函數Hello(n int)
要以參數 1 調用 1次,參數 2 調用兩次,參數 3 調用 3 次:
type IExample interface { Hello(n int) int } type Example struct { } func (e *Example) Hello(n int) int { fmt.Printf("Hello with %d\n", n) return n } func ExampleFunc(e IExample) { for n := 1; n <= 3; n++ { for i := 0; i <= n; i++ { e.Hello(n) } } }
編寫 Mock 對象:
type MockExample struct { mock.Mock } func (e *MockExample) Hello(n int) int { args := e.Mock.Called(n) return args.Int(0) } func TestExample(t *testing.T) { e := new(MockExample) e.On("Hello", 1).Return(1).Times(1) e.On("Hello", 2).Return(2).Times(2) e.On("Hello", 3).Return(3).Times(3) ExampleFunc(e) e.AssertExpectations(t) }
運行:
$ go test --- FAIL: TestExample (0.00s) panic: assert: mock: The method has been called over 1 times. Either do one more Mock.On("Hello").Return(...), or remove extra call. This call was unexpected: Hello(int) 0: 1 at: [equal_test.go:13 main.go:22] [recovered]
原來ExampleFunc()
函數中<=
應該是<
致使多調用了一次,修改過來繼續運行:
$ go test PASS ok github.com/darjun/testify 0.236s
咱們還能夠設置以指定參數調用會致使 panic,測試程序的健壯性:
e.On("Hello", 100).Panic("out of range")
testify
提供了測試套件的功能(TestSuite
),testify
測試套件只是一個結構體,內嵌一個匿名的suite.Suite
結構。測試套件中能夠包含多個測試,它們能夠共享狀態,還能夠定義鉤子方法執行初始化和清理操做。鉤子都是經過接口來定義的,實現了這些接口的測試套件結構在運行到指定節點時會調用對應的方法。
type SetupAllSuite interface { SetupSuite() }
若是定義了SetupSuite()
方法(即實現了SetupAllSuite
接口),在套件中全部測試開始運行前調用這個方法。對應的是TearDownAllSuite
:
type TearDownAllSuite interface { TearDownSuite() }
若是定義了TearDonwSuite()
方法(即實現了TearDownSuite
接口),在套件中全部測試運行完成後調用這個方法。
type SetupTestSuite interface { SetupTest() }
若是定義了SetupTest()
方法(即實現了SetupTestSuite
接口),在套件中每一個測試執行前都會調用這個方法。對應的是TearDownTestSuite
:
type TearDownTestSuite interface { TearDownTest() }
若是定義了TearDownTest()
方法(即實現了TearDownTest
接口),在套件中每一個測試執行後都會調用這個方法。
還有一對接口BeforeTest/AfterTest
,它們分別在每一個測試運行前/後調用,接受套件名和測試名做爲參數。
咱們來編寫一個測試套件結構做爲演示:
type MyTestSuit struct { suite.Suite testCount uint32 } func (s *MyTestSuit) SetupSuite() { fmt.Println("SetupSuite") } func (s *MyTestSuit) TearDownSuite() { fmt.Println("TearDownSuite") } func (s *MyTestSuit) SetupTest() { fmt.Printf("SetupTest test count:%d\n", s.testCount) } func (s *MyTestSuit) TearDownTest() { s.testCount++ fmt.Printf("TearDownTest test count:%d\n", s.testCount) } func (s *MyTestSuit) BeforeTest(suiteName, testName string) { fmt.Printf("BeforeTest suite:%s test:%s\n", suiteName, testName) } func (s *MyTestSuit) AfterTest(suiteName, testName string) { fmt.Printf("AfterTest suite:%s test:%s\n", suiteName, testName) } func (s *MyTestSuit) TestExample() { fmt.Println("TestExample") }
這裏只是簡單在各個鉤子函數中打印信息,統計執行完成的測試數量。因爲要藉助go test
運行,因此須要編寫一個TestXxx
函數,在該函數中調用suite.Run()
運行測試套件:
func TestExample(t *testing.T) { suite.Run(t, new(MyTestSuit)) }
suite.Run(t, new(MyTestSuit))
會將運行MyTestSuit
中全部名爲TestXxx
的方法。運行:
$ go test SetupSuite SetupTest test count:0 BeforeTest suite:MyTestSuit test:TestExample TestExample AfterTest suite:MyTestSuit test:TestExample TearDownTest test count:1 TearDownSuite PASS ok github.com/darjun/testify 0.375s
Go 標準庫提供了一個httptest
用於測試 HTTP 服務器。如今編寫一個簡單的 HTTP 服務器:
func index(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello World") } func greeting(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "welcome, %s", r.URL.Query().Get("name")) } func main() { mux := http.NewServeMux() mux.HandleFunc("/", index) mux.HandleFunc("/greeting", greeting) server := &http.Server{ Addr: ":8080", Handler: mux, } if err := server.ListenAndServe(); err != nil { log.Fatal(err) } }
很簡單。httptest
提供了一個ResponseRecorder
類型,它實現了http.ResponseWriter
接口,可是它只是記錄寫入的狀態碼和響應內容,不會發送響應給客戶端。這樣咱們能夠將該類型的對象傳給處理器函數。而後構造服務器,傳入該對象來驅動請求處理流程,最後測試該對象中記錄的信息是否正確:
func TestIndex(t *testing.T) { recorder := httptest.NewRecorder() request, _ := http.NewRequest("GET", "/", nil) mux := http.NewServeMux() mux.HandleFunc("/", index) mux.HandleFunc("/greeting", greeting) mux.ServeHTTP(recorder, request) assert.Equal(t, recorder.Code, 200, "get index error") assert.Contains(t, recorder.Body.String(), "Hello World", "body error") } func TestGreeting(t *testing.T) { recorder := httptest.NewRecorder() request, _ := http.NewRequest("GET", "/greeting", nil) request.URL.RawQuery = "name=dj" mux := http.NewServeMux() mux.HandleFunc("/", index) mux.HandleFunc("/greeting", greeting) mux.ServeHTTP(recorder, request) assert.Equal(t, recorder.Code, 200, "greeting error") assert.Contains(t, recorder.Body.String(), "welcome, dj", "body error") }
運行:
$ go test PASS ok github.com/darjun/go-daily-lib/testify/httptest 0.093s
很簡單,沒有問題。
可是咱們發現一個問題,上面的不少代碼有重複,recorder/mux
等對象的建立,處理器函數的註冊。使用suite
咱們能夠集中建立,省略這些重複的代碼:
type MySuite struct { suite.Suite recorder *httptest.ResponseRecorder mux *http.ServeMux } func (s *MySuite) SetupSuite() { s.recorder = httptest.NewRecorder() s.mux = http.NewServeMux() s.mux.HandleFunc("/", index) s.mux.HandleFunc("/greeting", greeting) } func (s *MySuite) TestIndex() { request, _ := http.NewRequest("GET", "/", nil) s.mux.ServeHTTP(s.recorder, request) s.Assert().Equal(s.recorder.Code, 200, "get index error") s.Assert().Contains(s.recorder.Body.String(), "Hello World", "body error") } func (s *MySuite) TestGreeting() { request, _ := http.NewRequest("GET", "/greeting", nil) request.URL.RawQuery = "name=dj" s.mux.ServeHTTP(s.recorder, request) s.Assert().Equal(s.recorder.Code, 200, "greeting error") s.Assert().Contains(s.recorder.Body.String(), "welcome, dj", "body error") }
最後編寫一個TestXxx
驅動測試:
func TestHTTP(t *testing.T) { suite.Run(t, new(MySuite)) }
testify
擴展了testing
標準庫,斷言庫assert
,測試替身mock
和測試套件suite
,讓咱們編寫測試代碼更容易!
你們若是發現好玩、好用的 Go 語言庫,歡迎到 Go 每日一庫 GitHub 上提交 issue😄
歡迎關注個人微信公衆號【GoUpUp】,共同窗習,一塊兒進步~