前言

单测本身不是目的,更根本地,要提升工程的可维护性。

为什么随着时间的推移,工程越来越难维护?因为工程的复杂度的增速快于我们治理复杂度的能力的增速。治理复杂度的能力落地了就是工程的可维护性。

用线性的手段去治理指数的问题,只在初期可行。长期必须要有一个比问题曲线更陡的能力曲线。

IMAGE

影响工程复杂度的因素:

  • 业务的本质复杂性
  • 互联网高人员流动性
  • 文档永远缺失或滞后

治理复杂度的能力

  • 设计能力
  • 测试能力

本文从测试角度出发做一点探讨。首先澄清概念,这里的“测试”专指研发人员自行开展的测试工作,不包括QA同学的工作。

可能涉及单元、集成、功能测试,用下图说明:

IMAGE

测试的意义

常见的说法:

  • 等项目提测后再补些单测。其心理:
    • 不知有没有意义,tl要求,没办法
    • 有意义,为了方便后续人员维护

事后补单测,好比把到女神了,依然热度不减当初,天天嘘寒问暖。是有这样的人,但你是吗?

另一种认识:

  • 帮助本人开发现在的功能。本人现在! 在没有护栏的高速路狂奔,开得越快,死得越快。 IMAGE

  • 帮助提高项目的长度维护性。顺便! 在高人员流动的情境下实现工程的长期可维护性
    • 靠员工传承 ✕
    • 靠文档传承 ✕
    • 自解释工程 √
  • test as a doc

可用的测试

低成本地实现:

  • 可重复运行
  • 可自动运行
  • 不依赖外部环境

即,测试本身的scalability

对比几种测试做法:

流程1:

  • 为case1在db造数据 (每次3m)
  • 本地启用应用(改配置连本地服务) (每次2m)
  • 在postman配置,为case1调api (若能长期保存psotman配置,则每次1m,否则每次5m)

流程2:

  • 为case1在db造数据 (每次3m)
  • 写测试代码 (首次10min,以后0)
  • 运行测试代码 (每次0.1m)

流程3:

  • 为case1在代码中靠数据 (首次6m,以后0)
  • 写测试代码 (首次10min,以后0)
  • 运行测试代码 (每次0.1m)

长期耗时对比:

  • 流程1:(3+2)n + 5 + 1(n-1) = 6n+4
  • 流程2:3n + 10 + 0.1n = 3.1n+10
  • 流程3:6+10+0.1n = 0.1n+16

IMAGE

流程1和2都是非scalable的做法,问题分析:

  • 流程1,依赖了外部环境,不可重复、无法自动化
  • 流程2,依赖了外部环境,不可重复,可以自动化
  • 流程3,不依赖外部环境,可以重复,可以自动化

以为点点postman、连mysql造条数据是图省事,诸不知这是更费事的做法。一个短期、一个长期,本质上都是“偷懒”,省点时间多看看窗外的风景、少掉几根头发。

Less is exponentially more —— Rob Pike

如果认同上述观点,接下来的内容其实不看也没啥损失。因为你总会想出各种手段去“偷懒”的,具体的手段反而关系不大了。换言之,以下方式随时可能被更先进、更scalable的方式替代。

工程可测性

遵守控制反转原则

并不是所有代码都是可测试的。谈具体测试做法前,得先保证代码的可测性。道理上是极其简单的,即SOLID原则中的D

Any higher classes should always depend upon the abstraction of the class rather than the detail. –Dependency Inversion Principle.

但实践起来并不那么容易。 比如,业务代码中很常见的repo调用dal的写法:

func (repo *RepositoryImpl) Create(ctx context.Context, user *model.User) (*int64, error) {
  // ...
  userDO := convert.UserModel2DO(user)
  dal.CreateUser(ctx, userDO)
  // ...
}

比如,调用rpc的写法:

// 调用rpc:
thirdcall.ProduceServiceClient.QueryTaskPackPage(c, pageTaskPackRequest)

// ProduceServiceClient定义:
type Client struct {
  kc *kitc.KitcClient
}

毫无违和感,却是违背DI的,进而限制可测性。

因为抽象是可变的,实现是固定的。依赖抽象使得测试过程中剥离无关部分(可能是其它系统、也可能是本系统的其它代码)成为可能。而测试,只应测试目标代码,既不应依赖另一个系统、模块的输入,也不应输出到另一个系统、模块,这是“不依赖外部环境”的双重含义。(从这个意义上说,测试的过程应践行函数式编程的理念:pure、immutable、no side effect。)

就go语言而言,唯一的抽象工具就是interface了。当依赖interface时,可以在测试时用内存实现的db替换外部的mysql;用mock的rpc客户端替换真实的rpc调用。

尽量避免全局变量

一时全局一时爽,一直全局会很惨!

散落在各处的全局变量引用,让人无法快速分析出外部依赖。本质上全局变量是固定的实现,绑定全局变量同样使得剥离依赖变量困难。

建议:总是在struct定义里声明清楚外部依赖,哪怕只是一个config

type TaskPackServiceImpl struct {
	MaterialService         MaterialService
	TaskService             TaskService
	UserService             UserService
}

type MatrixClientImpl struct {
	Config config.MatrixConfig
}

有个例外情况。构造器初始化传参的方式过于简单,复杂项目下,在我们没有依赖注入工具的情况下,会让单例生成变得很繁琐。如TaskPackServiceTaskService互相依赖,无法直接构造出来。如果严格执行上述建议,相当于人工实现依赖注入。所有单例都先使用无参数构造器new出来,然后再遍历依赖图,一个个set属性。

在引入依赖注入工具之前,这种耦合严重的场景可以直接引用全局变量,其余场景(占多数,毕竟是微服务)仍坚持该建议。

方法与实操

权衡投入产出,推荐对服务的serivce层做测试。服务的handler层和api暂不推荐。以service的公有方法为单位编写若干测试用例。

推荐两种实践:

  • 对复杂的service方法做单元测试,即把该方法的外部依赖全部mock掉,包括其它service,和自己dal层。
  • 复杂度一般的service方法,直接做集成测试,即不mock其它的其它service,不mock自己的dal层。但mock掉外部依赖:rpc、中间件的调用,等。

总得来说:Mock,只是结合具体场景的手段不尽相同。

场景1,数据库调用

有两种路线:

  • 1,直接把dal层mock掉
  • 2,dal层真实,但db被mock

建议走路线2,因为我们的业务往往sql的正确性是非常关键的,有些功能甚至就是些crud,路线1把dal层都mock掉了,发现问题的可能性大大降低了。

用内存数据库替代真实数据库(这也是一种mock)。

  • dao依赖抽象的DBManager
  • 提供DBManager的两个实现
  • 在init内提供选择(只在这里有区别,其余代码完全一样)
// 抽象的db协议
type DBManager interface {
	WithDB(ctx context.Context) context.Context
	GetDB(ctx context.Context) *gorm.DB
	TransactionWithResult(ctx context.Context, fc func(ctx context.Context) (interface{}, error)) (result interface{}, err error)
	Transaction(ctx context.Context, fc func(ctx context.Context) error) (err error)
}

// 测试用的db实现
type DBManagerFake struct {
}

// 生产用的db实现
type DBManagerReal struct {
}

// dal包的Init方法提供两种Init:
func Init() {
	initRealDB()   // 外部mysql
	EMDBManager = &DBManagerReal{}  
	initDAOs()
}

func InitTest() {
	initFakeDB()  // 内存sqlite
	EMDBManager = &DBManagerFake{}
	initDAOs()
}

// XXX_test.go文件里使用InitTest:
func TestAuditPassAction_Transfer_DoublePass(t *testing.T) {
	dal.InitTest()
	repository.Init()
	Init()
	// ...
}	

场景2,外部调用

数据库场景里代码在我们掌握范围内,像redis、rpc之类的(统称外部调用)客户端代码都是提供好的,像我司的kitool生成的客户端代码就是一个type Client struct,并没有提供interface,怎么办?

我们自己写个interface,再引用预生成的代码实现该interface。实际使用时,不直接用预生成的代码,而是通过依赖该interface

crowd项目和题库的交互为例,我们自己定义interface表达题库提供的能力协议:

type MatrixClient interface {
	AddUpdateBook(ctx context.Context, requests []*AddUpdateBookRequest) (*MatrixResponse, error)
	UpdateBookState(ctx context.Context, requests []*UpdateBookStateRequest) (*MatrixResponse, error)
	AddUpdateItem(ctx context.Context, requests []*AddUpdateItemRequest) (*MatrixResponse, error)
	UpdateItemState(ctx context.Context, itemIds []int64, state int) (*MatrixResponse, error)
}

然后有两份实现

  • 真实的MatrixClientImpl,生产使用
  • 假的MockMatrixClient,测试时使用。

类似地,其它形式的外部依赖,也可以这么解决。付出的额外成本是:

  • 一个interface定义
  • 一个调用真实接口的implementation

这个成本是非常小的,因为interface的定义就是原方法签名的拷贝,而implementation只是简单地返回真实调用。

MockMatrixClient怎么搞后面再介绍。

值得讨论的问题是,换位思考下,作为服务提供方时,我们是否应该提供interface+implementation,而不是只提供implementation

乍一看,前者更好。但更推荐后者,因为一个interface往往有多个方法,但多数场景下,并不会用到全部方法。一个大而全的interface反而让使用方背负过多负担。使用方根据需求定义自己的小interface,成本更低。

用例的编写

收集用例

产品 < 研发 < QA:

  • 产品给规则(和典型case)
  • 研发单测覆盖主干case
  • QA覆盖各种情形的case
  • bug反馈

建议:当修复qa反馈的bug后,应该考虑落地成代码内的测试用例,方便后续回归。(当因某个路段护栏坏了掉进沟里,把车吊上起之后,还想把护栏补一补,对吧?)

保持独立

  • 一个测试方法对应一个case
  • 用例之间不共享数据、状态
  • 线程安全,可并发跑测试

测试代码与业务代码分离

  • 文件独立,测试代码写在XXX_test.go里
  • 包独立, 业务为package service,对应测试应为package service_test
    • 独立包的好处是编译后成的生产用的可执行文件内不会包括test相关代码。
    • 减少包互相依赖的可能性。

单测覆盖率

命令:

  • cd app/service
  • go test -coverprofile=c.out
  • go tool cover -html=c.out

注意,覆盖率是statements,不是branches。

多少合适?

覆盖率不是追求的目标,作为研发,覆盖主干case是目标。但这个目标不易量化和评价。因此暂且用覆盖率代替,个人想法:60%及格,80%良好。awesome-go要求项目测试覆盖率达到80% 以上才有资格入选。Go社区两个常用库的覆盖率情况:

  • gin: 98%
  • gorm: 78%

mock生成工具

利用mockgen,只要有interface,就能自动生成implementation

例如:

mockgen -source=search.go -package=thirdcall -destination=search_mock.go

search.go内的interface进行mock,生成实现search_mock.go,其package为thirdcall

注意,search_mock.go为自动生成的代码,任何时候都不应人工修改它。当源interface有变化时,应重新执行上述命令。

mock生成的代码虽然是固定的,其行为表现却是高度可制定的。可以在测试代码里直接指定被Mock对象的行为,如:

// 执行SearchItem时传ctx和任意参数,都返回指定的resp和nil:
algoService.EXPECT().
  SearchItem(ctx, gomock.Any()).
  Return(resp, nil)

// 执行SearchItem时传ctx和任意参数,sleep两分钟,然后返回nil, nil。模拟服务超时。
algoService := thirdcall.NewMockAlgorithmService(ctrl)
algoService.
  EXPECT().
  SearchItem(ctx, gomock.Any()).
  DoAndReturn(func(ctx context.Context, r *searchpage0.SearchItemRequest) (*searchpage.KitcSearchItemResponse, error) {
    time.Sleep(time.Minute * 2)
    return nil, nil
  }).
  AnyTimes()

测试的成本

  • 项目初期,更长的开发时间
  • 更高的技能要求,对语言、对设计
  • 更煎熬的心理:
    • 长短期思维的博弈
    • 个人vs团队,前人vs后人