尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Go测试进阶:testify断言与gomock

Go测试进阶:testify断言与gomock Go测试进阶:testify断言与gomock摘要: 本篇讲解Go测试进阶实践使用testify/assert和testify/require简化断言代码用testify/suite组织测试套件管理共享状态借助gomock自动生成接口mock并验证调用行为统计测试覆盖率定位未测代码分享过度mock导致测试脆弱、接口一改几十个测试全崩的踩坑经验对比手写断言、testify和gomock三种方案。开篇故事去年接手一个老项目单测有2000多个用例改一行代码要跑半小时还经常挂红。打开测试文件一看满屏的if got ! want { t.Errorf(got %v want %v, got, want) }断言写得又长又重复。有个服务的依赖有20多个外部接口每个测试都要手写假实现几百行setup代码。后来引入testify做断言用gomock自动生成接口mock测试代码量砍掉一半可读性好了不少。但新问题跟着来团队里有人把所有依赖都mock掉连简单的字符串处理都mock结果业务逻辑一改几十个测试全挂修测试比写功能还累。mock用得好是利器用不好是负担。这篇把testify和gomock的用法写清楚也聊聊怎么避免过度mock。一、testify断言与requiretestify的assert包把那些重复的断言逻辑封装好了。assert.Equal一行搞定比较加报错assert.NoError检查错误assert.True检查布尔值。packagemainimport(errorsfmttestinggithub.com/stretchr/testify/assertgithub.com/stretchr/testify/require)// CalcDiscount 计算折扣价// price: 原价// discount: 折扣率0到1之间funcCalcDiscount(pricefloat64,discountfloat64)(float64,error){// 参数校验ifprice0{return0,errors.New(价格不能为负)}ifdiscount0||discount1{return0,errors.New(折扣必须在0到1之间)}returnprice*discount,nil}// TestCalcDiscount_Assert 使用assert断言// assert失败后测试继续执行收集所有失败信息funcTestCalcDiscount_Assert(t*testing.T){// 正常情况result,err:CalcDiscount(100,0.8)// 断言没有错误发生assert.NoError(t,err)// 断言结果相等失败时打印差异assert.Equal(t,80.0,result)// 异常情况: 负价格result,errCalcDiscount(-10,0.8)// 断言发生了错误assert.Error(t,err)// 断言result为零值assert.Equal(t,0.0,result)}// TestCalcDiscount_Require 使用require断言// require失败后立即停止当前测试避免后续空指针panicfuncTestCalcDiscount_Require(t*testing.T){result,err:CalcDiscount(100,0.8)// require.NoError失败立即停止后面的代码不执行// 适合前置条件检查避免nil解引用require.NoError(t,err)require.NotNil(t,result)// 只有上面的断言都通过才会执行到这里fmt.Println(断言通过继续验证)assert.Equal(t,80.0,result)}assert和require的区别要记住。assert失败后继续跑适合收集多个断言结果一次看完。require失败立即停止适合前置依赖检查比如require.NoError(t, err)之后再使用返回值避免nil解引用。二、testify/suite测试套件测试多了setup和teardown代码重复。suite把共享的初始化逻辑集中起来每个测试方法前后自动调用SetupTest和TearDownTest。packagemainimport(testinggithub.com/stretchr/testify/suite)// UserStore 假的存储接口用于演示typeUserStorestruct{datamap[string]string}funcNewUserStore()*UserStore{returnUserStore{data:make(map[string]string)}}func(s*UserStore)Set(key,valstring){s.data[key]val}func(s*UserStore)Get(keystring)(string,bool){v,ok:s.data[key]returnv,ok}func(s*UserStore)Close(){s.datanil}// UserSuite 测试套件内嵌suite.SuitetypeUserSuitestruct{suite.Suite store*UserStore// 每个测试方法共享的依赖}// SetupTest 每个测试方法执行前调用// 适合做数据初始化func(s*UserSuite)SetupTest(){s.storeNewUserStore()s.store.Set(init,ready)}// TearDownTest 每个测试方法执行后调用// 适合做资源清理func(s*UserSuite)TearDownTest(){s.store.Close()}// TestSet 测试写入func(s*UserSuite)TestSet(){s.store.Set(name,张三)// 用s.Assert()拿到断言对象val,ok:s.store.Get(name)s.Assert().True(ok)s.Assert().Equal(张三,val)}// TestGetNotExist 测试读取不存在的keyfunc(s*UserSuite)TestGetNotExist(){_,ok:s.store.Get(noexist)s.Assert().False(ok)}// TestInitData 测试初始化数据func(s*UserSuite)TestInitData(){// SetupTest已经写入了initval,ok:s.store.Get(init)s.Assert().True(ok)s.Assert().Equal(ready,val)}// TestUserSuite 入口函数suite.Run驱动所有Test开头的方法funcTestUserSuite(t*testing.T){suite.Run(t,new(UserSuite))}suite的好处是setup代码只写一次。每个Test方法独立运行互不干扰SetupTest保证每个方法拿到干净的初始状态。三、gomock接口mock生成gomock是Google开源的mock生成工具。给它一个接口定义自动生成完整的mock实现还能验证方法是否被调用、调用了几次、参数是什么。先定义接口和业务代码。packageservice// User 用户模型typeUserstruct{IDintNamestringAgeint}// UserRepo 用户仓储接口// 这个接口会被mock掉typeUserRepointerface{GetUser(idint)(*User,error)ListUsers(limitint)([]*User,error)}// UserService 业务服务依赖UserRepo接口typeUserServicestruct{repo UserRepo}funcNewUserService(repo UserRepo)*UserService{returnUserService{repo:repo}}// GetUserName 获取用户名返回名字或错误func(s*UserService)GetUserName(idint)(string,error){user,err:s.repo.GetUser(id)iferr!nil{return,err}returnuser.Name,nil}// ListAdultUsers 列出成年用户// 依赖ListUsers方法func(s*UserService)ListAdultUsers()([]*User,error){users,err:s.repo.ListUsers(100)iferr!nil{returnnil,err}// 过滤出成年用户varadults[]*Userfor_,u:rangeusers{ifu.Age18{adultsappend(adults,u)}}returnadults,nil}用mockgen生成mock代码。命令行执行:# 安装mockgen工具goinstallgo.uber.org/mock/mockgenlatest# 基于接口生成mock文件# -source指定接口所在文件# -destination指定输出路径mockgen-sourceuser.go-destinationmock_user.go-packageservice生成mock后写测试。packageserviceimport(errorstestinggithub.com/stretchr/testify/assertgo.uber.org/mock/gomock)// TestGetUserName 测试获取用户名funcTestGetUserName(t*testing.T){// 创建controller管理mock生命周期// ctrl.Finish验证所有EXPECT都满足ctrl:gomock.NewController(t)deferctrl.Finish()// 创建mock实例mockRepo:NewMockUserRepo(ctrl)// 设置期望: GetUser(1)被调用一次// 返回特定的User和nil错误mockRepo.EXPECT().GetUser(1).Return(User{ID:1,Name:张三,Age:25},nil).Times(1)service:NewUserService(mockRepo)name,err:service.GetUserName(1)// 验证结果assert.NoError(t,err)assert.Equal(t,张三,name)}// TestGetUserName_Error 测试获取用户名失败funcTestGetUserName_Error(t*testing.T){ctrl:gomock.NewController(t)deferctrl.Finish()mockRepo:NewMockUserRepo(ctrl)// 模拟数据库错误mockRepo.EXPECT().GetUser(999).Return(nil,errors.New(用户不存在)).Times(1)service:NewUserService(mockRepo)name,err:service.GetUserName(999)assert.Error(t,err)assert.Equal(t,,name)}// TestListAdultUsers 测试过滤成年用户funcTestListAdultUsers(t*testing.T){ctrl:gomock.NewController(t)deferctrl.Finish()mockRepo:NewMockUserRepo(ctrl)// gomock.Any匹配任意参数值// 适合不关心具体参数的场景mockRepo.EXPECT().ListUsers(gomock.Any()).Return([]*User{{ID:1,Name:张三,Age:25},{ID:2,Name:李四,Age:15},{ID:3,Name:王五,Age:30},},nil).Times(1)service:NewUserService(mockRepo)adults,err:service.ListAdultUsers()assert.NoError(t,err)// 15岁被过滤掉应该剩2个assert.Len(t,adults,2)assert.Equal(t,张三,adults[0].Name)assert.Equal(t,王五,adults[1].Name)}gomock的EXPECT能验证调用次数、参数匹配、调用顺序。Times(1)要求精确调用一次AnyTimes()不限次数Times(0)要求不调用。参数可以用具体值匹配也能用gomock.Any()匹配任意值。四、独家踩坑:过度mock导致测试脆弱这个坑我踩得深。有段时间团队规定所有外部依赖必须mock结果连一个简单的字符串格式化工具都被mock了。这个工具函数的逻辑改了一行40多个测试全红因为mock里写死了返回值。问题根源是把不该mock的东西也mock了。mock的代价是测试和实现绑定mock记录的是调用了什么一旦实现方式变了比如从调用FormatName改成直接fmt.Sprintfmock的EXPECT就不匹配测试就挂。判断该不该mock有个原则: mock有外部副作用的依赖不mock纯计算逻辑。数据库、HTTP接口、消息队列这些有副作用的mock掉。字符串处理、数学计算、数据转换这些纯函数直接用真实实现测。packageserviceimport(stringstestinggithub.com/stretchr/testify/assertgo.uber.org/mock/gomock)// FormatName 纯函数格式化用户名// 这种函数不该mock直接测真实逻辑funcFormatName(namestring)string{namestrings.TrimSpace(name)returnstrings.ToUpper(name)}// 错误做法: mock掉FormatName// 一旦FormatName实现改了测试全挂// func (s *UserService) GetDisplayName(id int) string {// user : s.repo.GetUser(id)// return FormatName(user.Name) // 这里不该mock// }// 正确做法: 只mock有副作用的repo// FormatName用真实实现跑funcTestGetDisplayName(t*testing.T){ctrl:gomock.NewController(t)deferctrl.Finish()mockRepo:NewMockUserRepo(ctrl)// 只mock数据访问格式化用真实逻辑mockRepo.EXPECT().GetUser(1).Return(User{ID:1,Name: zhang san ,Age:25},nil).Times(1)service:NewUserService(mockRepo)// GetDisplayName内部调用FormatName// 这里测的是FormatName的真实行为name:service.GetDisplayName(1)// TrimSpace去掉空格ToUpper转大写assert.Equal(t,ZHANG SAN,name)}测试覆盖率统计能帮我们发现没测到的代码。go test -cover输出覆盖率百分比go test -coverprofilecover.out生成详细报告go tool cover -htmlcover.out在浏览器里看哪些行没覆盖到。目标是核心逻辑覆盖率80%以上边界条件都测到。五、对比分析方案代码量可读性mock能力维护成本手写断言高差无低testify低好弱(suite/mock)低gomock中中强中手写断言代码冗长每个比较都要写if和Errorf适合极简场景。testify的assert和suite大幅减少样板代码适合大部分单元测试。gomock专攻接口mock自动生成代码调用行为验证精确适合依赖复杂的场景。实际项目里testify和gomock搭配用testify做断言gomock做接口mock效果最好。总结testify把断言代码从五行压到一行suite管理共享setup。gomock自动生成接口mockEXPECT验证调用行为。mock只mock有副作用的依赖纯函数用真实实现测。测试覆盖率帮你找到没测的代码。下一篇聊Go 1.18的原生Fuzz模糊测试看看怎么自动发现边界崩溃。
返回列表