文章目录
一、前言
1、单测的定位
单测在软件工程中的地位毋庸置疑,它要求工程师必须去主动思考代码的边界,异常处理等等。另一方面,它又是代码最好的说明书,你的函数具体做了什么,输入和输出一目了然。
计算机科学家Edsger Dijkstra曾说过:“测试能证明缺陷存在,而无法证明没有缺陷。”再多的测试也不能证明一个程序没有BUG。在最好的情况下,测试可以增强我们的信心:代码在很多重要场景下是可以正常工作的。
参考:go语言圣经之测试函数
2、vscode中生成单测
参考:在 VS Code 快速生成单元测试
vscode
生成单元测试如下,我们需要编写测试用例数组,明确指出来want结果以及wantErr,通过遍历的方式去执行测试用例数组。
funcTestGenerateStsTokenService(t *testing.T){type args struct{
ctx context.Context
generateStsData *dto.GenerateStsReqParams
}
tests :=[]struct{
name string
args args
wantResp *common.RESTResp
wantErr bool}{{
name:"测试正常生成sts",
args: args{
ctx: context.TODO(),
generateStsData:&dto.GenerateStsReqParams{
SessionName:"webApp",
AuthParams:&dto.AuthParamsData{},},},
wantResp:&common.RESTResp{
Code:0,
Data:&dto.OssStsRespData{},},
wantErr:false,},{
name:"测试异常生成sts",
args: args{
ctx: context.TODO(),
generateStsData:&dto.GenerateStsReqParams{
SessionName:"liteApp",
AuthParams:&dto.AuthParamsData{},},},
wantResp:&common.RESTResp{
Code:20003,
Data:interface{}(nil),},
wantErr:true,},}for_, tt :=range tests {
t.Run(tt.name,func(t *testing.T){
gotResp, err :=GenerateStsTokenService(tt.args.ctx, tt.args.generateStsData)if(err !=nil)!= tt.wantErr {
t.Errorf("GenerateStsTokenService() error = %v, wantErr %v", err, tt.wantErr)return}if!reflect.DeepEqual(gotResp, tt.wantResp){
t.Errorf("GenerateStsTokenService() = %v, want %v", gotResp, tt.wantResp)}})}}
二、构造测试case的注意事项
1、项目初始化
// TestMain会在执行其他测试用例的时候,自动执行funcTestMain(m *testing.M){setup()//初始化函数
retCode := m.Run()// 运行单元测试teardown()//后置校验,钩子函数,可不实现
os.Exit(retCode)//清理结果}
2、构造空interface{}
// 直接给Data赋值为nil的话,验证会失败,// 单纯的nil和(*infra.QueryOneMappingCode)(nil)是不一样的
wantResp:&common.RESTResp{
Code:0,
Message:"",
Data:(*infra.QueryOneMappingCode)(nil),},// 数组类型的空// []dto.OneMappingCode{}也会验证失败
wantRes:[]dto.OneMappingCode(nil),
3、构造结构体的time.Time类型
Data:&infra.xxx{
ID:54,
Code:"338798",
TakerUid:"",
State:1,
Type:1,
CreatedAt: time.Date(2023, time.June,9,16,32,59,0, time.Local),},
也可以直接打印接口的返回,看看CreatedAt返回的是什么,然后构造一下就可以。
t.Logf("gotResp:(%#v)", gotResp.Data)
4、构造json格式的test case
wantResp:&common.RESTResp{
Code:0,
Message:"success",
Data:`{
"id": 54,
"code": "338798",
"creator_uid": "12345",
"client_appId": "1234",
"taker_uid": "",
"state": 1,
"type": 1,
"created_at": "2023-06-09T16:32:59+08:00"
}`,},
三、运行单测文件
1、整体运行单测文件
cd /xxx 单测目录
go test
成功输出:
PASS
ok
2、运行单个单测文件报错
错误提示如下:
# command-line-arguments [command-line-arguments.test]./base_test.go:26:18: undefined: Ping
明明
Ping
函数和单测文件都在同一个包下面,为什么会出现
undefined
呢?
command-line-arguments
是什么?
答:
(1)command-line-arguments是什么
go test [flags][packages][build flags][packages]
命令行参数中指定的每个包或文件都将被视为一个要进行测试的包。而 "command-line-arguments"
这个标识符就是用来表示上述情况中命令行参数中指定的文件。
这样可以使 go test 命令将指定的文件作为单独的包进行处理,并执行其中的测试函数。
(2)undefined发生原因
错误提示build失败,也就是说我们需要把单测文件依赖的文件也传入进去。比如我这里单测base_test.go文件,则需要把base.go也写到命令行参数中。
具体参考:【Golang】解决Go test执行单个测试文件提示未定义问题
go test ./base.go./base_test.go
(3)缺少初始化导致的发生panic
一般来说我们在一个package下,定义一个TestMain()函数就可以了,进行代码的初始化。但是当我们需要运行单个测试文件的时候,有可能这个测试文件里面恰好没有TestMain()了咋整。
api_test.goTestMain()
base_test.go// 没有TestMain()函数// 解决方案1、初始化代码放到setup()函数中
2、go命令行
go test ./base.go./base_test.go./api_test.go./api.go3、只想运行base_test.go怎么办
base_test.go中加上自己的setuoBase()
3、查看单测覆盖率
go test -cover
coverage:80.4% of statements
4、单测覆盖文件解读
go test -coverprofile=coverage.out
// 打开单测覆盖率文件
mode: set
base.go:10.118,14.2331
base.go:14.23,17.321
解释如下:
10.118,14.2331 表示第 10 行到第 14 行代码被测试覆盖到了,且覆盖
率为 3/1(即 300%)。这是因为第 10 行至少执行了一次,如果执行了三次,则覆盖率为 300%。
14.23,17.321 表示第 14 行到第 17 行代码被测试覆盖到了,且覆盖率为 2/1(即 200%)。
5、生成可被浏览器打开的单测文件
go test -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
绿色代表被覆盖到的代码,红色代表没有被覆盖到的代码。
左上角是运行单测命令目录下,所有go文件的覆盖率。
可以考虑新增单测case来覆盖到这部分红色。
6、单测覆盖率的问题
覆盖率为
100%
表示测试用例覆盖了所有的可能执行路径,即程序的所有功能都被覆盖到了。而覆盖率高于
100%
则表示相同的代码路径被多次测试或某些代码行在被测试期间被执行了多次。
但是单测
100%并
不能保证没有
bug
,只能保证写出来的代码没问题,但逻辑或者业务上的漏洞是检测不到的。
博主在滴滴的组是建议单测覆盖率
50%
以上,其他朋友的公司要求核心接口必须有单测,整体单测覆盖率
30%
以上。有需要的可以参考下。
四、关于单测粒度的问题
写单测的时候,总会疑问到底要写的多细呢?特别是原来项目没有单测的时候,补单测的代码比业务逻辑代码还多。。。
本例中,目录结构如下:
domain:
base.go
code.go
code_test.go
util.go
code.go
会调用
base.go
和
util.go
的函数,运行
code_test.go
发现单测覆盖率
已经
80%
了,是不是意味着只需要写个
code_test.go
就可以了呢?
1、chatgpt的回答
实际上不是的,
base.go
和
util.go
后续还可能被其他的文件使用,我们写单测的时候,应该尽量覆盖所有的异常情况,也就是程序的边界问题。因此
base.go
和
util.go
也需要做对应的单测,这样才能得到高质量的代码。
2、个人理解
单个
code_test.go
文件导致的问题是下层函数不
mock
,可能会影响到实际的数据,导致单测只能运行一次,而不能一直
PASS
。其次是代码流程变长导致单测
case
越写越多,接近集成测试了,这不是我们单测的目标。
把
code_test.go
中关于
base.go
和
util.go
的函数都给
mock
掉,发现单测覆盖率只有
37%
,且测试路径比较短。还需要分别写
base_test.go
和util_test.go,写完
util_test.go
单测覆盖率立马
82%
。
拆分的粒度变细,更加关注每个函数的输入和输出。特别是当修改某个函数的时候,只需要使用对应的单测来进行验证,而不需要从入口处进行测试。毕竟单元测试不是集成测试。
参考:
Golang 单元测试:有哪些误区和实践?
Go的单元测试技巧
五、mock数据
在写单测的时候,程序难免会出现各种跨文件的函数调用,以及操作第三方中间件或者上下游交互的情况,这个时候
mock
就显得尤为重要。
想象下,没有
mock
的时候,我们运行单测可能就会写入一次数据库?或者对下游发起一次请求?这样的单测,怕是只能运行一次哟。
mock
的出现让我们关注代码的实现细节,不会担心会造成数据污染或者单测只能运行一遍就GG的情况。
1、mock组件选择
参考:如何做好单元测试?Golang Mock”三剑客“ gomock、monkey、sqlmock
GO进阶单元测试
博主这里更喜欢无侵入的
mock
,直接一把梭。可惜
monkey
已经不更新了,现在都是用
gomonkey
,国人大佬开发的
gomonkey 项目库
解析 Golang 测试(8)- gomonkey 实战
2、mock实操
(1)mock函数调用
函数中存在大量的封装调用,比如
A->B
,
A->C
这种,因此自由
mock B
和
C
函数对我们的单元测试来说还是很重要的。
patches := gomonkey.ApplyFunc(queryOneMappCode,func(ctx context.Context, code string)(*infra.QueryOneMappingCode,error){// 参数大于6则返回空iflen(code)>6{returnnil,nil}return&infra.QueryOneMappingCode{
ID:54,
Code:"338798",
CreatedAt: time.Date(2023, time.June,9,16,32,59,0, time.Local),},nil})defer patches.Reset()
(2)mock方法调用
1、实例化接口
var mockProvider = provider.Test
// 接口如下type TestDbProvider interface{SetDb(db *sqlx.DB)GetOne(dest interface{}, sql string, args interface{})(resp *infra.QueryOneMappingCode, err error)}2、mock对应的查询方法
// 注意,第一个参数不能是指针,不然mock会失效// 例如 var oss_bucket_obj *oss.Bucket ,传入target为: *oss_bucket_obj// 传地址会报错
patches := gomonkey.ApplyMethodFunc(mockProvider,"GetOne",func(dest interface{}, sql string, args interface{})(resp *infra.QueryOneMappingCode, err error){
code := args.(string)if code =="123456"{return&infra.QueryOneMappingCode{
ID:1,
Code:"123456",
CreatedAt: time.Date(2023, time.June,9,16,32,59,0, time.Local),},nil}elseif code =="456789"{return&infra.QueryOneMappingCode{
ID:1,
Code:"456789",
CreatedAt: time.Date(2023, time.June,9,16,32,59,0, time.Local),},nil}else{returnnil,nil}})defer patches.Reset()
(3)mock其他包的函数
在
xx_test
文件中直接引用其他包即可。一般
xx_test.go
和
xx.go
在同一个包下,所以也不用担心出现循环引用的问题。
patches := gomonkey.ApplyFunc(util.GenerateRandomCode,func(numDigits int)string{return"123456"})defer patches.Reset()
(4)mock循环中的函数
比如在A函数中,循环3次调用了B函数,那么mock如下:
createA :=&infra.CreateMappingCode{Code:"933903"}
createB :=&infra.CreateMappingCode{Code:"601690"}
createC :=&infra.CreateMappingCode{Code:"798493"}
p := gomonkey.ApplyFuncSeq(structureMappingCodeRecord,[]gomonkey.OutputCell{{Values: gomonkey.Params{createA}},{Values: gomonkey.Params{createB}},{Values: gomonkey.Params{createC}},})defer p.Reset()// 恢复原始函数
(5)mock http调用
// vscode自动生成的test代码for_, tt :=range tests {
t.Run(tt.name,func(t *testing.T){// mock httptest
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){if r.Method != http.MethodGet {
w.WriteHeader(http.StatusNotFound)}// 构造返回参数
w.WriteHeader(http.StatusOK)// 获取POST请求的参数,根据参数返回不同的响应
bodyBytes, err := io.ReadAll(r.Body)if err !=nil{// 处理错误
w.WriteHeader(http.StatusBadRequest)}// 获取post参数
params :=new(dto.GenerateStsReqParams)
json.Unmarshal(bodyBytes, params)// 根据传递的参数返回不同的响应
res :=new(common.RESTResp)if params.SessionName =="webApp"{
res =&common.RESTResp{
Code:0,
Message:"success",
Data:&dto.OssStsRespData{
Region:"hangzhou",
Bucket:"test",},}}else{
res =&common.RESTResp{
Code:1,
Message:"failed",
Data:&dto.OssStsRespData{},}}// 模拟接口的返回,http接口返回是字节数据,因此需要json.Marshal
jsonStr,_:= json.Marshal(res)
w.Write(jsonStr)}))defer ts.Close()// 替换原来的url为mock的url
GenerateOssStsUrl = ts.URL
// 发起请求,请求中的http会被mock掉
gotResp, err :=GenerateStsTokenService(tt.args.ctx, tt.args.generateStsData)if(err !=nil)!= tt.wantErr {
t.Errorf("GenerateStsTokenService() error = %v, wantErr %v", err, tt.wantErr)return}
t.Logf("gotResp:(%#v) ,wantResp:(%#v)", gotResp, tt.wantResp)if!reflect.DeepEqual(gotResp, tt.wantResp){
t.Errorf("GenerateStsTokenService() = %v, want %v", gotResp, tt.wantResp)}})}
3、对于mock的看法
对于
mock
,有以下两种态度
一方的人主张不要滥用mock,能不mock就不mock。被测单元也不一定是具体的一个
函数,可能是多个函数本来就应该串起来,必要的时候再mock。
一方则主张将被测函数所有调用的外面函数全部mock掉,只关注被测函数自己的
一行行代码,只要调用其他函数,全都mock掉,用假数据来测试。
本来处于懒惰和少写单测的角度,我是支持第一种方式的。
例如:
单测函数:A函数
内部逻辑:
A->B : B函数全是业务逻辑
A->C : C函数包括mysql或者redis操作
A->D->E: D函数纯业务逻辑,构造请求参数。E函数对外发起http请求
第一种方式是只
mock C
和
E
函数,测试
A
函数的时候,会把
B
和
D
也测试到。主打一个省事快捷。
直到我遇到了更复杂的场景,
B
里面还有
B1
和
B2
函数,
D
里面有
D1
和
D2
函数,逻辑非常复杂的情况下,第一种方式就变成了集成测试。单测用例慢慢变成了测试用例。 比如只修改
D2
函数的情况下,要修改和通过单测
A
进行测试。。。。
第二种方式,就是在每一层都
mock
掉外部调用。单测
A
就只关注
A
的逻辑,
mock
掉
B,C,D,E
,只关注
B,C,D,E
输出是正确或者错误的情况。
针对
B,C,D,E
函数又有自己的单测函数,充分覆盖掉。这样当修改
D2
函数的时候,只需要修改和通过
D2
的单测即可。
对于外部依赖,比如第三方库
mysql,redis,mq
这种统一进行
mock
。 对于内部的函数调用,建议是粒度细一些,
A_test.go
就只对
A.go
里面的逻辑负责。至于调用
B.go
的部分,就交给
B_test.go
吧。
end
版权归原作者 铁柱同学 所有, 如有侵权,请联系我们删除。