Go语言测试库testify使用学习


Posted in Golang onJuly 23, 2022

简介

testify可以说是最流行的(从 GitHub star 数来看)Go 语言测试库了。testify提供了很多方便的函数帮助我们做assert和错误信息输出。使用标准库testing,我们需要自己编写各种条件判断,根据判断结果决定输出对应的信息。

testify核心有三部分内容:

  • assert:断言;
  • mock:测试替身;
  • suite:测试套件。

准备工作

本文代码使用 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中存储的实际类型,空的含义不同:

  • 指针: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语言测试库testify使用学习的详细内容,更多关于Go语言测试库testify的资料请关注三水点靠木其它相关文章!

Golang 相关文章推荐
golang中切片copy复制和等号复制的区别介绍
Apr 27 Golang
golang 实现两个结构体复制字段
Apr 28 Golang
golang DNS服务器的简单实现操作
Apr 30 Golang
golang 定时任务方面time.Sleep和time.Tick的优劣对比分析
May 05 Golang
go类型转换及与C的类型转换方式
May 05 Golang
Golang生成Excel文档的方法步骤
Jun 09 Golang
golang中字符串MD5生成方式总结
Jul 04 Golang
Golang 字符串的常见操作
Apr 19 Golang
Golang jwt身份认证
Apr 20 Golang
Golang解析JSON对象
Apr 30 Golang
Golang并发工具Singleflight
May 06 Golang
GoFrame框架数据校验之校验结果Error接口对象
Jun 21 Golang
Go语言怎么使用变长参数函数
Jul 15 #Golang
Go微服务项目配置文件的定义和读取示例详解
Jun 21 #Golang
Go本地测试解耦任务拆解及沟通详解Go本地测试的思路沟通的重要性总结
Jun 21 #Golang
Go 内联优化让程序员爱不释手
Jun 21 #Golang
GoFrame框架数据校验之校验结果Error接口对象
Jun 21 #Golang
GoFrame基于性能测试得知grpool使用场景
Jun 21 #Golang
Golang gRPC HTTP协议转换示例
You might like
php遍历所有文件及文件夹的方法深入解析
2013/06/08 PHP
php实现文件下载代码分享
2014/08/19 PHP
php设计模式之委托模式
2016/02/13 PHP
js常用系统函数用法实例分析
2015/01/12 Javascript
javascript运动详解
2015/07/06 Javascript
jQuery Mobile 和 Kendo UI 的比较
2016/05/05 Javascript
JS不用正则验证输入的字符串是否为空(包含空格)的实现代码
2016/06/14 Javascript
在Mac OS上安装使用Node.js的项目自动化构建工具Gulp
2016/06/18 Javascript
轻松掌握JavaScript状态模式
2016/09/07 Javascript
js上下视差滚动简单实现代码
2017/03/07 Javascript
js最简单的双向绑定实例讲解
2018/01/02 Javascript
javascript实现贪吃蛇游戏(娱乐版)
2020/08/17 Javascript
vuex刷新后数据丢失的解决方法
2020/10/18 Javascript
[03:18]DOTA2亚洲邀请赛小组赛第一日 RECAP赛事回顾
2015/01/30 DOTA
使用python3.5仿微软记事本notepad
2016/06/15 Python
Python读取指定目录下指定后缀文件并保存为docx
2017/04/23 Python
python判断数字是否是超级素数幂
2018/09/27 Python
python+pyqt5编写md5生成器
2019/03/18 Python
如何使用Python自动控制windows桌面
2019/07/11 Python
python 穷举指定长度的密码例子
2020/04/02 Python
Python实现动态循环输出文字功能
2020/05/07 Python
python反扒机制的5种解决方法
2021/02/06 Python
HTML5 播放 RTSP 视频的实例代码
2019/07/29 HTML / CSS
html2canvas生成清晰的图片实现打印的示例代码
2019/09/30 HTML / CSS
Lulu & Georgia官方网站:购买地毯、家具、抱枕、壁纸、床上用品等
2018/03/19 全球购物
自荐信格式的六要素
2013/09/21 职场文书
口头翻译求职人自荐信
2013/12/07 职场文书
应届大学生求职信
2014/07/20 职场文书
职业道德模范事迹材料
2014/08/24 职场文书
大型主题婚礼活动策划方案
2014/09/15 职场文书
企业党的群众路线教育实践活动学习心得体会
2014/10/31 职场文书
2014年监理工作总结范文
2014/11/17 职场文书
党小组意见范文
2015/06/08 职场文书
答谢酒会主持词
2015/07/02 职场文书
公司董事任命书
2015/09/21 职场文书
2016年小学六一儿童节活动总结
2016/04/06 职场文书