背景
测试是保证代码质量的有效手段,而单元测试是程序模块儿的最小化验证。单元测试的重要性是不言而喻的。相对手工测试,单元测试具有自动化执行、可自动回归,效率较高的特点。对于问题的发现效率,单测的也相对较高。在开发阶段编写单测 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]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
设置触发的集成阶段:
参考资料:
[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
作者:石窗
来源:高德技术 - 微信公众号 [ID:amap_tech]
转载:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
更多内容推荐
接口测试自动化生成框架
接口测试给测试人员到底带来了什么,不仅仅是挑战,也是机会。让接口测试更加自动化,更加解决重复的劳动力是我们的目标和方向。本文简要的说明接口测试的相关知识和经验,并制定了接口测试自动化生成框架的系统架构图,大家一起来讨论和完善它,有机会在实践中体会它不一样的地方。
快速构建持续交付系统(四):Ansible 解决自动部署问题
在今天这篇文章中,我主要基于Ansible系统的能力,和你分享了搭建一套部署系统的过程。
2018 年 9 月 27 日
滴滴开源 Super-jacoco:java 代码覆盖率收集平台文档
Super-Jacoco是基于Jacoco、git二次开发打造的一站式JAVA代码全量/diff覆盖率收集平台,能够低成本、无侵入的收集代码覆盖率数据;Super-Jacoco除了支持JVM运行时间段的覆盖率收集外;还能够和环境无缝对接,收集服务端自定义时间段代码全量/增量覆盖率;并提供可视化的html覆盖率报表,协助覆盖率分析,支撑精准测试落地。
如何提高 PHP 代码的质量?第二部分 单元测试
说实话,在代码质量方面,PHP的压力非常大。通过阅读本系列文章,您将了解如何提高PHP代码的质量。在“如何提高PHP代码的质量?”的前一部分中,我们设置了一些自动化工具来自动检查我们的代码。
ITest:京东数科接口自动化测试实践
京东数科运维部平台开发组基于日常接口测试经验,开发了接口测试平台——ITest。
有赞 GO 项目单测、集成、增量覆盖率统计与分析
本文介绍有赞 GO 项目单测、集成、增量覆盖率统计与分析。
如何进行高效的 Rails 单元测试
在笔者开发的系统中,有大量的数据需要分析,不仅要求数据分析准确,而且对速度也有一定的要求的。没有写测试代码之前,笔者用几个很大的方法来实现这种需求。结果可想而知,代码繁杂,维护困难,难于扩展。借业务调整的机会,笔者痛定思痛,决定从测试代码做起,并随着不断地学习和应用,慢慢体会到测试代码的好处。本文忠实的记录了在这个过程中所获得的经验,介绍了如何进行高效的Rails单元测试。
如何写出优雅的 Golang 代码
本文介绍如何更快地写出优雅的 Go 语言代码。
蘑菇街支付金融 Android 单元测试实践
大家好,我是蘑菇街支付金融部门的邹勇,花名叫小创。今天很高兴跟大家分享一下安卓的单元测试在蘑菇街支付金融的实践。
期中测试丨 10 个消息队列热点问题自测
对于消息队列,你的学习效果如何?掌握了多少消息队列使用和实现的知识呢?
2019 年 9 月 7 日
携程租车 React Native 单元测试实践
本文是React和React Native项目单元测试的完整方案介绍。
Android 中的单元测试
由 于Instrument Test使用和运行的不便,在Android项目中对代码添加测试变得非常困难。本文基于项目实践,描述了在实际项目中如何借助于MVP模式和 Robolectric框架,实现逻辑和视图的分离,为代码添加有效完备的单元测试,并简单介绍了Robolectric的实现原理以及如何对其进行扩 展。
真实的战场:如何在大型项目中设计 GUI 自动化测试策略
如果你所在的企业或者项目正在大规模开展GUI测试,并准备使用页面对象模型、业务流程封装等最佳实践,那么你很可能会遇到本文所描述的问题并迫切需要相应的解决办法。
2018 年 8 月 10 日
一个 Golang 项目的测试实践全记录
一个链路涉及了4个服务,本文带你了解它是如何进行测试的。
先写测试,就是测试驱动开发吗?
测试驱动开发到底是什么呢?测试驱动开发和测试先行开发只差了一个词:驱动。只有理解了什么是驱动,才能理解了测试驱动开发。
2019 年 1 月 28 日
答疑(三)如何搭建测试的网络结构?
性能测试工具可能存在协调遗漏问题,在分析工具的延时数据时,你一定要特别留意。
2019 年 8 月 2 日
前端精准测试探索:覆盖率实时统计工具
本文主要介绍前端集成测试覆盖率统计工具的需要。
如何做好验收测试?
验收测试(Acceptance Testing),是确认应用是否满足设计规范的测试,是技术交付必经的环节。
2019 年 3 月 27 日
连接真实世界,让出行更美好
推荐阅读
增量代码覆盖率工具
持续交付中流水线构建完成后就大功告成了吗?别忘了质量保障
2018 年 2 月 9 日
如何为分布式存储系统做测试之:单元测试
你真的懂测试覆盖率吗?
2018 年 7 月 11 日
应用 Selenium 和 Ruby 进行面向领域的 Web 测试
QQ 音乐商业化 Web 团队:前端工程化实践总结(三)
知其然知其所以然:聊聊 API 自动化测试框架的前世今生
2018 年 8 月 20 日
电子书
大厂实战PPT下载
换一换 丁雪峰 | 平安壹钱包 资深架构师
翟佳 | StreamNative 联合创始人
杨森 | 达达集团 云平台DevOps & SRE Leader
评论