Go单元测试框架
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如C语言中单元指一个函数,Java里单元指一个类,图形化的软件中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
单元测试(模块测试)是开发者编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。例如,你可能把一个很大的值放入一个有序list 中去,然后确认该值出现在list 的尾部。或者,你可能会从字符串中删除匹配某种模式的字符,然后确认字符串确实不再包含这些字符了。
单元测试是由程序员自己来完成,最终受益的也是程序员自己。可以这么说,程序员有责任编写功能代码,同时也就有责任为自己的代码编写单元测试。执行单元测试,就是为了证明这段代码的行为和我们期望的一致。
笔者采用单元测试框架为 Convey、Monkey、SqlMock。
1.GoMonkey
使用GoMonkey有如下好处:
隔离被测代码
加速执行测试
使得执行变得确定
模拟特殊情况
支持功能列表:
支持为函数打桩
支持为一个函数打一个特定的桩序列
支持为成员方法打桩
支持为函数变量打桩
支持为一个接口打桩
GoMonkey对于变量的mock原理跟gostub一样通过reflect包实现。
打桩解释:将测试任务之外的,并且与测试任务相关的任务用桩代理,从而实现分离测试任务。例如funcA调用funcB,funcB调用funcC和funcD,如果用桩函数B1代替函数B那么,函数A就可以完全隔断与funcC、funcD的关系。
补齐解释:用桩函数代替为实现的代码。例如funcA调用funcB,而funcB还未实现,则可以代替funcB使得funcA可以使用。
控制解释:使用桩函数声明完全一样的funcB1,根据测试的需要输出合适的数据。
2.实现原理
gomonkey是为函数、变量方法打桩,但是对于函数方法的模拟替换,在Go这种静态类型中不太容易,因此需要再“运行时”替换,即是通过创建汇编指令实现。
核心是通过调用 ApplyCore方法。replace 通过替换内存地址来实现。
func (this *Patches) ApplyCore(target, double reflect.Value) *Patches {
this.check(target, double)
if _, ok := this.originals[target]; ok {
panic("patch has been existed")
}
this.valueHolders[double] = double
original := replace(*(*uintptr)(getPointer(target)), uintptr(getPointer(double)))
this.originals[target] = original
return this
}
func replace(target, double uintptr) []byte {
code := buildJmpDirective(double)
bytes := entryAddress(target, len(code))
original := make([]byte, len(bytes))
copy(original, bytes)
modifyBinary(target, code)
return original
}
3.Convey和GoMonkey基本使用
新建函数 ReadJsonFile。
func ReadJsonFile(filename string, m interface{}) error {
f, err := os.Open(filename)
if err != nil {
return fmt.Errorf("open filename falied : %v", err.Error())
}
buf, err := io.ReadAll(f)
if err != nil {
return fmt.Errorf("read filename falied : %v", err.Error())
}
err = json.Unmarshal(buf, m)
if err != nil {
return fmt.Errorf("unmarshal failed : %v", err.Error())
}
return nil
}
编写测试用例
测试两种场景,第一种对于ReadJsonFile进行打桩,抛出错误,第二种测试正常输出
func TestReadJsonFile(t *testing.T) {
convey.Convey("TestReadJsonFile", t, func() {
convey.Convey("Should be return error when open file error", func() {
patch := gomonkey.ApplyFunc(ReadJsonFile, func(filename string, target interface{}) error {
return fmt.Errorf("open file error")
})
defer patch.Reset()
var ret []string
err := ReadJsonFile("abc", &ret)
convey.So(err, convey.ShouldEqual, fmt.Errorf(`open file error`))
})
convey.Convey("Should be return nil when success", func() {
var ret struct{ Msg string }
err := ReadJsonFile("mock.json", &ret)
convey.So(err, convey.ShouldEqual, nil)
})
})
}
执行测试用例:
go test -v --run TestReadJsonFile -gcflags=all=-l
3.1 无返回值的函数示例
在Go开发中,很多时候我们都要传递指针类型的参数,然后再函数内部修改具体的值,而没有返回值。
比如
func UpdateUser(user *User) error {
fmt.Println(user) // 第一次打印user
err := UpdateInferUser(user)
fmt.Println(user) // 第二次打印user
return err
}
func UpdateInferUser(user *User) error {
if user == nil {
return fmt.Errorf("user is nil")
}
user.Name = "hello"
return nil
}
此函数 UpdateUser
内部调用 updateInferUser
完成对变量的修改。
func TestUpdateUser(t *testing.T) {
u := User{
Name: "1-init",
Password: "1-init",
}
convey.Convey("TestUpdateUser", t, func() {
convey.Convey("success1", func() {
patches := gomonkey.NewPatches()
defer patches.Reset()
patches.ApplyFunc(UpdateInferUser, func(object *User) error {
object.Name = "1-update"
return nil
})
err := UpdateUser(&u)
convey.So(err, convey.ShouldEqual, nil)
})
})
}
执行测试用例
3.2 结构体方法测试
type User struct {
Name string `json:"name"`
Password string `json:"password"`
}
// UpdatePassword 修改密码
func (u *User) UpdatePassword(newPassword string) {
u.Password = newPassword
}
func UpdateUser(user *User) error {
fmt.Println(user) // 第一次打印user
user.UpdatePassword("<PASSWORD>")
fmt.Println(user) // 第二次打印user
return nil
}
结构体内定义了方法 user.UpdatePassword
测试代码如下
convey.Convey("TestUpdateUser", t, func() {
convey.Convey("success1", func() {
patches := gomonkey.NewPatches()
defer patches.Reset()
patches.ApplyMethod(reflect.TypeOf(&u), "UpdatePassword", func(_ *User, _newStr string) {
u.Password = "1234"
})
err := UpdateUser(&u)
convey.So(err, convey.ShouldEqual, nil)
})
})
通过 applyMethod进行测试
4.遇到问题
4.1 mac m2 芯片直接执行测试会 permission denied问题
原因是因为 mac m系列对于内存有保护
err := syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC)
if err != nil {
panic(err)
}
解决方案参考 https://github.com/agiledragon/gomonkey/issues/70
目前看下来有三种
修改gomonkey源代码,err := syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_WRITE)
设置环境变量 GOARCH=amd64
直接通过docker -v 映射主机文件到docker镜像文件,在镜像中执行。
4.1.1 设置环境变量
GOARCH=amd64 go test --run TestWaitForServiceCreate -gcflags=all=-l
4.1.2 使用docker
docker中执行测试步骤如下
确保mac系统中以及安装了docker (docker对于大型企业收费,对于个人版还是免费,这里有需要也可以替换为 podman)
在代码当前目录执行
docker run -it --rm -v $PWD:/opt/www golang:1.20 /bin/bash
3. 进入系统中后切换到 /opt/www 目录执行
go test -v --run TestReadJsonFile -gcflags=all=-l
结果如下