跟我一起学Go系列:从写测试用例开始仗剑走天涯

从入门到深入 Go 我们已经走了很长的路,当你想启动多个测试类的时候你是不是想启动多个 main 方法,但是 Go 限制了在同一个 package 下只能有一个 main,所以这条路你是走不通的。那我们想写单元测试的时候应该如何操作呢?别着急,不用引入任何的第三方包,单元测试 Go 也有默认的规范写法。

约定

在 Go SDK 中 ”testing“ 包的内容就是 Go 默认提供的单元测试支持。Go 标准库对单元测试编写的格式有一些硬性要求:

  • 所有测试方法必须放在位于以 _test.go 结尾的文件中,这样在执行 go build 构建的时候测试代码才会被排除。
  • 测试函数的命名必须以 Test 开头,并且跟在 Test 后面的后缀开头第一个字母必须大写
  • 测试方法必须要包含 “t *testing.T” 参数。
func TestGetUser(t *testing.T)
func TestInsert(t *testing.T)

其中参数 t 用于报告测试失败和附加的日志信息。 testing.T 的拥有的方法如下:

func (c *T) Error(args ...interface{})
func (c *T) Errorf(format string, args ...interface{})
func (c *T) Fail()
func (c *T) FailNow()
func (c *T) Failed() bool
func (c *T) Fatal(args ...interface{})
func (c *T) Fatalf(format string, args ...interface{})
func (c *T) Log(args ...interface{})
func (c *T) Logf(format string, args ...interface{})
func (c *T) Name() string
func (t *T) Parallel()
func (t *T) Run(name string, f func(t *T)) bool
func (c *T) Skip(args ...interface{})
func (c *T) SkipNow()
func (c *T) Skipf(format string, args ...interface{})
func (c *T) Skipped() bool

比如我们现在有一段回文检测的 func:

package service


// 判断一个字符串s是否时回文字符串
func IsPalindrome(s string) bool {
  for i := range s {
    if s[i] != s[len(s)-1-i] {
      return false
    }
  }
  return true
}

想在单元测试中调用 这个方法:

package demo

import (
  "gorm-demo/service"
  "testing"
)

func TestString(t *testing.T) {
  palindrome := service.IsPalindrome("3ee3")
  if palindrome {
    t.Logf("IsPalindrome test success, param=%s", "3e1e3")
  } else {
    t.Fatalf("IsPalindrome test fail, param=%s", "3e1e3")
  }

}

根据是否是回文输出对应的结果:

=== RUN   TestString
    string_test.go:11: IsPalindrome test success, param=3e1e3
--- PASS: TestString (0.00s)
PASS

除了直接执行对应的测试方法之外我们还可以通过 go test 命令行的方式来执行测试,go test 是 Go 语言自带的测试工具,其中包含的是两类:单元测试和性能测试,通过 go help test 可以看到 go test 的使用说明:

格式形如: go test [-c] [-i] [build flags] [packages] [flags for test binary]

参数解读:

-c : 编译go test成为可执行的二进制文件,但是不运行测试。

-i : 安装测试包依赖的package,但是不运行测试。

关于build flags,调用go help build,这些是编译运行过程中需要使用到的参数,一般设置为空

关于packages,调用go help packages,这些是关于包的管理,一般设置为空

关于flags for test binary,调用go help testflag,这些是go test过程中经常使用到的参数

-test.v : 是否输出全部的单元测试用例(不管成功或者失败),默认没有加上,所以只输出失败的单元测试用例。

-test.run pattern: 只跑哪些单元测试用例

-test.bench patten: 只跑那些性能测试用例

-test.benchmem : 是否在性能测试的时候输出内存情况

-test.benchtime t : 性能测试运行的时间,默认是1s

-test.cpuprofile cpu.out : 是否输出cpu性能分析文件

-test.memprofile mem.out : 是否输出内存性能分析文件

-test.blockprofile block.out : 是否输出内部goroutine阻塞的性能分析文件

-test.memprofilerate n : 内存性能分析的时候有一个分配了多少的时候才打点记录的问题。这个参数就是设置打点的内存分配间隔,也就是profile中一个sample代表的内存大小。默认是设置为512 * 1024的。如果你将它设置为1,则每分配一个内存块就会在profile中有个打点,那么生成的profile的sample就会非常多。如果你设置为0,那就是不做打点了。

你可以通过设置memprofilerate=1和GOGC=off来关闭内存回收,并且对每个内存块的分配进行观察。

-test.blockprofilerate n: 基本同上,控制的是goroutine阻塞时候打点的纳秒数。默认不设置就相当于-test.blockprofilerate=1,每一纳秒都打点记录一下

-test.parallel n : 性能测试的程序并行cpu数,默认等于GOMAXPROCS。

-test.timeout t : 如果测试用例运行时间超过t,则抛出panic

-test.cpu 1,2,4 : 程序运行在哪些CPU上面,使用二进制的1所在位代表,和nginx的nginx_worker_cpu_affinity是一个道理

-test.short : 将那些运行时间较长的测试用例运行时间缩短

测试覆盖率

Go提供内置功能来检查你的代码覆盖率。我们可以使用 go test -cover 来查看测试覆盖率。

MacBook-Pro:mockDemo yy$ go test -cover
PASS
coverage: 0.0% of statements
ok      gorm-demo/test/mockDemo 0.007s

Go还提供了一个额外的-coverprofile参数,用来将覆盖率相关的记录信息输出到一个文件。例如:

MacBook-Pro:mockDemo yy$ go test -cover -coverprofile=tt.log
PASS
coverage: 0.0% of statements
ok      gorm-demo/test/mockDemo 0.007s

生成 tt.log 文件之后,执行 go tool cover -html=tt.log,使用 cover 工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个 HTML 报告。

断言

使用 Java 的同学看到这里估计会问: Go 中没有断言吗?还需要自己去判断。

其实没有断言这种东西我们仔细想想也并不难理解,从 Go 的 error 包设计将异常作为返回值而不是使用 try-catch 的模式来说,Go 希望你在测试阶段就知晓每一个可能出现的异常,而不是将异常吞掉。所以 Assert 这种吞掉错误的功能 Go 官方也不想提供。

当然 Go 官方不提供不代表广大开发同胞真的不想用,这不有大哥开发了灵活又好用的断言库 testify ,有了它,我们上面的代码就可以改为这样:

assert.True(t, service.IsPalindrome("3e45e3"))

输出:

=== RUN   TestString
string_test.go:11: 
Error Trace:	string_test.go:11
Error:      	Should be true
Test:       	TestString
--- FAIL: TestString (0.00s)
FAIL

简介明了,一眼就知道测试用例是否通过。真的是谁用谁知道。

具体 testify 还有很多实用的断言方法:

// 判断两个值是否相等
func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
// 判断两个值不相等
func NotEqual(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
// 测试失败,测试中断
func FailNow(t TestingT, failureMessage string, msgAndArgs ...interface{}) bool
// 判断值是否为nil,常用于 error 的判断
func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
// 判断值是否不为nil,常用于 error 的判断
func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool

大家有兴趣可以看看 API。

Mock 功能

使用这个功能之前,先着重声明 mock 的意思。

mock 模拟,模仿的意思。这里这里提供的功能是模拟某段功能,用我们的模拟逻辑去代替。

testify 也支持 Mock,不过 Go 原生的 mock 框架就挺好的。GoMock 是由 Go 官方开发维护的测试框架,实现了较为完整的基于 interface 的 Mock 功能。注意它没在 SDK 里面哈。

go get -u github.com/golang/mock/gomock

Gomock 还提供了 mockgen 工具用来辅助生成测试代码。

go get -u github.com/golang/mock/mockgen

使用的时候这两个包都需要安装。

安装 mockgen 有两种方式,你可以只在你的当前代码目录执行 go get ,这样 mockgen 命令只对当前目录有效;或者你直接取 mockgen 的目录下执行 go build ,编译后会在这个目录下生成一个可执行程序 mockgen。然后将这个可执行程序 mockgen 拖到 $GOPATH/bin/ 目录下后面你就可以全局使用 mockgen 。

mockgen 使用也很简单,可以对包或者源代码文件生成指定接口的 Mock 代码,注意是对接口文件哈。

package mockDemo

type Task interface {
	Do(string) (bool, error)
}

想对指定接口生成 mock 代码使用如下命令:

mockgen -source=源文件路径  -destination=写入文件的路径(没有这个参数输出到终端) -package=生成文件的包名

demo :
mockgen -source=/Users/cc/go/src/go-web-demo/test/mockDemo/task.go -destination=/Users/cc/go/src/go-web-demo/test/mockDemo/mock_task_test.go -package=mockDemo


-source:设置需要模拟(mock)的接口文件
-destination:设置 mock 文件输出的地方,若不设置则打印到标准输出中
-package:设置 mock 文件的包名,若不设置则为 `mock_` 前缀加上文件名(如本文的包名会为 mock_person)


接下来上示例,再次解释 mock 就是要模拟,比如我们的 Do 方法要去连接数据库查询数据,这里因为不方便测试连接数据库这段代码,但是又不想影响整体测试流程所以用 mock 的方式去替代这段逻辑。解释清楚了我们上代码。

整体测试代码如下:

接口:

package mockDemo

type Task interface {
	Do(string) (bool, error)
}

根据该接口生成 mock 类:

// Code generated by MockGen. DO NOT EDIT.
// Source: /Users/yangyue/go/src/go-web-demo/test/mockDemo/task.go

// Package mockDemo is a generated GoMock package.
package mockDemo

import (
	reflect "reflect"

	gomock "github.com/golang/mock/gomock"
)

// MockTask is a mock of Task interface.
type MockTask struct {
	ctrl     *gomock.Controller
	recorder *MockTaskMockRecorder
}

// MockTaskMockRecorder is the mock recorder for MockTask.
type MockTaskMockRecorder struct {
	mock *MockTask
}

// NewMockTask creates a new mock instance.
func NewMockTask(ctrl *gomock.Controller) *MockTask {
	mock := &MockTask{ctrl: ctrl}
	mock.recorder = &MockTaskMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockTask) EXPECT() *MockTaskMockRecorder {
	return m.recorder
}

// Do mocks base method.
func (m *MockTask) Do(arg0 string) (bool, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Do", arg0)
	ret0, _ := ret[0].(bool)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

// Do indicates an expected call of Do.
func (mr *MockTaskMockRecorder) Do(arg0 interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Do", reflect.TypeOf((*MockTask)(nil).Do), arg0)
}

测试方法:

package mockDemo

import (
	"fmt"
	"github.com/golang/mock/gomock"
	"testing"
)

func TestMock(t *testing.T) {
	ctl := gomock.NewController(t)
	defer ctl.Finish()
	task := NewMockTask(ctl)
	gomock.InOrder(task.EXPECT().Do("banana").Return(true, nil))

	task.Do("banana")
}

gomock.NewController:返回 gomock.Controller,它代表 mock 生态系统中的顶级控件。定义了 mock 对象的范围、生命周期和期待值。多 goroutine 下是线程安全的。

NewMockTask() 创建一个新的 MockTask 实例,因为 MockTask 实现了 Task 接口所有后面实际是调用 MockTask 的实现方法。

gomock.InOrder(calls ...*Call):声明调用 Call 的顺序,这里可以传入多个 Call。

task.EXPECT().Do("banana").Return(true, nil)EXPECT() 是期望拿到返回值,Call 的方法调用类似于 Java 中的 Build 模式,链式调用。有如下方法可供使用:

  • Call.Do():声明在匹配时要运行的操作
  • Call.DoAndReturn():声明在匹配调用时要运行的操作,并且模拟返回该函* 数的返回值
  • Call.MaxTimes():设置最大的调用次数为 n 次
  • Call.MinTimes():设置最小的调用次数为 n 次
  • Call.AnyTimes():允许调用次数为 0 次或更多次
  • Call.Times():设置调用次数为 n 次

我们测试一下调用顺序检测,多个 Call 的情况:

package mockDemo

import (
	"github.com/golang/mock/gomock"
	"testing"
)

func TestMock(t *testing.T) {
	ctl := gomock.NewController(t)
	defer ctl.Finish()
	task := NewMockTask(ctl)

	call1 := task.EXPECT().Do("banana").Return(true, nil)
	call2 := task.EXPECT().Do("apple").Return(true, nil)
	call3 := task.EXPECT().Do("pineapple").Return(true, nil)

	gomock.InOrder(call1, call2, call3)

	task.Do("apple")
	task.Do("banana")
	task.Do("pineapple")
}

顺序不一样的情况下是会报错的。

总结一下 mock 的使用:mock 是面向接口的测试,当你想测试的逻辑只是一段独立功能性的代码而没有提供接口去抽象化的时候你无法使用 mock 功能。当然不是说必须要面向接口开发,有接口的定义会更加规范化你的代码让你知道写出来的逻辑是审慎总结的。

posted @ 2021-04-19 09:35  rickiyang  阅读(1100)  评论(0编辑  收藏  举报