golang errors 取 错误 信息_Golang 单元测试:有哪些误区和实践?
![](/assets/blank.gif)
背景
测试是保证代码质量的有效手段,而单元测试是程序模块儿的最小化验证。单元测试的重要性是不言而喻的。相对手工测试,单元测试具有自动化执行、可自动回归,效率较高的特点。对于问题的发现效率,单测的也相对较高。在开发阶段编写单测 case ,daily push daily test,并通过单测的成功率、覆盖率来衡量代码的质量,能有效保证项目的整体质量。
![](/assets/blank.gif)
单测准则
什么是好的单测?阿里巴巴的《Java 开发手册》(点击下载)中描述了好的单测的特征:
- A(Automatic,自动化):单元测试应该是全自动执行的,并且非交互式的。
- I:(Independent,独立性):为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
- R:(Repeatable,可重复):单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
单测应该是可重复执行的,对外部的依赖、环境的变化要通过 mock 或其他手段屏蔽掉。
在 On the architecture for unit testing[1]中对好的单测有以下描述:
- 简短,只有一个测试目的
- 简单,数据构造、清理都很简单
- 快速,执行函数秒级执行
- 标准,遵守严格的约定(准备测试上下文,执行关键操作,验证结果)
单测的误区
- 没有断言。没有断言的单测是没有灵魂的。如果只是 print 出结果,单测是没有意义的。
- 不接入持续集成。单测不应该是本地的 run once ,而应该接入到研发的整个流程中,合并代码,发布上线都应该触发单测执行,并且可以重复执行。
- 粒度过大。单测粒度应该尽量小,不应该包含过多计算逻辑,尽量只有输入,输出和断言。
很多人不愿意写单测,是因为项目依赖很多,各个函数之间各种调用,不知道如何在一个隔离的测试环境下进行测试。
在实践中我们调研了几种隔离(mock)的手段。下面进行逐一介绍。
单测实践
本次实践的工程项目是一个 http(基于 gin 的http 框架) 的服务。以入口的 controller 层的函数为被测函数,介绍下对它的单测过程。下面的函数的作用是根据工号输出该用户下的代码仓库的 CodeReview 数据。
可以看到这个函数作为入口层还是比较简单的,只是做了一个参数校验后调用下游并将结果透出。
func ListRepoCrAggregateMetrics(c *gin.Context) { workNo := c.Query("work_no") if workNo == "" { c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrorWarpper(errors.ErrParamError.ErrorCode, "work no miss"), nil)) return } crCtx := code_review.NewCrCtx(c) rsp, err := crCtx.ListRepoCrAggregateMetrics(workNo) if err != nil { c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrorWarpper(errors.ErrDbQueryError.ErrorCode, err.Error()), rsp)) return } c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrSuccess, rsp))}
它的结果大致如下:
{ "data": { "total": 10, "code_review": [ { "repo": { "project_id": 1, "repo_url": "test" }, "metrics": { "code_review_rate": 0.0977918, "thousand_comment_count": 0, "self_submit_code_review_rate": 0, "average_merge_cost": 30462.584, "average_accept_cost": 30388.75 } } ] }, "errorCode": 0, "errorMsg": "成功"}
针对这个函数测试,我们预期覆盖以下场景:
- workNo 为空时报错。
- workNo 不为空时范围 ,下游调用成功,repos cr 聚合数据。
- workNo 不为空,下游失败,返回报错信息。
方案一:不 mock 下游, mock 依赖存储 (不建议)
这种方式是通过配置文件,将依赖的存储都连接到本地(比如 sqlite , redis)。这种方式下游没有 mock 而是会继续调用。
var db *gorm.DBfunc getMetricsRepo() *model.MetricsRepo { repo := model.MetricsRepo{ ProjectID: 2, RepoPath: "/", FileCount: 5, CodeLineCount: 76, OwnerWorkNo: "999999", } return &repo}func getTeam() *model.Teams { team := model.Teams{ WorkNo: "999999", } return &team}func init() { db, err := gorm.Open("sqlite3", "test.db") if err != nil { os.Exit(-1) } db.Debug() db.DropTableIfExists(model.MetricsRepo{}) db.DropTableIfExists(model.Teams{}) db.CreateTable(model.MetricsRepo{}) db.CreateTable(model.Teams{}) db.FirstOrCreate(getMetricsRepo()) db.FirstOrCreate(getTeam())}type RepoMetrics struct { CodeReviewRate float32 `json:"code_review_rate"` ThousandCommentCount uint `json:"thousand_comment_count"` SelfSubmitCodeReviewRate float32 `json:"self_submit_code_review_rate"` }type RepoCodeReview struct { Repo repo.Repo `json:"repo"` RepoMetrics RepoMetrics `json:"metrics"`}type RepoCrMetricsRsp struct { Total int `json:"total"` RepoCodeReview []*RepoCodeReview `json:"code_review"`}func TestListRepoCrAggregateMetrics(t *testing.T) { w := httptest.NewRecorder() _, engine := gin.CreateTestContext(w) engine.GET("/api/test/code_review/repo", ListRepoCrAggregateMetrics) req, _ := http.NewRequest("GET", "/api/test/code_review/repo?work_no=999999", nil) engine.ServeHTTP(w, req) assert.Equal(t, w.Code, 200) var v map[string]RepoCrMetricsRsp json.Unmarshal(w.Body.Bytes(), &v) assert.EqualValues(t, 1, v["data"].Total) assert.EqualValues(t, 2, v["data"].RepoCodeReview[0].Repo.ProjectID) assert.EqualValues(t, 0, v["data"].RepoCodeReview[0].RepoMetrics.CodeReviewRate)}
上面的代码,我们没有对被测代码做改动。但是在运行 go test 进行测试时,需要指定配置到测试配置。被测项目是通过环境变量设置的。
RDSC_CONF=$sourcepath/test/data/config.yml go test -v -cover=true -coverprofile=$sourcepath/cover/cover.cover ./...
- 初始化测试环境,清空DB数据,写入被测数据。
- 执行测试方法。
- 断言测试结果。
方案二:下游通过 interface 被 mock(推荐)
gomock[2] 是 Golang 官方提供的 Go 语言 mock 框架。它能够很好的和 Go testing 模块儿结合,也能用于其他的测试环境中。Gomock 包括依赖库 gomock 和接口生成工具 mockgen 两部分,gomock 用于完成桩对象的管理, mockgen 用于生成对应的 mock 文件。
type Foo interface { Bar(x int) int}func SUT(f Foo) { // ...}ctrl := gomock.NewController(t) // Assert that Bar() is invoked. defer ctrl.Finish() //mockgen -source=foo.g m := NewMockFoo(ctrl) // Asserts that the first and only call to Bar() is passed 99. // Anything else will fail. m. EXPECT(). Bar(gomock.Eq(99)). Return(101)SUT(m)
上面的例子,接口 Foo 被 mock。回到我们的项目,在我们上面的被测代码中是通过内部声明对象进行调用的。使用 gomock 需要修改代码,把依赖通过参数暴露出来,然后初始化时。下面是修改后的被测函数:
type RepoCrCRController struct { c *gin.Context crCtx code_review.CrCtxInterface}func NewRepoCrCRController(ctx *gin.Context, cr code_review.CrCtxInterface) *TeamCRController { return &TeamCRController{c: ctx, crCtx: cr}}func (ctrl *RepoCrCRController)ListRepoCrAggregateMetrics(c *gin.Context) { workNo := c.Query("work_no") if workNo == "" { c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrorWarpper(errors.ErrParamError.ErrorCode, "员工工号信息错误"), nil)) return } rsp, err := ctrl.crCtx.ListRepoCrAggregateMetrics(workNo) if err != nil { c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrorWarpper(errors.ErrDbQueryError.ErrorCode, err.Error()), rsp)) return } c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrSuccess, rsp))}
这样通过 gomock 生成 mock 接口可以进行测试了:
func TestListRepoCrAggregateMetrics(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() m := mock.NewMockCrCtxInterface(ctrl) resp := &code_review.RepoCrMetricsRsp{ } m.EXPECT().ListRepoCrAggregateMetrics("999999").Return(resp, nil) w := httptest.NewRecorder() ctx, engine := gin.CreateTestContext(w) repoCtrl := NewRepoCrCRController(ctx, m) engine.GET("/api/test/code_review/repo", repoCtrl.ListRepoCrAggregateMetrics) req, _ := http.NewRequest("GET", "/api/test/code_review/repo?work_no=999999", nil) engine.ServeHTTP(w, req) assert.Equal(t, w.Code, 200) got := gin.H{} json.NewDecoder(w.Body).Decode(&got) assert.EqualValues(t, got["errorCode"], 0)}
方案三:通过 monkey patch 方式 mock 下游 (推荐)
在上面的例子中,我们需要修改代码来实现 interface 的mock,对于对象成员函数,无法进行 mock。monkey patch 通过运行时对底层指针内容修改的方式,实现对 instance method 的 mock (注意,这里要求 instance 的 method 必须是可以暴露的)。用 monkey 方式测试如下:
func TestListRepoCrAggregateMetrics(t *testing.T) { w := httptest.NewRecorder() _, engine := gin.CreateTestContext(w) engine.GET("/api/test/code_review/repo", ListRepoCrAggregateMetrics) var crCtx *code_review.CrCtx repoRet := code_review.RepoCrMetricsRsp{ } monkey.PatchInstanceMethod(reflect.TypeOf(crCtx), "ListRepoCrAggregateMetrics", func(ctx *code_review.CrCtx, workNo string) (*code_review.RepoCrMetricsRsp, error) { if workNo == "999999" { repoRet.Total = 0 repoRet.RepoCodeReview = []*code_review.RepoCodeReview{} } return &repoRet, nil }) req, _ := http.NewRequest("GET", "/api/test/code_review/repo?work_no=999999", nil) engine.ServeHTTP(w, req) assert.Equal(t, w.Code, 200) var v map[string]code_review.RepoCrMetricsRsp json.Unmarshal(w.Body.Bytes(), &v) assert.EqualValues(t, 0, v["data"].Total) assert.Len(t, v["data"].RepoCodeReview, 0)}
存储层 mock
Go-sqlmock 可以针对接口 sql/driver[3] 进行 mock。它可以不用真实的 db ,而模拟 sql driver 行为,实现强大的底层数据测试。下面是我们采用 table driven[4] 写法来进行数据相关测试的例子。
package storeimport ( "database/sql/driver" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" "github.com/jinzhu/gorm" "github.com/stretchr/testify/assert" "net/http/httptest" "testing")type RepoCommitAndCRCountMetric struct { ProjectID uint `json:"project_id"` RepoCommitCount uint `json:"repo_commit_count"` RepoCodeReviewCommitCount uint `json:"repo_code_review_commit_count"`}var ( w = httptest.NewRecorder() ctx, _ = gin.CreateTestContext(w) ret = []RepoCommitAndCRCountMetric{})func TestCrStore_FindColumnValues1(t *testing.T) { type fields struct { g *gin.Context db func() *gorm.DB } type args struct { table string column string whereAndOr []SqlFilter group string out interface{} } tests := []struct { name string fields fields args args wantErr bool checkFunc func() }{ { name: "whereAndOr is null", fields: fields{ db: func() *gorm.DB { sqlDb, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) rs1 := sqlmock.NewRows([]string{"project_id", "repo_commit_count", "repo_code_review_commit_count"}).FromCSVString("1, 2, 3") mock.ExpectQuery("SELECT project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count FROM `metrics_repo_cr` GROUP BY project_id").WillReturnRows(rs1) gdb, _ := gorm.Open("mysql", sqlDb) gdb.Debug() return gdb }, }, args: args{ table: "metrics_repo_cr", column: "project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count", whereAndOr: []SqlFilter{}, group: "project_id", out: &ret, }, checkFunc: func() { assert.EqualValues(t, 1, ret[0].ProjectID, "project id should be 1") assert.EqualValues(t, 2, ret[0].RepoCommitCount, "RepoCommitCount id should be 2") assert.EqualValues(t, 3, ret[0].RepoCodeReviewCommitCount, "RepoCodeReviewCommitCount should be 3") }, }, { name: "whereAndOr is not null", fields: fields{ db: func() *gorm.DB { sqlDb, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) rs1 := sqlmock.NewRows([]string{"project_id", "repo_commit_count", "repo_code_review_commit_count"}).FromCSVString("1, 2, 3") mock.ExpectQuery("SELECT project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count FROM `metrics_repo_cr` WHERE (metrics_repo_cr.project_id in (?)) GROUP BY project_id"). WithArgs(driver.Value(1)).WillReturnRows(rs1) gdb, _ := gorm.Open("mysql", sqlDb) gdb.Debug() return gdb }, }, args: args{ table: "metrics_repo_cr", column: "project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count", whereAndOr: []SqlFilter{ { Condition: SQLWHERE, Query: "metrics_repo_cr.project_id in (?)", Arg: []uint{1}, }, }, group: "project_id", out: &ret, }, checkFunc: func() { assert.EqualValues(t, 1, ret[0].ProjectID, "project id should be 1") assert.EqualValues(t, 2, ret[0].RepoCommitCount, "RepoCommitCount id should be 2") assert.EqualValues(t, 3, ret[0].RepoCodeReviewCommitCount, "RepoCodeReviewCommitCount should be 3") }, }, { name: "group is null", fields: fields{ db: func() *gorm.DB { sqlDb, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) rs1 := sqlmock.NewRows([]string{"project_id", "repo_commit_count", "repo_code_review_commit_count"}).FromCSVString("1, 2, 3") mock.ExpectQuery("SELECT project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count FROM `metrics_repo_cr` WHERE (metrics_repo_cr.project_id in (?))"). WithArgs(driver.Value(1)).WillReturnRows(rs1) gdb, _ := gorm.Open("mysql", sqlDb) gdb.Debug() return gdb }, }, args: args{ table: "metrics_repo_cr", column: "project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count", whereAndOr: []SqlFilter{ { Condition: SQLWHERE, Query: "metrics_repo_cr.project_id in (?)", Arg: []uint{1}, }, }, group: "", out: &ret, }, checkFunc: func() { assert.EqualValues(t, 1, ret[0].ProjectID, "project id should be 1") assert.EqualValues(t, 2, ret[0].RepoCommitCount, "RepoCommitCount id should be 2") assert.EqualValues(t, 3, ret[0].RepoCodeReviewCommitCount, "RepoCodeReviewCommitCount should be 3") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cs := &CrStore{ g: ctx, } db = tt.fields.db() if err := cs.FindColumnValues(tt.args.table, tt.args.column, tt.args.whereAndOr, tt.args.group, tt.args.out); (err != nil) != tt.wantErr { t.Errorf("FindColumnValues() error = %v, wantErr %v", err, tt.wantErr) } tt.checkFunc() }) }}
持续集成
Aone (阿里内部项目协作管理平台)提供了类似 travis-ci[5] 的功能:测试服务[6]。我们可以通过创建单测类型的任务或者直接使用实验室进行单测集成。
# 执行测试命令mkdir -p $sourcepath/coverRDSC_CONF=$sourcepath/config/config.yaml go test -v -cover=true -coverprofile=$sourcepath/cover/cover.cover ./...ret=$?; if [[ $ret -ne 0 && $ret -ne 1 ]]; then exit $ret; fi
增量覆盖率可以通过 gocov/gocov-xml 转换成 xml 报告,然后通过 diff_cover 输出增量报告:
cp $sourcepath/cover/cover.cover /root/cover/cover.coverpip install diff-cover==2.6.1gocov convert cover/cover.cover | gocov-xml > coverage.xmlcd $sourcepathdiff-cover $sourcepath/coverage.xml --compare-branch=remotes/origin/develop > diff.out
设置触发的集成阶段:
![](/assets/blank.gif)
参考资料
[1]https://thomasvilhena.com/2020/04/on-the-architecture-for-unit-testing
[2]https://github.com/golang/mock
[3]https://godoc.org/database/sql/driver
[4]https://github.com/golang/go/wiki/TableDrivenTests
[5]https://travis-ci.org/
[6]https://help.aliyun.com/document_detail/64021.html
来源:阿里云开发者社区
golang errors 取 错误 信息_Golang 单元测试:有哪些误区和实践?相关推荐
- vue 启动报错,但是没有错误信息 Failed to compile with 1 errors
vue webpack2.0 有报错但是没有错误提示 完全看不到错误信息,懵逼 昨天还运行好好的项目,今天一来启动搞这 搜了下码友们遇到类似的问题: npm run build 打印出详细信息: 意思 ...
- 怎样用springboot开发cs_springboot开发之配置自定义的错误界面和错误信息
如何定制错误页面? (1)在有模板引擎的情况下:在template文件夹下的error/状态码:即将错误页面命名为:错误状态码.html放在template文件夹里面的error文件夹下,发生此状态码 ...
- AngularJS 表单数据验证及错误信息提示
一.表单验证基本原理 表单验证包括两个主题: 定义验证规则,验证数据有效性. 显示验证结果,把验证结果以友好的方式显示给用户. H5内置一些验证功能,并会显示内置的错误提示信息,先要禁用它,在< ...
- oracle 配置数据库错误,Oracle数据库配置错误信息解决方法
Oracle数据库配置错误信息 Oralce数据库的错误信息经常会出现,我们看见的都是错误的代码,至于错误原因究竟是什么还一时半会难以解答,所以就把一些常见的错误整理了一下,来看看也许对你有帮助的. ...
- 后盾网lavarel视频项目---页面post方式提交之后动态弹出错误信息
后盾网lavarel视频项目---页面post方式提交之后动态弹出错误信息 一.总结 一句话总结: 1.思路和我想的一样,有错误的时候弹出提示错误消息的模态框就好,没有错误的时候不管它 2.把模态框的 ...
- 利用JSON-schema校验请求报文,封装转换错误信息,提示前台
JSON-chema的语法就不讲述了,可自行查阅相关文档. 需求场景:前台请求接口的报文,为防止被非法拦截,需要后台再校验一遍报文合法性,之前都是在java代码中,用java代码来判断,查阅资料找到了 ...
- 项目上线,php的错误信息必须不让其在页面中显示给客户,
对于PHP开发者来 说,一旦某个产品投入使用,应该立即将 display_errors选项关闭,以免因为这些错误所透露的路径.数据库连接.数据表等信息而遭到黑客攻击.但是,任何一个产品在投入使用后,都 ...
- ios请求php接口失败,laravel,php_iOS调用Laravel接口返回错误信息,laravel,php,ios - phpStudy...
iOS调用Laravel接口返回错误信息 iOS端代码如下: AFHTTPRequestOperationManager *manage = [[AFHTTPRequestOperationManag ...
- SpringMVC+HibernateValidator,配置在properties文件中的错误信息回显前端页面出现中文乱码
问题: 后台在springMVC中使用hibernate-validator做参数校验的时候(validator具体使用方法见GOOGLE),用properties文件配置了校验失败的错误信息.发现回 ...
最新文章
- ueditor上传组件显示乱码_最全面的移动端 UI组件设计详解:中篇
- centos yum update 报错 Delta RPMs disabled because /usr/bin/applydeltarpm not installed 解决方法
- 【数字信号处理】线性常系数差分方程 ( 概念 | 线性常系数差分方程解法 )
- Python MyQR
- html中视频变圆角,圆形视频和圆角视频的一种实现方式
- Android_动态壁纸介绍
- 【整体二分】区间第k小(金牌导航 整体二分-1)
- Android(1)—Mono For Android 环境搭建及破解
- php redis 封装类,php redis封装类
- UIFont 字体族
- 奇怪的google博客搜索
- STM32-DMA控制器
- 如何创建您自己的I爱纽约T恤
- 计算机无纸化考试知识点,2012重庆无纸化考试《会计电算化》知识点:计算机软件...
- 程序员双手飞快敲键盘的时候是在敲代码吗?
- Xcode 模拟器如何录屏
- 哪个相机可以拍gif动图_魅族手机如何拍摄Gif图片 魅族手机拍摄Gif动图的方法图解...
- android电梯程序设计,[源码和文档分享]基于Android实现的电梯调度模拟
- 94.(leaflet之家)leaflet态势标绘-进攻方向绘制(燕尾)
- Mac无法写入移动硬盘 这些软件帮你解决
热门文章
- 计算机软考有学历限制吗,软考中级职称申请积分还需要学历吗?
- java小白会有那些工作_Java小白找工作与学习的第四天
- html input p,我想在input.phtml中创建下拉框。 (不要使用zend_form)
- Asterisk队列(Queue)振铃方式(ring strategy)
- antd vue form 手动校验_参与《开课吧》vue训练营笔记(Day1)
- SpringMVC遇到的问题——GET http://localhost/spring_mvc_war_exploded/js/jquery-3.3.1.js net::ERR_ABORTED 404
- 【转】.Net中的异步编程总结
- 【转】刨根究底字符编码之十六——Windows记事本的诡异怪事:微软为什么跟联通有仇?
- C#使用Redis的基本操作
- WSS 数据库表中的 UserInfo 表中的 tp_SystemId 字段的使用