golang errors 取 错误 信息_Golang 单元测试:有哪些误区和实践?
背景
測(cè)試是保證代碼質(zhì)量的有效手段,而單元測(cè)試是程序模塊兒的最小化驗(yàn)證。單元測(cè)試的重要性是不言而喻的。相對(duì)手工測(cè)試,單元測(cè)試具有自動(dòng)化執(zhí)行、可自動(dòng)回歸,效率較高的特點(diǎn)。對(duì)于問(wèn)題的發(fā)現(xiàn)效率,單測(cè)的也相對(duì)較高。在開(kāi)發(fā)階段編寫單測(cè) case ,daily push daily test,并通過(guò)單測(cè)的成功率、覆蓋率來(lái)衡量代碼的質(zhì)量,能有效保證項(xiàng)目的整體質(zhì)量。
單測(cè)準(zhǔn)則
什么是好的單測(cè)?阿里巴巴的《Java 開(kāi)發(fā)手冊(cè)》(點(diǎn)擊下載)中描述了好的單測(cè)的特征:
- A(Automatic,自動(dòng)化):單元測(cè)試應(yīng)該是全自動(dòng)執(zhí)行的,并且非交互式的。
- I:(Independent,獨(dú)立性):為了保證單元測(cè)試穩(wěn)定可靠且便于維護(hù),單元測(cè)試用例之間決不能互相調(diào)用,也不能依賴執(zhí)行的先后次序。
- R:(Repeatable,可重復(fù)):單元測(cè)試通常會(huì)被放到持續(xù)集成中,每次有代碼check in時(shí)單元測(cè)試都會(huì)被執(zhí)行。如果單測(cè)對(duì)外部環(huán)境(網(wǎng)絡(luò)、服務(wù)、中間件等)有依賴,容易導(dǎo)致持續(xù)集成機(jī)制的不可用。
單測(cè)應(yīng)該是可重復(fù)執(zhí)行的,對(duì)外部的依賴、環(huán)境的變化要通過(guò) mock 或其他手段屏蔽掉。
在 On the architecture for unit testing[1]中對(duì)好的單測(cè)有以下描述:
- 簡(jiǎn)短,只有一個(gè)測(cè)試目的
- 簡(jiǎn)單,數(shù)據(jù)構(gòu)造、清理都很簡(jiǎn)單
- 快速,執(zhí)行函數(shù)秒級(jí)執(zhí)行
- 標(biāo)準(zhǔn),遵守嚴(yán)格的約定(準(zhǔn)備測(cè)試上下文,執(zhí)行關(guān)鍵操作,驗(yàn)證結(jié)果)
單測(cè)的誤區(qū)
- 沒(méi)有斷言。沒(méi)有斷言的單測(cè)是沒(méi)有靈魂的。如果只是 print 出結(jié)果,單測(cè)是沒(méi)有意義的。
- 不接入持續(xù)集成。單測(cè)不應(yīng)該是本地的 run once ,而應(yīng)該接入到研發(fā)的整個(gè)流程中,合并代碼,發(fā)布上線都應(yīng)該觸發(fā)單測(cè)執(zhí)行,并且可以重復(fù)執(zhí)行。
- 粒度過(guò)大。單測(cè)粒度應(yīng)該盡量小,不應(yīng)該包含過(guò)多計(jì)算邏輯,盡量只有輸入,輸出和斷言。
很多人不愿意寫單測(cè),是因?yàn)轫?xiàng)目依賴很多,各個(gè)函數(shù)之間各種調(diào)用,不知道如何在一個(gè)隔離的測(cè)試環(huán)境下進(jìn)行測(cè)試。
在實(shí)踐中我們調(diào)研了幾種隔離(mock)的手段。下面進(jìn)行逐一介紹。
單測(cè)實(shí)踐
本次實(shí)踐的工程項(xiàng)目是一個(gè) http(基于 gin 的http 框架) 的服務(wù)。以入口的 controller 層的函數(shù)為被測(cè)函數(shù),介紹下對(duì)它的單測(cè)過(guò)程。下面的函數(shù)的作用是根據(jù)工號(hào)輸出該用戶下的代碼倉(cāng)庫(kù)的 CodeReview 數(shù)據(jù)。
可以看到這個(gè)函數(shù)作為入口層還是比較簡(jiǎn)單的,只是做了一個(gè)參數(shù)校驗(yàn)后調(diào)用下游并將結(jié)果透出。
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))}它的結(jié)果大致如下:
{ "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": "成功"}針對(duì)這個(gè)函數(shù)測(cè)試,我們預(yù)期覆蓋以下場(chǎng)景:
- workNo 為空時(shí)報(bào)錯(cuò)。
- workNo 不為空時(shí)范圍 ,下游調(diào)用成功,repos cr 聚合數(shù)據(jù)。
- workNo 不為空,下游失敗,返回報(bào)錯(cuò)信息。
方案一:不 mock 下游, mock 依賴存儲(chǔ) (不建議)
這種方式是通過(guò)配置文件,將依賴的存儲(chǔ)都連接到本地(比如 sqlite , redis)。這種方式下游沒(méi)有 mock 而是會(huì)繼續(xù)調(diào)用。
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)}上面的代碼,我們沒(méi)有對(duì)被測(cè)代碼做改動(dòng)。但是在運(yùn)行 go test 進(jìn)行測(cè)試時(shí),需要指定配置到測(cè)試配置。被測(cè)項(xiàng)目是通過(guò)環(huán)境變量設(shè)置的。
RDSC_CONF=$sourcepath/test/data/config.yml go test -v -cover=true -coverprofile=$sourcepath/cover/cover.cover ./...- 初始化測(cè)試環(huán)境,清空DB數(shù)據(jù),寫入被測(cè)數(shù)據(jù)。
- 執(zhí)行測(cè)試方法。
- 斷言測(cè)試結(jié)果。
方案二:下游通過(guò) interface 被 mock(推薦)
gomock[2] 是 Golang 官方提供的 Go 語(yǔ)言 mock 框架。它能夠很好的和 Go testing 模塊兒結(jié)合,也能用于其他的測(cè)試環(huán)境中。Gomock 包括依賴庫(kù) gomock 和接口生成工具 mockgen 兩部分,gomock 用于完成樁對(duì)象的管理, mockgen 用于生成對(duì)應(yīng)的 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。回到我們的項(xiàng)目,在我們上面的被測(cè)代碼中是通過(guò)內(nèi)部聲明對(duì)象進(jìn)行調(diào)用的。使用 gomock 需要修改代碼,把依賴通過(guò)參數(shù)暴露出來(lái),然后初始化時(shí)。下面是修改后的被測(cè)函數(shù):
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, "員工工號(hào)信息錯(cuò)誤"), 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))}這樣通過(guò) gomock 生成 mock 接口可以進(jìn)行測(cè)試了:
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)}方案三:通過(guò) monkey patch 方式 mock 下游 (推薦)
在上面的例子中,我們需要修改代碼來(lái)實(shí)現(xiàn) interface 的mock,對(duì)于對(duì)象成員函數(shù),無(wú)法進(jìn)行 mock。monkey patch 通過(guò)運(yùn)行時(shí)對(duì)底層指針內(nèi)容修改的方式,實(shí)現(xiàn)對(duì) instance method 的 mock (注意,這里要求 instance 的 method 必須是可以暴露的)。用 monkey 方式測(cè)試如下:
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)}存儲(chǔ)層 mock
Go-sqlmock 可以針對(duì)接口 sql/driver[3] 進(jìn)行 mock。它可以不用真實(shí)的 db ,而模擬 sql driver 行為,實(shí)現(xiàn)強(qiáng)大的底層數(shù)據(jù)測(cè)試。下面是我們采用 table driven[4] 寫法來(lái)進(jìn)行數(shù)據(jù)相關(guān)測(cè)試的例子。
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() }) }}持續(xù)集成
Aone (阿里內(nèi)部項(xiàng)目協(xié)作管理平臺(tái))提供了類似 travis-ci[5] 的功能:測(cè)試服務(wù)[6]。我們可以通過(guò)創(chuàng)建單測(cè)類型的任務(wù)或者直接使用實(shí)驗(yàn)室進(jìn)行單測(cè)集成。
# 執(zhí)行測(cè)試命令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增量覆蓋率可以通過(guò) gocov/gocov-xml 轉(zhuǎn)換成 xml 報(bào)告,然后通過(guò) diff_cover 輸出增量報(bào)告:
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設(shè)置觸發(fā)的集成階段:
參考資料
[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
來(lái)源:阿里云開(kāi)發(fā)者社區(qū)
總結(jié)
以上是生活随笔為你收集整理的golang errors 取 错误 信息_Golang 单元测试:有哪些误区和实践?的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 2021年外汇市场休市时间汇总 外汇市场
- 下一篇: c语言int32u的作用,求c语言大神