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判断key是否在map中的代码
Apr 24 Golang
go语言-在mac下brew升级golang
Apr 25 Golang
Golang 实现超大文件读取的两种方法
Apr 27 Golang
go语言中json数据的读取和写出操作
Apr 28 Golang
Go语言中break label与goto label的区别
Apr 28 Golang
解决golang结构体tag编译错误的问题
May 02 Golang
golang日志包logger的用法详解
May 05 Golang
golang switch语句的灵活写法介绍
May 06 Golang
Golang二维数组的使用方式
May 28 Golang
浅谈Go语言多态的实现与interface使用
Jun 16 Golang
Go 语言下基于Redis分布式锁的实现方式
Jun 28 Golang
Go 内联优化让程序员爱不释手
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/06 PHP
学习php设计模式 php实现桥梁模式(bridge)
2015/12/07 PHP
php实现简单的权限管理的示例代码
2017/08/25 PHP
YII2框架中添加自定义模块的方法实例分析
2020/03/18 PHP
JavaScript 仿关机效果的图片层
2008/12/26 Javascript
jQuery学习7 操作JavaScript对象和集合的函数
2010/02/07 Javascript
了不起的node.js读书笔记之例程分析
2014/12/22 Javascript
jQuery实现个性翻牌效果导航菜单的方法
2015/03/09 Javascript
浅谈javascript获取元素transform参数
2015/07/24 Javascript
鼠标经过子元素触发mouseout,mouseover事件的解决方案
2015/07/26 Javascript
很实用的js选项卡切换效果
2016/08/12 Javascript
NodeJS实现视频转码的示例代码
2017/11/18 NodeJs
vue添加axios,并且指定baseurl的方法
2018/09/19 Javascript
Vue项目History模式404问题解决方法
2018/10/31 Javascript
巧用Python装饰器 免去调用父类构造函数的麻烦
2012/05/18 Python
python批量导出导入MySQL用户的方法
2013/11/15 Python
基于Python和Scikit-Learn的机器学习探索
2017/10/16 Python
Python闭包函数定义与用法分析
2018/07/20 Python
python3 拼接字符串的7种方法
2018/09/12 Python
pip 安装库比较慢的解决方法(国内镜像)
2019/10/06 Python
虚拟机下载python是否需要联网
2020/07/27 Python
在Python3.74+PyCharm2020.1 x64中安装使用Kivy的详细教程
2020/08/07 Python
Python之京东商品秒杀的实现示例
2021/01/06 Python
美国知名的女性服饰品牌:LOFT(洛芙特)
2016/08/05 全球购物
BONIA官方网站:国际奢侈品牌和皮革专家
2016/11/27 全球购物
美国顶级水上运动专业店:Marine Products
2018/04/15 全球购物
董事长岗位职责
2013/11/30 职场文书
省级四好少年事迹材料
2014/01/25 职场文书
中学教师培训制度
2014/01/31 职场文书
公证委托书标准格式
2014/09/11 职场文书
陕西导游词
2015/02/04 职场文书
幼儿园小朋友毕业感言
2015/07/30 职场文书
高三语文教学反思
2016/02/16 职场文书
《世界多美呀》教学反思
2016/02/22 职场文书
MySQL表的增删改查(基础)
2021/04/05 MySQL
Spring Security中用JWT退出登录时遇到的坑
2021/10/16 Java/Android