微信号:go-programming-lang

介绍:重点介绍go语言的高级功能,比如interface、 reflection、goroutine、concurrency、memory model、testing、functional、toolchains等.

Go语言单元测试

2016-12-29 01:28 Oscar

简介

Go 语言在设计之初就考虑到了代码的可测试性。一方面 Go 本身提供了 testing 库,使用方法很简单; 另一方面 go 的 package 提供了很多编译选项,代码和业务逻辑代码很容易解耦,可读性比较强(不妨对比一下C++测试框架)。 本文中,我们讨论的重点是 Go 语言中 的单元测试,而且只讨论一些基本的测试方法,包括下面几个方面:

  1. 一个简单的测试用例

  2. Table driven test

  3. 使用辅助测试函数(test helper)

  4. 临时文件

这里我们只涉及到一些通用的测试方法。关于 HTTP server/client 测试,这里不做深入讨论。

阅读建议

Testing shows the presence, not the absence of bugs – Edsger W. Dijkstra

在阅读本文之前,建议您对 Go 语言的 package 有一定的了解,并在实际项目中使用过,下面是一些基本的要求:

  1. 了解如何在项目中 import 一个外部的package

  2. 了解如何将自己的项目按照功能模块划分 package

  3. 了解 struct、struct字段、函数、变量名字首字母大小写的含义(非必需)

  4. 了解一些 Go语言的编译选项,比如 +build !windows(非必需)

如果你对 1、2都不太了解,建议阅读一下这篇文章How to Write Go Code,动手实践一下。

一个简单的测试用例

为了便于理解,我们首先给出一个代码片段(如果你已经使用过go 的单元测试,可以跳过这个环节):

// demo/equal.go
package demo

func equal(a, b int) bool {
  return a == b
}

// demo/equal_test.go
package demo
import (
  "testing"
)

func TestEqual(t *testing.T) {
  a := 1
  b := 1
  shouldBe := true
  if real := equal(a, b); real == shouldBe {
    t.Errorf("equal(%d, %d) should be %v, but is:%v\n", a, b, shouldBe, real)
  }
}

上面这个例子中,如果你从来没有使用过单元测试,建议在本地开发环境中运行一次。这里有几点需要注意一下:

  1. 这两个文件的父目录必须与包名一致(这里是 demo),且包名必须是在 $GOPATH 下

  2. 测试用例的函数命名必须符合 TestXXX 格式,并且参数是 t *testing.T

  3. 了解一下 t.Errorf 与 t.Fatalf 的行为差异

Table Driven Test

上面的测试用例中,我们一次只能测试一种情况,如果我们希望在一个 TestXXX 函数中进行很多项测试,Table Driven Test 就派上了用场。 举个例子,假设我们实现了自己的 Sqrt 函数 mymath.Sqrt,我们需要对其进行测试:

首先,我们需要考虑一些特殊情况:

  1. Sqrt(+Inf) = +Inf

  2. Sqrt(±0) = ±0

  3. Sqrt(x < 0) = NaN

  4. Sqrt(NaN) = NaN

然后,我们需要考虑一般情况:

  1. Sqrt(1.0) = 1.0

  2. Sqrt(4.0) = 2.0

注意:在一般情况中,我们对结果进行验证时,需要考虑小数点精确位数的问题。由于文章篇幅限制,这里不做额外的处理。

有了思路以后,我们可以基于 Table Driven Test 实现测试用例:

func TestSqrt(t *testing.T) {
  var shouldSuccess = []struct {
    input    float64 // 输入
    expected float64 // 期望结果
  }{
    {math.Inf(1), math.Inf(1)}, //正无穷
    {math.Inf(-1), math.NaN()}, //负无穷
    {-1.0, math.NaN()},
    {0.0, 0.0},
    {-0.0, -0.0},
    {1.0, 1.0},
    {4.0, 2.0},
  }
  for _, ts := range shouldSuccess {
    if actual := Sqrt(t.input); actual != ts.expected {
      t.Fatalf("Sqrt(%f) should be %v, but is:%v\n", ts.input, ts.expected, actual)
    }
  }
}

辅助函数

在写测试的过程中,我们可能遇到下面几个场景:

  1. 待测试的功能需要一些前提条件,比如初始化数据库连接、打开文件、创建资源

  2. 核心功能测试结束后,需要一些清理工作,比如关闭文件、销毁资源

  3. 待测试的功能错误分类比较多,考虑到table driven test,写到一个测试函数里可读性比较差


这时候,我们需要定义一些辅助函数,以协助核心功能的测试。下面我们以用户登录校验为例,来看如何使用辅助函数。 我们要测试的函数是 login,为了保证本次单元测试不会污染数据库,我们采取的流程是:

  1. 初始化数据库连接

  2. 创建一个用户

  3. 测试 login

  4. 删除该用户

确定了测试的逻辑以后,我们看下代码:

// file name: user_test.go
// source code: https://github.com/oscarzhao/blogger-server/blob/master/controllers/user_test.go

// 在 package 级别进行数据库连接初始化
func init() {
  // 一些数据库初始化操作
}

// testCreateUser 创建一个临时用户
func testCreateUser(t *testing, userSpec map[string]string) (int, []byte) {
 ... } // testDeleteUser 根据 userID 删除一个用户 func testDeleteUser(t *testing.T, userID string) (int, []byte) {  ... } // TestVerifyLogin 创建用户、测试登录,然后删除该用户 // 该函数由 go 语言的 test 框架调用 func TestVerifyLogin(t *testing.T) {  // 初始化 userID 和 data  statusCode, msg := testCreateUser(t, userID, data)  if statusCode >= http.StatusBadRequest {    t.Fatalf("should succeeed, create user (%s), but fails, error:%s\n", userID, msg)  }  // 测试结束时,清理数据  defer func(userID string) {    statusCode, msg := testDeleteUser(t, userID)    if statusCode >= http.StatusBadRequest {      // 处理错误 ...    }  }(userID)  // 测试登录功能  shouldSuccess := xxx  for _, ts := range shouldSuccess {    statusCode, msg = testVerifyPassword(t, ts)    if statusCode != http.StatusOK {      // 处理错误 ...    }  } }

在测试代码中,我们推荐使用 t.Fatalf , 而不是 t.Errorf,一方面测试代码不需要做太多容错,另一方面增加了测试代码的可读性。

临时文件

如果待测试的功能模块涉及到文件操作,临时文件是一个不错的解决方案。go语言的 ioutil 包提供了 TempDir 和 TempFile 方法,供我们使用。

我们以 etcd 创建 wal 文件为例,来看一下 TempDir 的用法:

// github.com/coreos/etcd/wal/wal_test.go

func TestNew(t *testing.T) {
  p, err := ioutil.TempDir(os.TempDir(), "waltest")
  if err != nil {
    t.Fatal(err)
  }
  defer os.RemoveAll(p)  // 千万不要忘记删除目录

  w, err := Create(p, []byte("somedata"))
  if err != nil {
    t.Fatalf("err = %v, want nil", err)
  }
  if g := path.Base(w.tail().Name()); g != walName(0, 0) {
    t.Errorf("name = %+v, want %+v", g, walName(0, 0))
  }
  defer w.Close()

  // 将文件 waltest 中的数据读取到变量 gb []byte 中 

  // 根据 "somedata" 生成数据,存储在变量 wb byte.Buffer 中

  // 临时文件中的数据(gb)与 生成的数据(wb)进行对比
  if !bytes.Equal(gd, wb.Bytes()) {
    t.Errorf("data = %v, want %v", gd, wb.Bytes())
  }
}

上面这段代码是从 etcd 中摘取出来的,源码查看 coreos/etcd - Github。 需要注意的是,使用 TempDir 和 TempFile 创建文件以后,需要自己去删除。

关于 package

在写单元测试时,一般情况下,我们将功能代码和测试代码放到同一个目录下,仅以后缀 _test 进行区分。

对于复杂的大型项目,功能依赖比较多时,通常在跟目录下再增加一个 test 文件夹,不同的测试 放到不同的子目录下面,如下图所示:

针对自己的项目进行测试时,可以结合这两种方式实现测试用例,提高代码的可读性和可维护性。

参考链接:

  1. golang.org/pkg/testing

  2. Testing Techniques:https://talks.golang.org/2014/testing.slide%20%E2%80%9Ctesting%20techniques%22

  3. Table Driven Test:https://github.com/golang/go/wiki/TableDrivenTests

扫码关注微信公众号“深入Go语言”


 
深入Go语言 更多文章 Go语言并发模型:使用 select Go语言并发模型:使用 context Go语言并发模型:以并行处理MD5为例 Go语言并发模型:像Unix Pipe那样使用channel Go语言反射三定律
猜您喜欢 PHP100大讲堂:直播【2013-07-04】跟我一起玩转Mysql数据库(1) 互联网三大集团:程序员、设计师、极客手机/桌面壁纸下载 10个小方法让你的数据更引人注目 学习 Python 的三种境界 【科普】从三体人列计算机到神经网络