阿里妹导读:单元测试作为开发的有力武器,应该在软件开发的各个流程中发挥它的价值。原始的开发模式(开发完毕,交给测试团队进行端到端测试)的流程,应该逐步向 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.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)
}
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.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)
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))
}
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.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)
}
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()
})
}
}
# 执行测试命令
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
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
阿里云开发者成长计划面向全年龄段开发者,依托免费资源、免费体验、免费学习、免费实践 4 大场景,全面助力开发者轻松掌握云上技能。开发者专属的特价云服务器,涵盖ECS、MySQL、Flink等多个爆款,低至1元起!
识别下方二维码,或点击 “阅读原文” ,快去优惠购买吧~
文章评论