Golang 单元测试:有哪些误区和实践?
背景
測試是保證代碼質量的有效手段,而單元測試是程序模塊兒的最小化驗證。單元測試的重要性是不言而喻的。相對手工測試,單元測試具有自動化執行、可自動回歸,效率較高的特點。對于問題的發現效率,單測的也相對較高。在開發階段編寫單測 case ,daily push daily test,并通過單測的成功率、覆蓋率來衡量代碼的質量,能有效保證項目的整體質量。
單測準則
什么是好的單測?阿里巴巴的《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.DB func 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]RepoCrMetricsRspjson.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.gm := 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.ContextcrCtx 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.CrCtxrepoRet := 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 = 0repoRet.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.RepoCrMetricsRspjson.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 store import ("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.Contextdb func() *gorm.DB}type args struct {table stringcolumn stringwhereAndOr []SqlFiltergroup stringout interface{}}tests := []struct {name stringfields fieldsargs argswantErr boolcheckFunc 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/cover RDSC_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.cover pip install diff-cover==2.6.1 gocov convert cover/cover.cover | gocov-xml > coverage.xml cd $sourcepath diff-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
原文鏈接:https://developer.aliyun.com/article/778487?
版權聲明:本文內容由阿里云實名注冊用戶自發貢獻,版權歸原作者所有,阿里云開發者社區不擁有其著作權,亦不承擔相應法律責任。具體規則請查看《阿里云開發者社區用戶服務協議》和《阿里云開發者社區知識產權保護指引》。如果您發現本社區中有涉嫌抄襲的內容,填寫侵權投訴表單進行舉報,一經查實,本社區將立刻刪除涉嫌侵權內容。總結
以上是生活随笔為你收集整理的Golang 单元测试:有哪些误区和实践?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 玩转ECS第8讲 | 服务器迁移中心SM
- 下一篇: Serverless在编程教育中的实践