阿里妹導讀:單元測試作為開發的有力武器,應該在軟體開發的各個流程中發揮它的價值。原始的開發模式(開發完畢,交給測試團隊進行端到端測試)的流程,應該逐步向 devops 的方向轉變。本文是一個轉型的具體實踐過程,以一個實際的業務應用項目為例,介紹了在展開單測實踐過程中遇到的一些常見問題的思考,並著重介紹了幾種 mock 方法,對於一些相對複雜依賴項較多的業務也可以作為借鑑。
文末福利:雲伺服器怎麼選?
測試是保證代碼質量的有效手段,而單元測試是程序模塊兒的最小化驗證。單元測試的重要性是不言而喻的。相對手工測試,單元測試具有自動化執行、可自動回歸,效率較高的特點。對於問題的發現效率,單測的也相對較高。在開發階段編寫單測 case ,daily push daily test,並通過單測的成功率、覆蓋率來衡量代碼的質量,能有效保證項目的整體質量。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": "成功"}方案一:不 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 ./...方案二:下遊通過 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) defer ctrl.Finish() m := NewMockFoo(ctrl) 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)}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
[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
阿里雲開發者成長計劃面向全年齡段開發者,依託免費資源、免費體驗、免費學習、免費實踐 4 大場景,全面助力開發者輕鬆掌握雲上技能。開發者專屬的特價雲伺服器,涵蓋ECS、MySQL、Flink等多個爆款,低至1元起!
識別下方二維碼,或點擊 「閱讀原文」 ,快去優惠購買吧~