阿里妹导读:单元测试作为开发的有力武器,应该在软件开发的各个流程中发挥它的价值。原始的开发模式(开发完毕,交给测试团队进行端到端测试)的流程,应该逐步向 devops 的方向转变。本文是一个转型的具体实践过程,以一个实际的业务应用项目为例,介绍了在展开单测实践过程中遇到的一些常见问题的思考,并着重介绍了几种 mock 方法,对于一些相对复杂依赖项较多的业务也可以作为借鉴。
文末福利:云服务器怎么选?
A(Automatic,自动化):单元测试应该是全自动执行的,并且非交互式的。
I:(Independent,独立性):为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
R:(Repeatable,可重复):单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
简短,只有一个测试目的
简单,数据构造、清理都很简单
快速,执行函数秒级执行
标准,遵守严格的约定(准备测试上下文,执行关键操作,验证结果)
没有断言。没有断言的单测是没有灵魂的。如果只是 print 出结果,单测是没有意义的。
不接入持续集成。单测不应该是本地的 run once ,而应该接入到研发的整个流程中,合并代码,发布上线都应该触发单测执行,并且可以重复执行。
粒度过大。单测粒度应该尽量小,不应该包含过多计算逻辑,尽量只有输入,输出和断言。
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 不为空,下游失败,返回报错信息。
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]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)}
RDSC_CONF=$sourcepath/test/data/config.yml go test -v -cover=true -coverprofile=$sourcepath/cover/cover.cover ./...
初始化测试环境,清空DB数据,写入被测数据。
执行测试方法。
断言测试结果。
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)
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))}
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)}
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)}
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.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()})}}
# 执行测试命令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
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元起!
识别下方二维码,或点击 “阅读原文” ,快去优惠购买吧~