前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >如何编写可测试的代码:两个核心三个思路

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

作者头像
腾讯云开发者
发布2024-01-25 09:03:03
4321
发布2024-01-25 09:03:03
举报

?导读

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

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

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

代码语言:javascript
复制
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 标准库里的几乎都是纯函数。我们也可以自己实现一些纯函数,比如:

代码语言:javascript
复制
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) {
// ...
}

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

代码语言:javascript
复制
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)
}

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

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

代码语言:javascript
复制
func NHoursLater(n int64) time.Time {
    return time.Now().Add(time.Duration(n) * time.Hour)
}

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

再看个例子:

代码语言:javascript
复制
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、抽离依赖

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

代码语言:javascript
复制
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,比如:

代码语言:javascript
复制
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 来控制它的行为,从而完成测试,比如:

代码语言:javascript
复制
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 对象,为这个函数编写更多的测试用例。

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

  • 梳理函数依赖;
  • 依赖转为入参;
  • 把具体对象转为接口。

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

代码语言:javascript
复制
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 等等。如果简单地把依赖转成函数入参,比如:

代码语言:javascript
复制
func NewOrder(user UserInfo, order OrderInfo, idempotent IdemChecker, orderSystem OrderSystemSDK, mq KafKaPusher, redis Redis.Client) error {
// ...
}

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

04、对象化

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

代码语言:javascript
复制
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)可以被引用。而对象本身可以有无限多的成员变量,因此通过实现对象方法而不是函数,我们可以更加容易地添加依赖,比如:

代码语言:javascript
复制
type orderCreator struct {
    checker IdemChecker
    orderSystem OrderSystemSDK
    kafka KafkaPusher
    redis Redis.Client
}

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

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

代码语言:javascript
复制
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、函数变量化

我们先来看个例子:

代码语言:javascript
复制
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 是一个包而不是一个对象,因此我没办法把它作为一个子项放到对象中。针对这种场景,我们就要考虑函数变量化了。所谓函数变量化其实就是用一个变量来保存函数指针,比如:

代码语言:javascript
复制
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 的函数指针,性能上看起来是多了一次内存寻址,但其实根本无关紧要。但是它带来的好处却是的巨大,因为我们在编写测试用例时就可以这样:

代码语言:javascript
复制
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 日志处理函数!除了日志以外,其实还有很多这样的静态方法调用,我们都可以用变量来保存这些函数,比如:

代码语言:javascript
复制
// 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 函数中手动调用。

本文参与?腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-01-24,如有侵权请联系?cloudcommunity@tencent.com 删除

本文分享自 腾讯云开发者 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与?腾讯云自媒体同步曝光计划? ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 01、把大象放进冰箱
  • 02、纯函数
  • 03、抽离依赖
  • 04、对象化
  • 05、函数变量化
  • 06、总结一下
  • 07、最后,尽量避免使用 init
相关产品与服务
腾讯云服务器利旧
云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
http://www.vxiaotou.com