Go 每日一庫之 testify

簡介

testify可以說是最流行的(從 GitHub star 數來看)Go 語言測試庫了。testify提供了很多方便的函數幫助我們做assert和錯誤信息輸出。使用標準庫testing,我們需要自己編寫各種條件判斷,根據判斷結果決定輸出對應的信息。

testify核心有三部分內容:

準備工作

本文代碼使用 Go Modules。

創建目錄並初始化:

$ mkdir -p testify && cd testify
$ go mod init github.com/darjun/go-daily-lib/testify

安裝testify庫:

$ go get -u github.com/stretchr/testify

assert

assert子庫提供了便捷的斷言函數,可以大大簡化測試代碼的編寫。總的來說,它將之前需要判斷 + 信息輸出的模式

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中存儲的實際類型,空的含義不同:

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 = "
  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 = "

  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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/w6yBqgPsoCPnWpMU_dxF0w