Go 每日一庫之 testify

簡介

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斷言listAlistB包含相同的元素,忽略元素出現的順序。listA/listB必須是數組或切片。若是有重複元素,重複元素出現的次數也必須相等。

Empty

函數類型:

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

Empty斷言object是空,根據object中存儲的實際類型,空的含義不一樣:

  • 指針:nil
  • 整數:0;
  • 浮點數:0.0;
  • 字符串:空串""
  • 布爾:false;
  • 切片或 channel:長度爲 0。

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斷言expectedactual相等,或者能夠轉換爲相同的類型,而且相等。這個條件比Equal更寬,Equal()返回trueEqualValues()確定也返回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等。

Assertions 對象

觀察到上面的斷言都是以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

require提供了和assert一樣的接口,可是遇到錯誤時,require直接終止測試,而assert返回false

mock

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()方法的返回值分別爲MockUsersnil,返回值在上面的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")

suite

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

測試 HTTP 服務器

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😄

參考

  1. testify GitHub:github.com/stretchr/testify
  2. Go 每日一庫 GitHub:https://github.com/darjun/go-daily-lib

個人博客:https://darjun.github.io

歡迎關注個人微信公衆號【GoUpUp】,共同窗習,一塊兒進步~

相關文章
相關標籤/搜索