如何编写可测试的代码:两个核心三个思路

0203ad7b87c6891cf68db7ef90d16feb.png

b56af83c9bcabb231b0c365fbbce5089.gif

👉导读

在需要长期迭代的项目中编写单元测试,已经在各个团队中逐渐成为一种虚伪的共识。虽然嘴上都说好,但身体很诚实。

👉目录

1 把大象放进冰箱

2 纯函数

3 抽离依赖

4 对象化

5 函数变量化

6 总结一下

7 最后,尽量避免使用 init

8 写到最后

在需要长期迭代的项目中编写单元测试,已经在各个团队中逐渐成为一种虚伪的共识。虽然嘴上都说好,但身体很诚实。毕竟编写单元测试需要在实现业务功能以外付出额外的精力和时间,所以很多人把它视为是一种沉重的工作负担。造成这种认知的本质问题主要有两点,除了在意识上没有真正认同单元测试的价值外,更多的还是因为实践中发现编写单元测试太耗时,经常要花费很多时间去设计测试用例,而且为了让被测函数跑起来,需要花费大量时间去为它创建运行环境,初始化变量,mock 对象等等,有时候甚至抠破脑袋也不知道该怎么写测试。因此,本文以 Go 语言为例,讲讲如何设计和编写容易测试的业务代码。

其实,如果有意识地设计数据结构和函数接口,其实我们的代码是很容易进行测试的,不需要任何奇技淫巧。不过实际工作中,大部分同学在设计阶段并没有 For Test 的意识,自然而然就会写出一些很难测试的代码。要明白代码易测试和逻辑结构清晰是两码事,逻辑清晰并不代表代码易测试,即使是经验丰富的程序员如果不注意也会写出难以测试的代码,比如:

func GetUserInfo(uid int64) (*UserInfo, error) {
    key := buildUserCacheKey(uid)
    val, err := redis.NewClient(USERDB).GetString(key)
    if err == nil {
      return unmarshalUserInfoFromStr(val)
    }
    res, err := mysqlPool.GetConn().Query("select * from user where uid=?", uid)
    // ... 
}

上面这段代码逻辑写得还是很清晰的(不是自夸),先从 Redis 里取缓存,没取到再去 MySQL 取。虽然很容易读懂,但是如果要你给这个函数写单元测试,那你就会很崩溃了。因为函数内部要去 Redis 取数据,在开发环境中根本连不上 Redis 。即使连上了,Redis 里也没数据。MySQL 同理。并且你有没有发现,这些个依赖还根本没法 mock!在给 GetUserInfo 函数编写单测时,我根本没有办法控制 MySQL 和 Redis 对象的行为。如果没有办法控制它们,那确实就没办法编写测试代码。

那接下来我们就进入正题:如何编写易于测试的业务代码。

01

把大象放进冰箱

把大象装进冰箱有几个步骤?

  1. 打开冰箱门;

  2. 把大象塞进去;

  3. 关上冰箱门。

当然这只是个笑话,开关门倒是简单,但是把大象塞进去哪有那么简单。然而,如果在写业务代码时有意识地稍微考虑一下可测试性,那么写单元测倒是真的是一件挺容易的事情,主要就两步:

  • 设置好所有入参的值;

  • 判断输出的值是否如预期。

这两个步骤非常直观也很容易理解,但是实际中为啥单测写起来那么复杂呢?

02

纯函数

为了讲明白这个问题,首先我要讲一讲纯函数的概念。如果一个函数满足:

  • 输入相同的入参会得到相同的结果;

  • 无副作用;

  • 无外部依赖。

那么这个函数就是一个纯函数。纯函数的例子有很多,像 Go 标准库里的几乎都是纯函数。我们也可以自己实现一些纯函数,比如:

func Add(a, b int) int {
    return a+b
}


func getRedisUserInfoKey(uid int64) {
    return fmt.Sprintf("uinfo:%d", uid)
}


func sortByAgeAsc(userList []User) []User {
    n := len(userList)
    for i:=0; i<n; i++ {
        for j := i+1; j<n; j++ {
            if userList[i].Age > userList[j].Age {
                userList[i], userList[j] = userList[j], userList[i]
            }
        }
    }
    return userList
}


func ParseInt(s string) (int64, error) {
// ...
}

纯函数最大的特点就是其结果只受输入控制,当入参确定了,输出结果就确定了。入参和输出结果之间有一种确定性的映射关系(虽然可能很复杂),就像数学中的函数一样。基于这种特性,对于纯函数就非常容易编写测试用例,尤其是基于表格的测试,比如:

var testCases = []struct{
  input string
  expectOutput int64
  expectErr error
}{
  {"100",100,nil,},
  {"-99999",-99999,nil,},
  {"1.2",0,ErrNotInt,},
// ...
}
for _, tc := range testCases {
  actual, err := ParseInt(tc.input)
  assert_eq(tc.expectOutput, actual)
  assert_eq(tc.expectErr, err)
}

基于表格编写测试用例是最好的一种单测编写方式,没有之一。我们对每一组测试,输入是什么,输出应该是什么,如果有错误的话应该返回什么错误,这些都一目了然。并且我们可以很容易地新增更多测试用例,而不需要修改其它部分代码。

但实际业务开发中我们很少编写纯函数,大部分都是非纯函数,比如:

func NHoursLater(n int64) time.Time {
    return time.Now().Add(time.Duration(n) * time.Hour)
}

此函数返回距今 n 小时后的时间。虽然接收一个参数 n,但是实际上每次执行结果都是随机的,因为这个函数除了依赖 n 还依赖当前时间。而当前时间的值并不由调用方来控制且一直在变,因此你没法预测当输入 n 之后函数会输出什么。这其实就是一个很典型的隐式依赖——虽然我们输入了参数 A,但是函数内部还隐式地依赖了别的参数。

再看个例子:

func GetUserInfoByID(uid int64) (*UserInfo, error) {
  val, err := mysqlPool.GetConn().Query("select * from t_user where id=? limit 1", uid)
  if err != nil {
    return nil, err
  }
  return UnmarshalUserInfo(val)
}

这个函数的问题也类似,即使你传了 uid,但是无法确定函数会返回什么值,因为它完全依赖内部的 MySQL 模块的返回。这些都是平时业务代码中非常常见的例子。你可以想一想,如果让你来对上述两个非纯函数编写单测,你应该怎么做呢?

其实如果函数的实现像上面两个例子,那么除了用 monkeyPatch 这种骚操作,基本上没办法做测试。不过既然是骚操作,那么这里就不多说了。我们应该要把 monkeyPatch 视为最后的手段,如果为某个函数写测试时不得不使用 monkeyPatch,那只能说明这段代码写得有问题。monkeyPatch 应该只出现在给老项目补单测当中,我还是更多地讲讲如何编写可测试代码。

其实讲上面的例子,最大的目的就是想告诉大家一个道理:如果要容易地对函数进行测试,就要想办法让函数依赖的变量全部可控。为了做到这些,我总结了一些指导思想:

03

抽离依赖

最简单的办法就是让函数所有的依赖都作为入参传入,对于上面例子我们可以这样改造:

func NHoursLater(n int64, now time.Time) time.Time {
    return now.Add(time.Duration(n) * time.Hour)
}


func GetUserInfoByID(uid int64, db *sql.DB) (*UserInfo, error) {
    val, err := db.Query("select * from t_user where id=? limit 1", uid)
    if err != nil {
        return nil, err
    }
    return UnmarshalUserInfo(val)
}

这样改造之后,虽然在调用时需要额外实例化一些对象,但并不是一个大问题,并且我们的函数更容易测试了。对于 NHoursLater 这个函数,我可以随意设定 now 的值,然后看结果是否和预期一致,测试起来非常容易。但是对于第二个例子就有些问题了,因为传入的参数是 *sql.DB 这样一个指向结构体对象的指针,我想控制它的行为就比较麻烦了。因为 sql.DB 是标准库实现的对象,其方法都在标准库实现,没办法修改。因此这里就应该考虑使用 Go 中的 interface,比如:

type Queryer interface {
    Query(string, args ...interface{}) (*sql.Rows, error)
}


func GetUserInfoByID(uid int64, db Queryer) (*UserInfo, error) {
    val, err := db.Query("select * from t_user where id=? limit 1", uid)
    if err != nil {
        return nil, err
    }
    return UnmarshalUserInfo(val)
}

这里立刻就能够看出使用 interface 的好处!interface 限制对象的行为,但不限制具体对象的实现,所谓的动态派发。因此我们在编写测试代码时,就可以自己简单实现一个 Queryer 来控制它的行为,从而完成测试,比如:

type mockQuery struct {}


func (m *mockQuery) Query(string, args ...interface{}) (*sql.Rows, error) {
    return sqlmock.NewRows([]string{"id", "name", "age"}).AddRow(1, "jerry", 5).AddRow(2, "tom", 7)
}


func TestGetUserInfoByID(t *testing.T) {
    userInfo, err := GetUserInfoByID(1, new(mockQuery ))
    assert_eq(err, nil)
    assert_eq(*userInfo, UserInfo{ID: 1, Name:"jerry", Age: 5})
}

然后你就可以通过表格驱动的方式,配合上自己的 mock 对象,为这个函数编写更多的测试用例。

简单总结一下我们可以归纳一个抽离依赖三部曲:

  • 梳理函数依赖;

  • 依赖转为入参;

  • 把具体对象转为接口。

把依赖抽离为入参是一种常用的方式,但是在有些场景它也不完全适用,因为有些函数的依赖实在是太多了,比如:

func NewOrder(user UserInfo, order OrderInfo) error {
// 幂等检测
    if err := idempotenceCheck(user, order); err != nil {
        return err
    }
    // 去订单系统创建订单,返回创建成功的订单信息
    newInfo, err := orderSystem.NewOrder(user, order)
    if err != nil {
        return err
    }
    // 发送订单信息到消息队列的new_order topic中
    err = mq.SendToTopic("new_order", newInfo)
    if err != nil {
        return err
    }
    // 把订单信息存到redis中方便用户查询
    cacheKey := getUserOrderCacheKey(user.ID)
    redis.Hset(cacheKey, newInfo.ID, newInfo)
    return nil
}

上述是一个简化后的创建订单函数,除了依赖于 userInfo 和 orderInfo,它还依赖某下游系统进行幂等检测,依赖于订单系统创建订单,需要向消息队列推消息,需要把数据缓存到 Redis 等等。如果简单地把依赖转成函数入参,比如:

func NewOrder(user UserInfo, order OrderInfo, idempotent IdemChecker, orderSystem OrderSystemSDK, mq KafKaPusher, redis Redis.Client) error {
// ...
}

上述函数签名就会非常复杂,调用方在调用函数前需要实例化很多对象。虽然测试方便了,但是在业务中调用却极为不便。并且更严重的是,如果后期要在代码中新增一些反欺诈和用户安全过滤等功能,这些功能都依赖于下游的微服务,难道还是每次改函数签名吗?这显然是不能接受的。因此我们要考虑第二种方法。

04

对象化

如果我们实现一个函数,那么函数能够使用的依赖要么通过参数传入,要么就是引用全局变量。如果依赖过多,通过参数传递是不现实的,那似乎就只能使用全局变量了吗?别忘了对象方法:

type Foo struct {
    Name string
    Age int
}


func (f *Foo) Bar(a,b,c int) string {
// f.Name
// f.Age
}

在对象方法中,虽然只有 a,b,c 3个入参,但实际上还有对象本身(在别的语言里的 this 或 self)可以被引用。而对象本身可以有无限多的成员变量,因此通过实现对象方法而不是函数,我们可以更加容易地添加依赖,比如:

type orderCreator struct {
    checker IdemChecker
    orderSystem OrderSystemSDK
    kafka KafkaPusher
    redis Redis.Client
}


func (self *orderCreator) NewOrder(user UserInfo, order OrderInfo) error {
// ...
}

通过把依赖放到对象内部,我们可以很方便地控制我们的依赖,在编写测试代码时自己根据需要编写一个构造函数即可:

func constructOrderCreator() *orderCreator {
    return &orderCreator{
        checker: newMockChecker(),
        // ...
    }
}


func TestNewOrder(t *testing.T) {
    obj := constructOrderCreator()
    obj.NewOrder(user, order)
}

这种方式其实也是抽离依赖的一种,只是把依赖抽离到对象中了而已,没有放到入参里面。它可以支持复杂的依赖关系,不管多少依赖,在结构定义中加项即可。缺点是实例化稍微比较麻烦,所以很少会每个请求的 handler 都实例化一次,通常是共享一个全局的对象,因此只会实例化一次(就避免了它的缺点),或者通过工厂模式来产生该对象。并且在写测试时,由于 Go 不是 RAII 的语言,我们可以偷懒只进行部分实例化。也就是说,如果我知道 obj.FuncA 只用到了 obj.X,那么我实例化 obj 时只实例化 obj.X 即可。

除了上述两种方式,还有一种很常见的方式,就是函数变量化

05

函数变量化

我们先来看个例子:

import (
    "repo/group/proj/log"
)


func add(ctx context.Context, a,b int) int {
    c := a+b
    log.InfoContextf(ctx, "a+b=%d", c)
    return c
}

在业务代码中打日志随处可见,如果被测函数中包含了打日志语句的话,经常会遇到以下问题:

  • 日志句柄没有实例化,引用空指针导致 panic;

  • 日志默认打到文件系统上,产生大量垃圾文件

并且像上面例子中,log.InfoContextf 是 log 包提供的一个静态方法,log 是一个包而不是一个对象,因此我没办法把它作为一个子项放到对象中。针对这种场景,我们就要考虑函数变量化了。所谓函数变量化其实就是用一个变量来保存函数指针,比如:

import (
    "domain/group/proj/log"
)


var (
    infoContextf = log.InfoContextf
)


func add(ctx context.Context, a,b int) int {
    c := a+b
    infoContextf(ctx, "a+b=%d", c)
    return c
}

我们用 infoContextf 来保存 log.InfoContextf 的函数指针,性能上看起来是多了一次内存寻址,但其实根本无关紧要。但是它带来的好处却是的巨大,因为我们在编写测试用例时就可以这样:

type logHandler func(context.Context, string, ...interface{})


// 用自己的实现替换函数指针
func replaceinfoContextf(f logHandler) func() {
    old := infoContextf
    infoContextf = f
    return func() {
        infoContextf = old
    }
}


// 自己实现一个log函数,啥都不做
func logDiscard(_ context.Context, _ string, _...interface{}) {
    return
}


func TestAdd(t *testing.T) {
    // 测试前把infoContextf替换为logDiscard
    resume := replaceinfoContextf(logDiscard)
    // 测试结束后自动恢复
    defer resume()
    // do your testing
}

再也不需要担心日志没有初始化了,我们可以自己来 mock 日志处理函数!除了日志以外,其实还有很多这样的静态方法调用,我们都可以用变量来保存这些函数,比如:

// in bussiness file
var (
    hostName = os.HostName
    getNow = time.Now
    openFile = os.Open
    // ...
)


func NHoursLater(n int64) time.Time {
    return getNow().Add(time.Duration(n)*time.Hour)
}


// in test file
func TestNHoursLater(t *testing.T) {
    now := time.Now()
    fiveHoursLater := now.Add(time.Duration(5)*time.Hour)
    getNow = func() time.Time {
        return now
    }
    assert_eq(NHoursLater(5), fiveHoursLater)
}

避免直接在函数内部调用静态方法,通过这些“函数指针变量”,我们可以在测试时方便地替换为自己的实现,屏蔽掉系统差异、时间差异等各种程序以外的因素,让测试代码每次都能跑在相同的环境下。

函数变量化其实就是我们常说的打桩

06

总结一下

其实以上提到的一些编码技巧都不涉及到什么高深的设计模式,也不涉及到什么技术深度。它完全就是一些编程套路,但前提是你在编写业务代码得有写单测的意识,才能写出容易测试的业务代码。

总结一下就是简单的两条指导思想:

  • 明确函数依赖(不管显示的和隐式的,它都是客观存在的依赖);

  • 抽离出依赖(想办法让函数内部的依赖都可以从函数外部控制,和依赖注入很像)。

具体抽离方法:

  • 对于依赖较少的函数,可以直接把依赖作为入参传递;

  • 对于依赖较复杂的函数,把它写成某对象的方法,依赖都存储为该对象的成员变量;

  • 函数内部不直接调用静态方法,用变量保存静态方法的函数指针(不要直接调,用变量做代理)。

记住这些要点,其实写出容易测试的业务代码真的很容易。

同时我们可以做一些测试套件的建设,因为大部分需要 mock 的对象都是通用的外部依赖,尤其是 MySQL Redis 等等,因此我们可以实现一些通用的 testsuite,方便我们来设置 mock 对象的行为,而不用每次都写很多代码来实现 mock 对象。比如:

  • mock mysql: https://github.com/DATA-DOG/go-sqlmock

  • testify/mock: https://github.com/stretchr/testify/tree/master/mock 编写mock对象的框架(maybe)

这些测试套件的建设越丰富,我们编写测试也会越容易(轮子团队加油啊)。

07

最后,尽量避免使用 init

其实 Go 还有一些额外的因素会影响我们写单测,那就是它的一个特性——init。init 在 Go 中其实是一个争议很大特性,很多人都反对用它,甚至有人向 Go2 提 proposal 想删掉 init(当然这是不现实的)。主要原因就是,如果一个包中有 init 函数,它会在 main 开始执行前就执行(也会在我们的单测函数运行前运行)。

这就带来一个问题,因为这些包的引入都是有副作用的,比如它们会到约定的地方读取配置文件,注册一些全局对象,或者尝试连接服务发现的 agent 来进行服务注册。如果哪一个环节有问题,那么框架层面就会认为初始化失败,很可能直接 panic。但是这其实会影响我们单测的运行。单测运行时不依赖真实环境,但是由于 init 的特性,如果真的某个 init 函数导致 panic,我们很可能都没办法跑单测。

另一个问题是,init 的执行顺序其实是和 import 顺序相关,这里面还有嵌套的逻辑。而且 gofmt 可能会重新调整 import 的顺序,某些时候可能会由于 init 执行顺序不一致而引入一些 bug,并且很难排查。框架如果经过严格测试,用 init 还可以,一般自己编写业务代码不要使用 init,宁愿自己写 InitXXX 然后在 main 函数中手动调用。

08

写到最后

单测思维常驻心中,遵照两个指导思想和三个解决思路,相信你也能非常便捷地写出良好的单元测试,coverage 90%+不是梦,分分钟拿下 epc 小王子的称号!

-End-

原创作者|刘德恩

  e1088b363e5c7ca6cdaa91be4cc45987.png

你是如何编写可测试代码的?对可测试代码又有什么看法?欢迎评论分享。我们将选取1则优质的评论,送出腾讯Q哥公仔1个(见下图)。2月1日中午12点开奖。

ff9b5829fa4755651bcfeac88ae445b1.png

分享抽龙年红包封面!!转发本篇文章就能随机获得以下封面 1 个!限量50个,周五中午12点开奖!

参与方式:

1、分享本篇文章到朋友圈,并截图。

2、到腾讯云开发者公众号后台回复“0125”,经核验截图后,即可随机抽取以下 1 款红包封面!

8d95edc7e2a4f2e762c4e679b97a1cc8.png

📢📢欢迎加入腾讯云开发者社群,享前沿资讯、大咖干货,找兴趣搭子,交同城好友,更有鹅厂招聘机会、限量周边好礼等你来~

380177f5397c8d2ebe5cae3fa40fded5.jpeg

(长按图片立即扫码)

99bc01bc9978cc79fc305de9996b5c88.png

440db46fabcf292b433229568d90363e.png

e0148c68be5048126d2344cc71da12ee.png

e1f3aa2f3a5966b4b83c73b66398e3a5.png


ed5a640ebf31f040acdc55f696b846f2.png

  • 22
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值