Golang 单元测试实践

前言

单元测试是一段在应用程序或包中用于测试程序功能是否正确的函数,在软件开发过程中有着十分重要的作用。

从一个简单的例子开始

在一些使用 golang 编写的开源软件中,我们通常会看到以 _test.go 结尾的文件名。这些都是开发者针对自己的功能代码所编写的单元测试文件,它们通常会和被测试的代码放置在同一个包下。

Step1 编写单元测试

为了更好的介绍 golang 中单元测试所涉及的几个概念和注意事项。在这里先看一个简单的例子:

.
├── math.go
└── math_test.go
// math.go 功能代码
package math

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

func Sub(a, b int) int {
return a - b
}
// math_test.go(v1)单元测试代码
package math

import "testing"

func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("Add(1, 2) != 3")
}
}

math_test.go 中的单元测试函数签名风格为 func TestXxxx(t *testing.T) ,该类型函数有且只能有一个参数 t *testing.T,这是 Go 语言单元测试的一种规范。不仅如此,所有的单元测试文件的命名也必须遵循 xxxx_test.go 的格式。只有这样,在执行 go test 命令时才可以分辨出哪些是需要执行的单元测试。

Step2 运行单元测试

在包含单元测试文件的目录下执行命令 go test -v (-v : 打印运行细节,也可以不加 -v),可以看到控制台输出以下内容。

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok     demo-unit-test/math     0.513s

go test 命令只会在当前目录下寻找以 _test.go 为后缀的文件,如果想要一并执行当前目录下以及当前目录的子文件夹中的单元测试可以使用命令 go test ./...go test 会去扫描这些文件中的特殊函数,包括 func TestXxxx 和其它几种函数。接着 go test 会生成一个临时的不可见的 main 包来调用这些函数,构建并运行它们。另外,需要注意的一点是 go test 并不保证每个 package 之间单元测试的顺序,通常它们会是并行的。所以,在编写 package 内的单元测试时不要依赖其他包内的数据或者执行结果。最终将生成测试的结果打印到控制台上,并清理生成的临时 main包。

Step3 改进单元测试

在上面的例子中,我们仅仅引入了一个 testing 包。不过,对于接下来的测试中还需要其他的包,来帮助我们更方便的编写单元测试。

// math_test.go(v2)单元测试代码
package math

import (
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"os"
"testing"
)

func TestMain(t *testing.M) {
log.Info("start unit test")
exitValue := t.Run()
log.Info("end unit test")
os.Exit(exitValue)
}

func TestAdd(t *testing.T) {
require.Equal(t, 3, Add(1, 2))
}
INFO[0000] start unit test                              
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
INFO[0000] end unit test                                
ok     demo-unit-test/math     0.566s

上面的示例代码与 math_test.go (v1) 的主要区别是多了一个 TestMain(t *testing.M) 函数和引入了 testify 包。TestMain 函数本质上是一个 package 中所有单元测试文件的入口,它只会执行一次。通过在TestMain函数中调用 t.Run() 来执行当前 package 中的所有单元测试文件中的测试函数。由于在调用 t.Run()之前就可以运行任意代码,那么就可以在测试函数执行之前设置一些需要用到的全局变量,并且相对于 init() 方法来说它还可以在运行完单元测试函数之后运行任意代码(执行一些资源释放相关工作等)。但在使用 TestMain 函数时,需要记住下面两点规则:

  • 由于函数名在指定包内需要唯一,所以 TestMain 函数在一个包内只能出现一次。如果包内有多个单元测试文件,就需要根据实际情况把 TestMain 函数放在最合适的一个测试文件中。
  • 并不是所有的单元测试都需要 TestMain 方法。

相对于math_test.go (v1) 的单元测试, testify 包可以让我们更方便的对测试结果进行断言。点击查看关于更多 testify的介绍

Step4 查看单测覆盖率

编写单元测试时,及时了解单元测试代码的覆盖率也十分重要。go test 也提供了一些参数,可以让开发者在控制台中看到单元测试的覆盖率,并且配合 go tool 命令能查看更为详细的覆盖范围情况。

运行以下命令计算当前单元测试的覆盖率:

go test -v -cover

你可以在控制台上看到以下输出:

INFO[0000] start unit test                              
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
coverage: 50.0% of statements
INFO[0000] end unit test                                
ok     demo-unit-test/math     0.514s

其中,覆盖率 coverage 的值为 50% 的原因是在这个 packagemath.go 一共有两个函数 AddSub ,但是只为 Add 编写了单元测试,所以单测覆盖率为 50%。需要注意的是这个值的计算方式不是根据 TestAdd 签名作为依据的,如果你把 TestAdd 改为 TestAddd 后对覆盖率的值不会产生任何影响。但是在 TestAdd 函数中增加一行代码 require.Equal(t, -1, Sub(1, 2)) 后再执行 go test -v -cover 这行命令,这时你就会发现覆盖率变成了 100%

通过执行下面的命令,会在控制台上打印覆盖率的基础上生成一份更为详细的覆盖范围报告:

go test -v -coverprofile=coverage.out

运行下面命令,查看每个函数的单测覆盖率:

go tool cover -func coverage.out

控制台输出:

demo-unit-test/math/math.go:3:  Add             100.0%
demo-unit-test/math/math.go:7: Sub             0.0%
total:                         (statements)    50.0%

如果我把 Add 函数改成下面这种:

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

再次执行 go tool cover -func coverage.out 命令的输出则会变成下面这种情况:

demo-unit-test/math/math.go:3:  Add             66.7%
demo-unit-test/math/math.go:10: Sub             0.0%
total:                         (statements)    50.0%

可以看到 Add 函数的单元测试覆盖率变成了 66.7%。所以,在我们写的单测要尽可能的把所有的逻辑分支都执行到,包括异常情况。针对一个被测试的函数,我们可能就要构造几种不同的输入来使覆盖率达到一个更高的值。

运行下面命令,在 Web 浏览器打开 coverage.out 文件:

go tool cover -html=coverage.out

如何给业务代码写单测?

在实际的项目中,不仅有简单的 CURD 类的函数也会有一些调用链较长或者会依赖其他服务的函数。对于前者来说,我们通常会将此类代码文件放置在同一个 package 中。在其中的一个单元测试文件的 TestMain 函数中实例化一个数据库连接,此 package 下所有的单元测试都复用这一个连接,执行完 package 中所有的单测之后再释放掉即可。在一个单元测试文件中可能会存在 TestCreateTestDelete 两个函数,有同学可能会问在 TestDelete 中能否使用 TestCreate 函数中创建的数据?如果在业务上 Add 函数和 Delete 函数是强关联的,那么后面的删除函数也是可以使用前面创建的测试用例数据的。当然,如果为了保持每个单元测试函数的独立性你也可以在每个函数中构造当前函数所需要的数据。关于单测数据的准备和处理,应该根据实际情况灵活的选择适合的方案,不要拘泥于形式,“不管白猫黑猫,抓住老鼠就是好猫”。

针对依赖其他服务的函数,一种简单的方式是提供一个可调用的服务配置到项目中供单测函数使用。另外一种就是将第三方服务抽象成接口,在被测试代码中调用接口的方法,在测试时传入 Mock 类型,从而将依赖的服务从具体的被测试函数中解耦出去。为了帮助你理解,下面我们以一段代码为例进行讲解。

package face
   
import (
 "context"
 "google.golang.org/grpc"
)

type DetectReq struct {
   Id   string `json:"id"`
   URI  string `json:"uri"`
   Type string `json:"type"`
}

type DetectRes struct {
   Id      string `json:"id"`
   Feature string `json:"feature"`
}

func Detect(req *DetectReq, ctx context.Context, client *grpc.ClientConn) (*DetectRes, error) {
 // other
 return clinet.Detect(req, ctx)
}

Detect 函数依赖于一个 gRPC 连接,但是当我们在执行单元测试时可能由于种种原因无法无法建立 gRPC 连接,从而影响单元测试的执行。不过我们可以通过下面的这种改动,让它运行起来。

package face
   
import (
 "context"
 "google.golang.org/grpc"
)

type DetectReq struct {
   Id   string `json:"id"`
   URI  string `json:"uri"`
   Type string `json:"type"`
}

type DetectRes struct {
   Id      string `json:"id"`
   Feature string `json:"feature"`
}

type EngineService interface {
 Detect(req *DetectReq, ctx context.Context) (*DetectRes, error)
}

func Detect(req *DetectReq, ctx context.Context, svc *EngineService) (*DetectRes, error) {
 // other
 return svc.Detect(req, ctx)
}

在上面的代码中,我们将 Detect 函数与 gRPC 解耦,只要我们传入一个实现了 EngineService 接口类型的实例,Detect 函数便可成功运行。这样我们在编写单元测试时,就可以自己实现一个不依赖其他服务的实例传入 Detect 函数即可。针对改写后的代码,我们可以编写下面这种单元测试:

package face

import (
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"os"
"testing"
)

type faceEngineService struct {}

type NewFakeEngineService() *EngineService {
   return &faceEngineService{}
}

func (e *faceEngineService) Detect(req *DetectReq, ctx context.Context) (*DetectRes, error) {
   res := &DetectRes{
     Id:      "2dc64674-1bac-4d6d-b4c7-54ab6b66dd91",
     Feature: "HDGBHKJHEKJBFHJKBHBCVEHVGCVGEVFGE",
  }
   return res, nil
}

func TestDetect(t *testing.T) {
   ctx, _ := context.WithTimeout(s.ctx, time.Minute*30)
   fake := NewFakeEngineService()
   req := &DetectReq{
      Id: "2dc64674-1bac-4d6d-b4c7-54ab6b66dd91",
      URI: "https://www.example.com/xxx.jpg",
      Type: "face",
  }
   res, err := Detect(req, ctx, fake)
   require.NoError(t, err)
   require.NotNil(t, res)
}

总结

对于编写单元测试来说,其实还有各种各样的库和工具可供开发者使用。例如使用 gotests 工具自动生成单元测试代码,减少开发人员的工作量。golang/mock 实现了基于 interface 的 Mock 功能,能够与 Golang 内置的 testing 包进行很好的继承。sqlmock 可以用来模拟数据库连接,假如你在单测时没有可用的数据库就可以考虑使用它。还有一个终极解决方案包 bouk/monkey 能够通过替换函数指针的方式来修改任意函数的实现。

对于到底使用哪种方式来解决你的单元测试依赖问题,从而使单元测试覆盖率更高,并没有一个标准的答案,适合的就是最好的。不过度追求复杂的设计,不妥协糟糕的代码质量,在此之间寻找一个你认为平衡的点即可。

发布者

Avatar photo

常轩

总要做点什么吧!

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注