一文了解一线互联网大厂的 Golang 单测最佳实战经验

时间:2023-01-31 17:07:47


Go 单测实战篇:Golang 单测最佳实战经验

一文了解一线互联网大厂的 Golang 单测最佳实战经验

深入理解 Go 单测中 stub 和 mock 的原理

Go 单测里面,最常见的就是通过 gomonkey(stub) 打桩或者 mocker(mock) 的模拟来替换掉我们原本的执行逻辑,因此首先我们要对这两种方式有一个比较深入的理解,要理解为何 Go 单测的时候能够替换掉原来的方法!!!

gomonkey(stub) 打桩的原理和细节

打桩就是编写或生成桩代码,Go 里面用的最多的打桩的库是 gomonkey 库早期我们团队使用 gomonkey 库非常多,但是后面经过内部团队的讨论,最终因为 gomonkey 存在的一些问题,转而开始使用 mock 的方式。但即便如此,在业界,使用 gomonkey 还是依然非常多

gomonkey 作为一个打桩的工具,使用场景还是比较广泛,可以使用在我们大部分的应用场景。但是,它依然还是有很多限制,它必须要找到该方法对应的真实的类(结构体):

  • • gomonkey 必须禁用 golang 编译器的内联优化,不然函数被内联之后,就找不到接缝了,stub 无法进行。一般我们是通过 go test 的时候带上 '-gcflags=all=-N -l' 来禁用内联优化。

  • • gomonkey 需要很高的系统权限,因为在运行时替换函数入口是一个权限要求较高的事情,在一个安全的系统上,比如在10.15+的macos上,这一点就是很难做到的。

  • • gomonkey 不支持异包未导出函数的打桩、不支持同包未导出方法的打桩

mocker(mock) 模拟的原理和细节

Mock 是在测试过程中,对于一些不容易构造/获取的对象,创建一个Mock 对象来模拟对象的行为。Mock 最大的功能是帮你把单元测试进行解耦通过 mock 模拟的机制,生成一个模拟方法,然后替换调用原有代码中的方法,它其实是做一个真实的环境替换掉业务本需要的环境。

通过 mock 可以实现:

  • • 验证这个对象的某些方法的调用情况,调用了多少次,参数是什么,返回值是什么等等

  • • 指定这个对象的某些方法的行为,返回特定的值,或者是执行特定的动作等等

Go 官方有一个 gomock 和 mocker,但是只能模拟 interface 方法,这就要求我们业务编写代码的时候具有非常好的接口设计,这样才能顺利生成 mock 代码。

mock 的大致原理是,在编译阶段去确定要调用的对象在 runtime 时需要指向的 mock 类,也就是改变了运行时函数指针的指向。对于接口 interface 的 mock,我们通过 gomock or mocker 库来帮我们自动生成符合接口的类并产生对应的文件,然后需要通过 gomock or mocker 约定的 API 就能够对 interface 中的函数按我们自己所需要的方式来模拟。这样,runtime 运行时其实就可以指向 mock 的 interface 实现来满足我们的单测诉求。

测试用例编写的最佳方式

非常简单的逻辑可以采用 assert 库

比较结果的时候,不要直接判断 A 是否 等于 B,而需要采用 assert 方式 :

最差实践:
func TestAdd(t *testing.T) {
    actual := 2 + 2
    expected := 4
    if (actual != expected) {
        t.Errorf("Expected %d, but got %d", expected, actual)
    }
}


最优实践:
func TestAdd(t *testing.T) {
    actual := 2 + 2
    expected := 4
    assert.Equal(t, expected, actual)
}

推荐使用表驱动的方式

Table Driven 表驱动测试方法,就是把测试的输入和和期望的输出都写在一起组成一个 struct 数组,数组中的每条记录都是一个含有输入和期望值的完整测试用例,这种方式可以使我们的测试更加清晰和简练,减少了复制粘贴,并大大提高的测试代码的可读性。业界很多开源项目都是表驱动测试方法,比如etcd 的表驱动测试示例、Goland 自带的单测生成工具也是表驱动。

表驱动的方式来实现的具体使用就是,如果要测试多个条件,则用 struct 数组的形式:

func TestAdd(t *testing.T) {
    cases := []struct {
        A, B, Expected int
    }{
        {1, 1, 2},
        {1, -1, 0},
        {1, 0, 1},
        {0, 0, 0},
    }

    for _, tc := range cases {
        t.Run(fmt.Sprintf("%d + %d", tc.A, tc.B), func(t *testing.T) {
            t.Parallel()
            assert.Equal(t, t.Expected, tc.A+tc.B)
        })
    }
}

推荐使用 convey 包来进行测试

表驱动的方式,可以很好通过批量输入输出的方式来执行不同的单测逻辑,但是,他们的定义和断言并没有放在一起,阅读起来并没有那么直观。并且表驱动的方式如果有测试用例的话,那么可能导致在我们的 IDE 上屏都展现不完,也就是比较占地方。

如果想要可读性更好的测试方式的话,可以使用 "github.com/smartystreets/goconvey/convey",它的可读性会更好点,利用 convey 可以让我们的单测代码变得更为优雅和简洁。这个也是我在实际工作中用到的最多的方式:

package alarm

import (
    "testing"

    . "github.com/smartystreets/goconvey/convey"
)

func Test_subs(t *testing.T) {
    Convey("Test_subs", t, func() {
        Convey("case subs len is less", func() {

            index := subs("123456", 3)
            So(index, ShouldEqual, "123")
        })
        Convey("case subs len is more", func() {
            index := subs("123456", 10)
            So(index, ShouldEqual, "123456")
        })
    })
}

采用 sub-tests 架构来测试一个方法中的多个分支

比如,一个方法中,要测试成功的 case、失败的 case 等多个不同的分支,那么不要每一个 case 一个测试方法,而是在一个测试方法中执行不同的逻辑:

func TestSomeFunction(t *testing.T) {
    t.Run("success", func(t *testing.T){
        //...
    })

    t.Run("wrong input", func(t *testing.T){
        //...
    })
}

当然, 如果用我推荐的 Convey 包来测试的话,同样也是这样的效果

func TestSomeFunction(t *testing.T) {
    Convey("TestSomeFunction", t, func() {
        Convey("case success", func() {
                //...
        })
        Convey("case wrong input", func() {
                //...
        })
    })
}

是不是看着就很顺畅 ?如果有同样的看法,那么就果断用起来吧

mock 的使用经验说明

了解自己的测试意图

测试意图是说,单测里面主要是测自己写的业务逻辑,不要把单测精力放在 RPC 接口上的测试,像 redis、mysql 这些外部网络请求操作都可以 mock 掉,但是自己写的业务逻辑,一定不能 mock,一定要有相对详细的测试。

为什么?因为我们这里说的是单元测试,不是接口测试也不是集成测试,单元测试就是要层我们自己写的一个个的小函数、小方法,而这些函数里面的逻辑是你可以控制的,并且需要经常修改的,因此容易出错,所以,要测试。

而外部网络请求如 RPC 接口调用或者数据库请求,过程是你无法控制的,要么成功、要么失败,你要测试的逻辑是成功后你要返回什么,失败后,要你要返回什么,只要关心并测试网络请求的结果即可。因此外部依赖的请求,我们一般推荐使用 mock 的发方式来解决,从而减少对外部的真正依赖。mock 的时候,通常我们 mock 出两个 case,一个是成功的 case,一个是失败的 case。

初期大家会觉得写单测麻烦,耗时太长,花那么多时间去写单测觉得心累,但是只要坚持一段时间,就会发现,只要前面做好了,后面写单测会非常简单,因为套路都是一样的,前面已经有经验了。并且更为重要的是,如果想要优化代码或者重构代码,有单测的时候,可以极大的减少 bug。

不要过度依赖 mock

目前很多同学搞单测,其实都是为了单测覆盖度,然后大量使用了 mock,因为这样最容易跑完覆盖度,但是这样的单测其实并没有真正达到单测的目的。所以单测的时候,建议尽可能的减少 mock,让单测跑最真实的代码只有外部依赖的情况下才使用 mock。这里可以参考 千万不要过度依赖于 MOCK!,过度使用 Mock 可能带来以下三个问题:

  • • 让测试代码更难以理解

  • • 测试用例更难维护

  • • 测试用例无法保证代码能正常工作

适合 mock 的场景

如下这些场景的情况下,比较适合使用 mock :

  • • 对象的状态难以创建或者重现,比如网络错误或者文件读写错误等,因为我们无法控制外部请求的状况,因此比较适合 mock

  • • 对象方法上的执行太慢,比如在测试开始之前初始化数据库,而我们的单测执行的耗时要求是尽量的快,所以非常耗时的操作,我们可以 mock 掉。

  • • 该对象提供非确定性的结果,比如当前的时间,Go 里面经常会用到 time 包,而这个就比较适合 mock。

Go 单测的基本准则

要写合适并且有意义的单测

如果函数非常简单比如就 2-3 行代码,或者说是一个内联函数,这种情况下,我们如果有绝对的自信,说这个函数不会出问题,那么我们可以不写这个函数对应的单测

对于一些业务核心的逻辑,必须要写完整的单测。对于一些内部的计算逻辑、拼接逻辑,必须要写单测。

写单测的目的是为了让我们的代码减少 bug,并且方面我们对代码最优化、做重构。如果我们写一堆无用的单测,那么就没有任何意义,我们写单测并不能只是为了跑单测覆盖度,而是要真正的帮助我们提高代码质量。

合适的单测命令

如下的单测命令,可以打印详细信息,计算单测覆盖率,同时通过 gcflags=all=-N -l' 来覆盖所有在 GOPATH 中的包,并且禁用了内联优化。

go test `go list ./... |grep -v api_test` -v -run='^Test' -covermode=count -gcflags=all=-l ./...

内联优化一般用于能够快速执行的函数,因为在这种情况下函数调用的时间消耗显得更为突出,同时内联体量小的函数也不会明显增加编译后的执行文件占用的空间。Go 中,函数体内包含:闭包调用,select ,for ,defer,go 关键字的的函数不会进行内联。并且除了这些,还有其它的限制。

或者 :

go test -v  -covermode=count -coverprofile=cover.out.tmp -coverpkg=./... -gcflags='all=-N -l' ./...

单测的覆盖率

在我们团队,甚至包括整个公司,都对单测的覆盖率有强要求。我们团队都是通过 DevOps 流水线进行强制校验,我们提交代码 MR 的时候,想要分支代码合入主干,必须覆盖率要达到指定的比例才能合入。这里的覆盖率包括:

  • • 全量覆盖率,是指整个项目工程的所有的代码的覆盖率,要达到 50%

  • • 增量覆盖率,是指你这次提交的代码的覆盖率,要达到 50%

单测也需要进行 Code Review

常规的,我们对代码的提交,肯定需要有 CR(Code Review)的过程,只有 CR 通过了,才能合入 master。但是大多数情况下,我们只会 CR 业务代码,不会 CR 单测,但是,单测也有必要 CR,要让团队大家写单测的方式方法、准确性都保持统一。

如果团队从没有写过单测,怎么推动?

如果团队从没有写过单测,那么我们怎么推动?我们最初,也是从无到有的过程,并且中间踩了很多坑,最初大家有很多怨言,到现阶段,我们团队已经都能够很好的接受并且确实让我们的代码质量有所提高。

为了避免大家踩同样的坑,我的建议是:

  • • leader 首先要做好调研和宣讲,包括单测的优势、业内的使用情况

  • • 找 1-2 个高级别的同学带头,先进行尝试

  • • 试点之后逐步推动到其他同学

  • • 定期做好复盘,给出正向、反向的反馈,总结经验

  • • 刚开始不要求单测覆盖率一定达到多少,先把流程跑起来

  • • 后期大家都接受并且认可之后,根据团队情况,规定合适的单测覆盖度

其他常见的 mock 库

数据库相关操作采用 go-sqlmock 这个 mock 库

针对数据库的操作,推荐我们使用 sqlmock 这个库来进行 mock。go-sqlmock 本质是一个实现了 sql/driver 接口的 mock 库,它的设计目标是支持在测试中,模拟任何 sql driver 的行为,而不需要一个真正的数据库连接。我们在单测过程中,不要直连真正的数据库有如下几个原因:

  • • 在单测的时候,可能根本就没有权限连接(比如,缺乏账号密码啥的)

  • • 即便连接上了,那么也不应该真正操作数据库,因为这个可能会对数据库造成一些压力甚至是脏数据,尤其是写操作;再者,直连数据库会导致单测耗时较长;

使用起来也比较简单,示例如下,详细的可以参考 里面的详细使用:

import (
    "fmt"
    "testing"

    "github.com/DATA-DOG/go-sqlmock"
)

// a successful case
func TestShouldUpdateStats(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
    }
    defer db.Close()

    mock.ExpectBegin()
    mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectCommit()

    // now we execute our method
    if err = recordStats(db, 2, 3); err != nil {
        t.Errorf("error was not expected while updating stats: %s", err)
    }

    // we make sure that all expectations were met
    if err := mock.ExpectationsWereMet(); err != nil {
        t.Errorf("there were unfulfilled expectations: %s", err)
    }
}

http 相关操作采用 httptest 这个 mock 库

httptest 是 Go 官方提供的 专门用于进行 http Web 开发测试的包。同理,我们在单测过程中,也不要直连真正的 web server,具体原因和数据库类似,可以参考上面说的原因。

使用起来同样很简单,一个示例如下,更详细的说明最好是查看官方库httptest 的使用介绍:

func TestUpperCaseHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/upper?word=abc", nil)
    w := httptest.NewRecorder()
    upperCaseHandler(w, req)
    res := w.Result()
    defer res.Body.Close()
    data, err := ioutil.ReadAll(res.Body)
    if err != nil {
        t.Errorf("expected error to be nil got %v", err)
    }
    if string(data) != "ABC" {
        t.Errorf("expected ABC got %v", string(data))
    }
}

说明一下,httptest.NewRecorder() 可以获得 httptest.ResponseRecorder 结构,而此结构实现了 http.ResponseWriter 接口。

从代码设计层面为单测做准备

合理的对外部依赖做好接口封装

合理的对外部依赖做好接口封装,因为外部依赖我们基本上期望的方式就是通过 mock 来实现单测的目的,这里就要求我们对外部依赖的里面做好方法的封装。具体做法就是定义一个 interface,然后把外部依赖的接口都统一定义并封装好,这样的代码设计,既方便写单测,整体代码又比较优雅。

Go 协程出去的怎么做单测 (不在单测里面 sleep)

Go 语言里面,我们经常会 go 一个协程出去做异步的事情,这个异步的事情是不影响主逻辑的,从业务流程上是可以失败的,因此 go 一个协程出去执行是 ok 的,业务场景下,也有挺多这样的业务诉求。那么针对这样的代码,go 出去的异步逻辑,我们要怎么单测呢 ?如果直接执行,那么 go 出去的代码可能根本就来不及执行,整个单测的逻辑就结束了,所以就导致 go 出去的异步逻辑就无法执行到单测了,而且有时候也会导致执行单测的时候直接 panic 。因此,这里的核心点在于我们在单测的时候要保证 go 出去的也能执行完毕。

早期的时候,我会用一个比较粗暴的方式(相信很多同学也有类似的经历),就是在单测里面 sleep 2 秒中,这样可以让 go 出去的协程也能被执行到,并且不会因为 leaving in fight goroutine 从而大致单测执行的时候会 panic。但是,这个方式其实是不推荐的,因为,我们针对单测,还有一个非常重要的关键点,那就是单测的执行要尽可能的快,因此不要在单测里面 sleep。

那么针对 go 出去的逻辑,要怎么单测呢?这里的核心点在于我们在单测的时候要保证 go 出去的也能执行完毕。为此,我们可以自己封装一个 MyGo,把原生的 go 封装起来,这样方便我们做单测:

封装如下:

package  testgo

var testGoImpl TestGo

type TestGo struct {
    ignoreGo bool
}

// 在单测里面要执行这个,用来忽略 go
func IgnoreGo() {
    testGoImpl.ignoreGo = true
}

// 不忽略 go
func RecoverGo() {
    testGoImpl.ignoreGo = false
}


func Go(f func()) {
    if testGoImpl.ignoreGo {
        // 如果忽略,啥事不干,直接 return, 这样的话,单测的时候,就可以执行到并且不会 panic
        return
    }   
    
    // 正常的业务逻辑,还是正常 go 一个协程出去执行业务逻辑
    go f()

}

这个时候,在业务代码里面,我们就不能再用官方的 go 关键字来 go 一个协程了,需要用 testgo.Go 来替换了。

而我们执行单测的时候,只需要在执行前,调用一下 testgo.IgnoreGo() ,执行后再调用一下 testgo.RecoverGo() 就可以完美解决