测试

测试金字塔

测试金字塔概念由 Mike Cohn 提出,并在其著作《Succeeding with Agile》中做了详细论述

测试金字塔是自动化测试分层覆盖情况的一个参考模型,其特点是:

测试

小型测试

中型测试

大型测试

优点

编写成本低、执行速度快、定位问题准确,覆盖全面

可靠的整体产品质量

可靠的整体产品质量

缺点

整体数量多,离业务层远

成本和价值都是处于居中位置

编写成本高,速度慢,不稳定,定位问题难

总的来说




小型测试带来优秀的代码质量、良好的异常处理、优雅的错误报告;大中型测试会带来整体产品质量和数据验证。




一般情况下较好的测试结构为 70% 的小型测试,20% 的中型测试,10% 的大型测试




但是如果一个项目是面向用户的,拥有较高的集成度,或者用户接口比较复杂,他们可以有更多的中型和大型测试;如果是基础平台或者面向数据的项目,例如索引或网络爬虫,则最好有大量的小型测试,中型测试和大型测试的数量要求会少很多。




单元测试

单元测试定义:自动化实现的,用于验证一个单独函数或独立功能模块的代码是否按照预期工作,着重于典型功能性问题、数据损坏、错误条件和大小差一错误等方面的验证

作为撑起整个测试金字塔的最底端的根,重要性不言而喻

避免出现:如果你的代码以莫名其妙的方式运行起来了,那建议你就不要再动它了

  1. 更少的 bug 数量80%的 bug 都是低级错误,而一个简单的单元测试能把这 80% 的低级错误测出来80%
  2. 测试即文档一个函数最佳使用说明就是其对应的单元测试,我们在看开源代码时往往都有着完善的单元测试,当想知道一个函数如何使用时,单元测试就是最好的文档
  3. 方便回归改了自己的实现,如果整体的单元测试能过,那基本次改动是没有太大问题的。如果线上发现了某个 bug,事后要想这是不是单测遗漏的 case,如果则补充对应的 case,函数也会健壮,修改或优化逻辑的时候也就对代码更有信心
基本要求:

快速,环境一致,任意顺序,并行 所以单元测试往往要 mock 掉第三方接口,数据库等有 IO 开销的方法

现实情况

由于大部分情况下开发人员承担着较多的业务工作或人手不足,导致无法对每一个函数有着完善的单元测试,所以目前暂定的方案是只对业务逻辑层进行测试,但是往往 model 层也承担了较多的业务,采用 mock 的方案,由于没有对 model 层进行测试,所以整个函数的信心还是不足的。基于这种情况,设计出了使用真实数据库,但同时不违背基本要求的单元测试。

重要思路

redis:使用了开源框架 miniredis,一个基于内存的 redis mock 库,满足 redis 常用的各个函数,真实可靠数据库:通过 docker 本地启动一个环境一致的数据库环境,对于每一个执行单元测试的函数,随机创建一个唯一的数据库名,执行完单元测试后销毁该数据库,这样就支持了环境一致,任意顺序,并行。

单测的使用

  1. 拉取单元测试公共包
go get e.coding.net/txxkt/txxkt/common-go
  1. 在自己项目的 common 目录下创建 test/test.go 文件,拷贝如下内容该函数可以用来初始化一个 com 对象
package test

import (
"testing"

"e.coding.net/txxkt/txxkt/collection-service/common"
"e.coding.net/txxkt/txxkt/common-go/test"
"go.uber.org/zap"
)

// comOption 定义了初始化 com 的可选参数
type comOption int

const (
// 模拟 DB
FakeDb comOption = 1

// 模拟 Redis
FakeRedis comOption = 2
)

// SetupCom 初始化一个 com 对象
func SetupCom(t *testing.T, opts ...comOption) (com *common.Common, teardown func()) {
logger, _ := zap.NewProduction()
com = &common.Common{
DataAccess: &common.DataAccess{},
Logger: logger.Sugar(),
}
optMap := make(map[comOption]struct{})
for _, opt := range opts {
if _, ok := optMap[opt]; !ok {
optMap[opt] = struct{}{}
}
}
type tear interface {
Teardown()
}
var tds []tear
for opt := range optMap {
switch opt {
case FakeDb:
td := test.SetupDB(t, test.DefaultDBConfig)
com.Db = td.Db
tds = append(tds, td)
case FakeRedis:
tr := test.SetupRedis(t)
com.DataAccess.Rd = tr.Rd
com.DataAccess.Rc = tr.Rc
tds = append(tds, tr)
default:
}
}
teardown = func() {
for _, td := range tds {
td.Teardown()
}
}
return com, teardown
}

// InitTables 初始化表结构
func InitTables(t *testing.T, com *common.Common, tables ...interface{}) {
for _, table := range tables {
if err := com.Db.Migrator().CreateTable(table); err != nil {
t.Fatal(err)
}
}
}
  1. 写对应的单元测试基本结构如下
func TestStatisticsService_GetUVInfo(t *testing.T) {
t.Parallel()
Convey(RunFuncName(), t, func() {
com, teardown := SetupCom(t, FakeDb, FakeRedis)
defer teardown()

s := StatisticsService{
Common: com,
}

// 构造 db 初始数据
InitTables(t, com, &model.OperationLog{})
opM := model.NewOperationLogModel(com, time.Now())
opM.Create(&model.OperationLog{
Id: 0,
EpsId: 0,
})

// 构造 redis 初始数据
s.Rd.Set("key", 10, 0)

Convey("case: 获取近 7 天数据, 走缓存", func() {
c := TestCase{
Ctx: context.Background(),
Req: &pb.GetUVInfoRequest{},
Want: &pb.GetUVInfoResponse{}
WantErr: false,
}
patches := NewPatches()
defer patches.Reset()

patches.ApplyFuncVar(&common.NowTime, func() time.Time { return time.Date(2021, 12, 31, 0, 0, 0, 0, time.Local)})

c.Run(t, s.GetUVInfo)
})
})
}

其他

关于 gomonkey 使用方法可以看这篇文章​​https://www.jianshu.com/p/633b55d73ddd​