Go 单元测试入门实践
Go 单元测试
Go中不同文件的单元测试代码,写在其对应的 xxx_test.go 文件,该单元测试文件可以包含三种类型的函数,单元测试函数、基准测试函数和示例函数。本文只介绍其中的单元测试函数。
本文将从不同的需求场景出发,用具体的例子速览Go单元测试的编写。
(ps:对于函数以及方法打桩推荐使用 gomonkey,bou.ke 的 monkey 框架证书已经失效)
1. 单元测试的基本构成
简单测试函数示例:
// 文件名 demo.go
package testutil
import "errors"
type calculator struct {}
func (c *calculator) multiplication(a, b int) (int, error) {
return a*b, nil
}
func (c *calculator) Division(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("error: divide by 0")
}
return a/b, nil
}
// 在同一个目录中创建 demo_test.go 文件
package testutil
import (
"github.com/stretchr/testify/require"
"testing"
)
// 基本构成:构造输入,执行函数,判断结果
func Test_calculator_Division(t *testing.T) {
var cal calculator
// 定义输入
numA := 2
numB := 1
// 预期结果
want := 2
// 执行函数
res, err := cal.Division(numA, numB)
// 校验
if err != nil {
t.Errorf("Division() error = %v, wantErr: nil", err)
}
if res != want {
t.Errorf("Division() result = %v, want %v", res, want)
}
}
跳过某些测试:
测试函数增加如下判断,执行 go test -short 会跳过以下测试用例
func Test_calculator_Division(t *testing.T) {
if testing.Short() {
t.Skip("short模式下会跳过该测试用例")
}
...
}
创建多个测试用例:
- 子测试:t.Run(“case name”, func(t *testing){…}),其中 case name 用于区分不同的测试用例
- 表格驱动测试:for range { t.Run(“case name”, func(t *testing){…}) }
- 并行处理测试:在 func 开头加入 t.Parallel();注:默认情况下同一个package里面是串行运行,不同package之间是并行执行
- 使用帮助函数提取重复逻辑:在 _test.go文件中定义的非测试函数开头加入 t.Helper() 可以使报错更清晰,报错时会输出调用者的信息,而不是只输出被调用函数的信息
- PS: t.Error 和 t.Fatal 区别在于,前者遇到错误不停
// 多个测试用例
// 表格驱动
// 并行执行
func Test_calculator_Division(t *testing.T) {
// 包内也并行执行
t.Parallel()
// 定义输入
type args struct {
a int
b int
}
// 多个测试用例:用例名称、入参、预期结果、预期是否出错
tests := []struct {
name string
args args
want int
wantErr bool
}{
{"case name", args{2, 1}, 2, false},
}
// 遍历测试用例
for _, tt := range tests {
// 使用t.Run()执行子测试
t.Run(tt.name, func(t *testing.T) {
c := &calculator{}
got, err := c.Division(tt.args.a, tt.args.b)
if (err != nil) != tt.wantErr {
t.Errorf("Division() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Division() got = %v, want %v", got, tt.want)
}
})
}
}
多个测试都依赖某变量:
// 可以用来初始化全局变量;将多个测试函数都依赖的实体(变量,数据库,GRPC服务等),在测试开始前初始化好,从而避免重复代码
// 每个代码包可以有一个 TestMain, 它会包中所有测试开始前执行
func TestMain(m *testing.M) {
setUp() // 自己实现的资源初始化代码,一般是对全局变量进行赋值
code := m.Run()
tearDown() // 清理资源,如:关闭DB,删除临时文件,关闭 http 连接等
os.Exit(code)
}
快速生成测试文件:
使用 Goland 右键 Generate 生成函数或整个文件的测试代码
使用 gotests 生成:gotests -all -w demo.go
测试覆盖率:
通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。
执行:go test -cover
或者 go tool cover -html=c.out
可以使用 goland 的右键执行 test with coverage,即可可视化看到包中测试覆盖情况
testify: 断言 /require /assert:
testify是一个社区非常流行的Go单元测试工具包,其中使用最多的功能就是它提供的断言工具—testify/assert
或 testify/require
。(也可以选用功能更加丰富 goconver)
// slice 的对比判断
if !reflect.DeepEqual(got, s.want) {
t.Errorf("expected:%#v, result:%#v", s.want, res)
}
// 使用 testify 只需一行判断
assert.Equal(t, res, s.want)
有多个断言语句时:
// 创建 assert 对象
assert := assert.New(t)
// 无需再传入 Testing.T 参数了
assert.Equal(res, want)
require 拥有 assert 所有断言函数,它们的唯一区别就是 — require 遇到失败的用例会立即终止本次测试。
2. 接口打桩
Mock:接口测试
除了网络和数据库等外部依赖之外,我们在开发中也会经常用到各种各样的接口类型。使用 gomock 可以较好地为接口打桩,gomonkey也可以,只是接口测试编写稍微复杂一些。
Mockgen
mockgen 命令用来为给定一个包含要mock的接口的Go源文件,生成mock类源代码。
安装:$GOPATH/bin已经加入到环境变量中;GO111MODULE=on; go get github.com/golang/mock/mockgen@v1.6.0
运行: 源码模式和反射模式
mockgen -source=db.go -destination=mocks/db_mock.go -package=mocks
测试函数写法:
func TestGetFromDB(t *testing.T) {
// 创建gomock控制器,用来记录后续的操作信息
ctrl := gomock.NewController(t)
// 调用mockgen生成代码中的NewMockDB方法
m := mocks.NewMockDB(ctrl)
// 打桩(stub)
// 当传入Get函数的参数为 expect input 时返回 1 和 nil
m.
EXPECT().
xxx(gomock.Eq("expect input")). // 函数方法 xxx,指定输入参数=“expect input”
Return(1, nil). // 返回值 1,nil
Times(1) // 调用次数
// 传入mock接口 m,给函数调用
if v := GetFromDB(m, "expect input"); v != 1 {
t.Fatal()
}
// 如果函数调用时传入的参数不是 “expect input”,那么打桩不会生效,而是调用原函数
}
入参可以指定特定值,也可以使用以下的方法指定:
- gomock.Eq(value):表示一个等价于value值的参数
- gomock.Not(value):表示一个非value值的参数
- gomock.Any():表示任意值的参数
- gomock.Nil():表示空值的参数
返回值可以设置不同的返回方式:
- Return():返回指定值
- Do(func):执行操作,忽略返回值
- DoAndReturn(func):执行并返回指定值
设置期望被调用的次数:
- Times() 断言 Mock 方法被调用的次数。
- MaxTimes() 最大次数。
- MinTimes() 最小次数。
- AnyTimes() 任意次数(包括 0 次)。
指定调用顺序:
// 指定顺序
gomock.InOrder(
m.EXPECT().FunctionName("1"),
m.EXPECT().FunctionName("2"),
m.EXPECT().FunctionName("3"),
)
注意:
对接口进行 mock 之后,需要传入该mock之后的对象。如果测试函数无法传入mock之后的对象,则无法测试。
func GetFromDB(key string) int {
db := NewDB()
if value, err := db.Get(key); err == nil {
return value
}
return -1
}
// 如果是这个方法,无法传入mock后的 db,因此该方法无法测试
3.全局变量打桩
Stub:全局变量
GoStub 也是一个单元测试中的打桩工具,它支持为全局变量、函数等打桩。一般在单元测试中只会使用它来为全局变量打桩,Stub为函数打桩不太方便。另外全局变量也可以用 gomonkey 打桩。
使用示例:
func TestGetConfig(t *testing.T) {
// 为全局变量configFile打桩,给它赋值为一个指定字符串
stubs := gostub.Stub(&configFile, "./test.txt")
defer stubs.Reset()
// 执行要测试的函数
data, err := GetConfig()
if err != nil {
t.Fatal()
}
// 此时函数GetConfig()中使用的全局变量 configFile 就是我们上面设置的值
}
4.函数打桩
Monkey:函数、方法
(由于 license 问题,推荐使用更强大的并且一直在更新的 gomonkey 包替代)
monkey 是一个Go单元测试中十分常用的打桩工具,其原理可以参考这篇 Monkey Patching in Go: 译文。
Gomonkey: 函数、方法、变量
gomonkey is a library to make monkey patching in unit tests easy, and the core idea of monkey patching comes from Bouke, you can read this blogpost for an explanation on how it works.
gomonkey 打桩的原理与 bou.ke 的 monkey 是一样的,并且 gomonkey 功能更加丰富:支持为函数、成员方法、全局变量、接口打桩等。下面以新版的 github.com/agiledragon/gomonkey/v2 为例,介绍不同的打桩方法
函数:ApplyFunc
// ApplyFunc(参数:要被打桩的函数;定义新的函数以及其输出)
patches := ApplyFunc(myPackage.MyFunction, func(_ string, _ ...string) (string, error) {
return "expected result", nil
})
defer patches.Reset()
// 后续执行被打桩的函数,得到的函数结果即为 "expected result", nil
res, err := myPackage.MyFunction("any string input", "any string input")
成员方法:ApplyMethodFunc
instance := &myStruct{}
var p *myStruct
// ApplyMethodFunc(参数:成员的指针;成员方法名称;定义替换的函数)
patches := ApplyMethodFunc(p, "TheMethodName", func(_ int) error {
return nil
})
defer patches.Reset()
// 后续调用任意该成员实列的该方法,都只执行上述定义的打桩函数,结果返回 nil
err := instance.TheMethodName(1222)
全局变量:ApplyGlobalVar
// ApplyGlobalVar(参数:变量地址,变量打桩的值)
patches := ApplyGlobalVar(&num, 150)
defer patches.Reset()
接口:需要组合复用上述的 ApplyFunc 和 ApplyMethod
(对接口的打桩,gomonkey 用起来还是没有上述的 mock 自然)
// 使用 ApplyFunc 将返回接口的函数指向一个实现对象
patches := ApplyFunc(TheFunctionReturnInterface, func(_ string) myInterface {
return interfaceInstance
})
defer patches.Reset()
// 然后通过 ApplyMethod 改变上述实现对象的行为
patches.ApplyMethod(interfaceInstance, "TheMethodName",func(_ *xxx, _ string) (string, error) {
return "expected result", nil
})
其它类型的打桩可以参考 gomonkey 仓库中的 example
5.HTTP打桩
httptest:
服务端模拟
// 模拟 http server
func NewLocalHTTPSTestServer(handler http.Handler) (*httptest.Server, error) {
ts := httptest.NewUnstartedServer(handler)
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
return nil, err
}
ts.TLS = &tls.Config{Certificates: []tls.Certificate{cert}}
ts.StartTLS() // 此处启动 https 服务,如果是 ts.Start() 则是 HTTP 服务
return ts, nil
}
// client 调用测试
func TestLocalHTTPSserver(t *testing.T) {
handlerFunc := http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello, client") // 对请求的处理逻辑
}
)
ts, err := NewLocalHTTPSTestServer(handlerFunc)
assert.Nil(t, err)
defer ts.Close()
// ts.URL 为上面模拟server的访问地址,可以通过替换 listener 改变
res, err := http.Get(ts.URL)
assert.Nil(t, err)
// 如果想要替换 url:
// 新建server时先不启动,也就是去掉 ts.StartTLS()
// mylistener, err := net.Listen("tcp", "127.0.0.1:8080")
// ts.Listener.Close()
// ts.Listener = mylistener
// ts.StartTLS()
greeting, err := ioutil.ReadAll(res.Body)
res.Body.Close()
assert.Nil(t, err)
assert.Equal(t, "Hello, client", string(greeting))
}
- 点赞
- 收藏
- 关注作者
评论(0)