1.单元测试中的困难
如果项目中没有单元测试,对于刚刚开始或者说是规模还小的项目来说,效率可能还不错。但是一旦项目变得复杂起来,每次新增功能或对旧功能的改动都要重新手动测试一遍所有场景,费时费力,而且还有可能因为疏忽导致漏掉一些覆盖不到的点。在这个基础上,单元测试的好处就显现了出来。在单元测试覆盖比较全面的项目中进行开发,不需要耗费大量的时间去手动测试;并且在重构的时候也可以很轻松的验证代码逻辑的正确性。
而在日常的开发中,想编写一个好的单元测试也是不容易的,因为一般我们的代码不是单纯的流程控制,有着统一规范的输入输出,大多数都是依赖着外部系统,例如:数据库,网络,第三方接口等等。对于这种情况,我们很难单纯通过Golang标准库去编写好的单元测试,这时候我们就需要借助第三方的Mock工具来帮助我们完成单元测试。
2.Web服务器Mock
httptest 是Go标准库提供的Web服务器Mock工具,让我们模拟一个场景来看看httptest是怎么Mock Web服务器的:
假设现在我们要开发一个公司内部的系统,让公司员工都可以直接使用,所以需要接入公司统一的登录接口,让我们来写一个简单的用户登录权限校验方法。
func ValidateUserAuth(username, password, authUrl string) bool {
body, _ := json.Marshal(struct {
Username string
Password string
}{username,
password})
//调用外部接口
request, _ := http.NewRequest(http.MethodPost, authUrl, bytes.NewReader(body))
client := http.Client{}
response, _ := client.Do(request)
//如果返回的状态码为200则表示用户验证成功
return response.StatusCode == http.StatusOK
}
其中,authUrl参数为公司统一的权限校验接口,判断用户是否为公司员工。当然,这种参数传递方式可能不会出现在真正的代码中,没关系,我们先看这种方式,后面会优化为其他方式。
如果我们要对这个方法写一个单元测试的话,我们肯定是不能传真正的authUrl来测试的,因为这是外部系统提供的接口,我们无法保证它的稳定性。因此用于模拟web服务器行为的httptest就派上用场了。
func TestValidateUserAuth(t *testing.T) {
//函数类型的变量,用户定义Web服务器行为
var handler http.HandlerFunc = func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(200)
writer.Write(nil)
}
mockAuthServer := httptest.NewServer(handler)
success := ValidateUserAuth("user", "passwd", mockAuthServer.URL)
assert.True(t, success)
}
调用htttest.NewServer方法,方法的第一个参数为一个函数类型,该函数的内容就是用户自定义的Web服务器行为。通过这种方式,httptest会创建一个http服务器并返回*httptest.Server类型的变量mockAuthServer来表示该服务器的基本信息,然后就可以通过将实际要访问的服务器地址替换为我们自己Mock的服务器的url(mockAuthServer.URL)来完成对第三方接口的mock。
尽管单纯使用httptest已经可以解决外部http调用的mock问题,但解决方式仍然不够优雅,在实际的项目中,我们还需要进行更深度的Mock。
现在我也找了很多测试的朋友,做了一个分享技术的交流群,共享了很多我们收集的技术文档和视频教程。
如果你不想再体验自学时找不到资源,没人解答问题,坚持几天便放弃的感受
可以加入我们一起交流。而且还有很多在自动化,性能,安全,测试开发等等方面有一定建树的技术大牛
分享他们的经验,还会分享很多直播讲座和技术沙龙
可以免费学习!划重点!开源的!!!
qq群号:310357728【暗号:csdn999】
3.非侵入式的打桩框架
gomonkey是一款打桩框架,打桩的意思就是创建一个模拟的目标结果对目标内容进行替换,并且不需要修改代码本身。gomonkey可以对函数、方法、全局变量、函数变量等打桩。
3.1 对函数打桩
上面我们举了一个使用httptest Mock第三方服务器接口的例子,但是在实际的开发中,我们一般不会这样在参数中传递外部服务的url,更多的可能是用读取配置文件的方式。
func GetAuthUrl() string {
//解析配置文件,返回对应的结构体
config := ParseConfig(configFilePath)
//返回配置文件中AuthUrl的值
return config.AuthUrl
}
我们定义了一个GetAuthUrl()方法用于获取第三方用户登陆服务的url。然后我们就可以将上面的权限验证代码稍作修改:
func ValidateUserAuth2(username, password string) bool {
//注意这行,我们通过调用GetAuthUrl()方法来获取第三方接口url
authUrl := GetAuthUrl()
body, _ := json.Marshal(struct {
Username string
Password string
}{username,
password})
request, _ := http.NewRequest(http.MethodPost, authUrl, bytes.NewReader(body))
client := http.Client{}
response, _ := client.Do(request)
return response.StatusCode == http.StatusOK
最后在测试的时候使用gomonkey完成对GetAuthUrl()方法的打桩,返回我们Mock好的http服务器的url:
func TestValidateUserAuth2(t *testing.T) {
//函数类型的变量,用户定义Web服务器行为
var handler http.HandlerFunc = func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(200)
writer.Write(nil)
}
mockAuthServer := httptest.NewServer(handler)
//1.对GetAuthUrl()方法进行打桩,返回mockAuthServer的url
patches := gomonkey.ApplyFunc(GetAuthUrl, func() string {
return mockAuthServer.URL
})
//2.在程序结束时恢复现场
defer patches.Reset()
success := ValidateUserAuth2("user", "passwd")
assert.True(t, success)
}
- gomonkey.ApplyFunc(target interface{}, double interface{})函数表示为一个函数打桩。
- gomonkey.ApplyFunc(target interface{}, double interface{})的第一个参数为函数的名称;第二个参数为用户Mock的函数,该函数用户替换目标函数的行为。
- 在执行patches.Reset()方法之后,gomonkey会还原现场信息,也就是不再为该函数打桩。
通过这种方式我们就可以在不侵入业务代码的时候实现单元测试。
3.2 对方法打桩
对方法的打桩使用方式和对函数打桩基本上是一样的,我们直接拿官方的例子来说明一下,就不再多做解释。
func TestApplyMethod(t *testing.T) {
slice := fake.NewSlice()
var s *fake.Slice
Convey("for succ", t, func() {
err := slice.Add(1)
So(err, ShouldEqual, nil)
//对方法打桩
patches := gomonkey.ApplyMethod(reflect.TypeOf(s), "Add", func(_ *fake.Slice, _ int) error {
return nil
})
defer patches.Reset()
err = slice.Add(1)
So(err, ShouldEqual, nil)
err = slice.Remove(1)
So(err, ShouldEqual, nil)
So(len(slice), ShouldEqual, 0)
})
}
gomonkey.ApplyMethod(target reflect.Type, methodName string, double interface{})表示对一个成员方法打桩。第一个参数为目标成员的类型;第二个参数为目标方法的名称;第三个参数为用户Mock的函数,该函数用户替换目标方法的行为,函数的参数和返回值都和被打桩方法保持一致。
注:目前gomonkey由于Golang反射的限制,不支持对私有成员方法打桩。
3.3 对全局变量打桩
var num = 1
func TestApplyGlobalVar(t *testing.T) {
patches := gomonkey.ApplyGlobalVar(&num, 100)
defer patches.Reset()
assert.Equal(t, num, 100)
}
- gomonkey.ApplyGlobalVar表示对一个变量打桩,第一个参数为变量指针,第二个参数为变量值。
4.数据库Mock
sqlmock是一个数据库操作Mock工具,它可以自定义SQL操作的行为,不需要连接真实的数据库,并且无需侵入代码逻辑。下面我们举几个例子:先定义一个Person结构体来和数据库中的person表相对应
type Person struct {
Id int64
Name string
Age int
}
然后,写两个基本的数据库操作:INSERT操作和SELECT操作
//向person表中插入一条记录
func InsertPerson(db *sql.DB, person *Person) (int64, error) {
sqlStr := "insert into person (name, age) values (?, ?)"
result, err := db.Exec(sqlStr, person.Name, person.Age)
if err != nil {
return -1, err
}
lastInsertId, _ := result.LastInsertId()
return lastInsertId, nil
}
//根据id查询对应的person记录
func QueryPersonById(db *sql.DB, id int64) (Person, error) {
sqlStr := "select name, age from person where id = ?"
result := Person{Id: id}
rows, err := db.Query(sqlStr, id)
if err != nil {
return result, err
}
if rows.Next(){
rows.Scan(&result.Name, &result.Age)
}
return result, nil
}
如果我们想要测试这两个方法的正确性,一般的做法可能就是连接真实的数据库然后去验证数据库的变化是否符合预期。这样我们的测试就直接依赖了外部的系统,sqlmock可以通过Mock数据库很好的解决这个问题。
func TestInsertPerson(t *testing.T) {
db, mock, _ := sqlmock.New()
mock.ExpectExec("insert into person").WillReturnResult(sqlmock.NewResult(100, 1))
p := &Person{
Name: "zhangsan",
Age: 23,
}
id, err := InsertPerson(db, p)
assert.Equal(t, id, int64(100))
assert.Nil(t, err)
}
- 通过sqlmock.New()方法我们可以得到一个*sql.DB结构体指针实例,我们在调用InsertPerson方法的时候只需要把真实数据库的连接替换为该结构体指针就可以实现对数据库的Mock。
- WillReturnResult方法表示自定义的数据库行为,参数为driver.Result类型,可以通过sqlmock.NewResult方法来构造。sqlmock.NewResult(100,1)的第一个参数表示该语句上一次插入的记录id为100,这对设置了自增id的表很有用;第二个参数表示该表通过这条语句收到影响的记录条数为1。
不只正常结果的返回,sqlmock还可以Mock数据库操作的异常情况:
func TestInsertPersonWithError(t *testing.T) {
db, mock, _ := sqlmock.New()
mock.ExpectExec("insert into person").WillReturnError(errors.New("database internal error"))
p := &Person{
Name: "zhangsan",
Age: 23,
}
id, err := InsertPerson(db, p)
assert.Equal(t, id, int64(-1))
assert.NotNil(t, err)
}
- 用法也非常简单,只需要将WillReturnResult方法替换为WillReturnError方法就可以了。
- 这个方式可以用来测试我们程序对异常情况的处理是否正确。
对于查询操作,sqlmock也可以很好的支持:
func TestQueryPerson(t *testing.T) {
db, mock, _ := sqlmock.New()
//构造返回记录
rows := sqlmock.NewRows([]string{"name", "age"}).
AddRow("zhangsan", 23)
mock.ExpectQuery("select name, age from person").WillReturnRows(rows)
person, err := QueryPersonById(db, 1)
assert.Nil(t, err)
assert.Equal(t, person.Name, "zhangsan")
assert.Equal(t, person.Age, 23)
}
- 使用sqlmock.NewRows(columns []string)方法可以构造数据库记录,参数为记录的字段。
- 然后通过AddRow(values ...driver.Value)方法添加记录的内容。
5.让测试变得更简洁
在Golang的标准库中没有提供断言功能,这让我们平时的测试很不方便。testify的assert包提供了完善的断言功能,使用方式也非常简单。熟悉Java的同学应该不会对下面的形式感到陌生。
使用方式:
func TestSomething(t *testing.T) {
// All these assertions pass
assert.Equal(t, "hello", "hello", "Values are equal")
assert.NotEqual(t, "hello", "world", "Values are different")
assert.Contains(t, "hello", "el", "String contains other given string")
assert.True(t, true, "Value is true")
assert.False(t, false, "Value is false")
// All these assertions fail
assert.Equal(t, "hello", "world", "Values are equal")
assert.NotEqual(t, "hello", "hello", "Values are different")
assert.Contains(t, "hello", "y", "String contains other given string")
assert.True(t, false, "Value is true")
assert.False(t, true, "Value is false")
}
6.总结
httptest可以帮助我们完成对Web服务器的Mock,sqlmock可以完成对数据库的Mock,这两个工具基本可以帮助我们完成绝大部分外部系统的Mock工作。但是,实际中的代码逻辑、层次等等都是多变的,我们很多情况下不能够很好的将httptest或是sqlmock的入口注入到代码中,这时候我们就需要用gomonkey动态的Mock一些函数、方法或是变量来帮助我们开启httptest或sqlmock的入口。而testify则是帮助我们在繁琐的判断和错误处理中脱离出来的一大利器。在这几个测试库的帮助下,我们便可以写出一手优雅漂亮的测试代码了。
END今天的分享到此结束了!点赞关注不迷路!