天天看點

Golang 單元測試:有哪些誤區和實踐?

Golang 單元測試:有哪些誤區和實踐?

作者 | 石窗

來源 | 阿裡技術公衆号

背景

測試是保證代碼品質的有效手段,而單元測試是程式子產品兒的最小化驗證。單元測試的重要性是不言而喻的。相對手工測試,單元測試具有自動化執行、可自動回歸,效率較高的特點。對于問題的發現效率,單測的也相對較高。在開發階段編寫單測 case ,daily push daily test,并通過單測的成功率、覆寫率來衡量代碼的品質,能有效保證項目的整體品質。

Golang 單元測試:有哪些誤區和實踐?

單測準則

什麼是好的單測?阿裡巴巴的《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]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 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.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/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           

設定觸發的內建階段:

Golang 單元測試:有哪些誤區和實踐?

參考資料

[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