TDD

Test-Driven Development: 测试驱动开发,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

单元测试框架提供的日志方法

方 法 备 注
Log 打印日志,同时结束测试
Logf 格式化打印日志,同时结束测试,eg: t.Logf("%+v", resp)
Error 打印错误日志,同时结束测试
Errorf 格式化打印错误日志,同时结束测试
Fatal 打印致命日志,同时结束测试
Fatalf 格式化打印致命日志,同时结束测试

单元测试

Go 为了提高测试的性能,会对包的编译后的测试代码进行缓存。

常用命令

1
2
3
4
go test . // 跑全部测试用例
go test -v -run=TestA // 指定 TestA 用例进行测试
go test -v ./local_test.go local.go local2.go // 测试单个文件,一定要带上被测试的原文件,如果原文件有其他引用,也需一并带上。-v 可以让测试时显示详细的流程。
go test ./tests -v -count=1 // Go 为了提高测试的性能,会对包的编译后的测试代码进行缓存。一般常见的,也是官方推荐的清除缓存的方式是使用 -count 参数。此参数一般用以设置测试运行的次数,如果设置为 2 的话就会运行测试两次。

测试文件中必须导入 “testing” 包,并写一些名字以 TestZzz 打头的全局函数,这里的 Zzz 是被测试函数的字母描述,如 TestFmtInterface,TestPayEmployees 等。比如:

1
2
3
func TestAbcde(t *testing.T) {
    ...
}

测试用例案例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// main/hello.go
package main

import "fmt"

const englishHelloPrefix = "Hello, "

func Hello(name string) string {
    if name == "" {
        name = "World"
    }
    return englishHelloPrefix + name
}

func main() {
    fmt.Println(Hello("Chris"))
}

// main/hello_test.go
package main

import "testing"

func TestHello(t *testing.T) {

    assertCorrectMessage := func(t *testing.T, got, want string) {
        t.Helper() // 告诉测试套件这个方法是辅助函数。通过这样做,当测试失败时所报告的行号将在 函数调用中 而不是在辅助函数内部。这将帮助其他开发人员更容易地跟踪问题。
        if got != want {
            t.Errorf("got %q wang %q", got, want)
        }
    }

    // 子测试
    t.Run("saying hello to people", func(t *testing.T) {
        name := "Chris"
        got := Hello(name)
        want := englishHelloPrefix + name

        assertCorrectMessage(t, got, want)
    })

    t.Run("saying 'Hello, World' when an empty string is supplied", func(t *testing.T) {
        got := Hello("")
        want := englishHelloPrefix +  "World"

        assertCorrectMessage(t, got, want)
    })
}

模糊测试

go1.18才可以支持模糊测试。模糊测试是通过指定的规则, 使用模糊参数变异作为输入去测试模糊目标在各种边界条件下的状况的一种测试方式。

常用命令

1
2
3
go test -fuzz=Fuzz # 全局模糊测试
go test -fuzz=FuzzReverse # 只对某个函数(Reverse)进行模糊测试
go test -run=FuzzReverse/1fdd0160e6b3dd8f1e6b7a4179b4787e0c014cf9c46c67a863d71e3a0277c213 # 测试某个失败数据:使用 -run=file 指定数据文件

示例

模糊测试的适用场景比较有限,如果函数的入参并不是像本例中的那样的简单(字符串),而是各种对象呢?可能它就无能为力了吧。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func Reverse(s string) string {
    b := []byte(s)
    for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
        b[i], b[j] = b[j], b[i]
    }
    return string(b)
}

// 记得前面导入 "unicode/utf8" 包
func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc)  // Use f.Add to provide a seed corpus
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

基准测试

基准测试主要是通过测试CPU和内存的效率问题,来评估被测试代码的性能,进而找到更好的解决方案。

对于基准测试,go test 会并发执行,默认最大是 8 个并发,可以通过-parallel指定,最终按测试函数在文件中的顺序打印测试结果。

常用命令

1
2
3
4
5
6
7
8
9
go test -bench=. // 会运行所有的基准测试函数;代码中的函数会被调用 N 次(N 是非常大的数,如 N = 1000000),并展示 N 的值和函数执行的平均时间,单位为 ns(纳秒,ns/op)。

go test -bench=. -run=none // 是否过滤掉单元测试用例:因为默认情况下 go test 会运行单元测试,为了防止单元测试的输出影响我们查看基准测试的结果,可以使用-run=匹配一个从来没有的单元测试方法,过滤掉单元测试的输出,我们这里使用none,因为我们基本上不会创建这个名字的单元测试方法。

go test -bench=. -benchtime=3s // 测试时间调整:默认是1秒,一般来说这个值最好不要超过3秒,意义不大。

go test -bench=. -run=none -benchmem // -benchmem可以提供每次操作分配内存的字节数, 以及每次操作分配内存的次数。

go test -bench="Fib$" -cpuprofile=cpu.pprof . // 压测BenchmarkFib用例,添加 -cpuprofile 参数即可生成 BenchmarkFib 对应的 CPU pprof 文件

testing 包中有一些类型和函数可以用来做简单的基准测试;测试代码中必须包含以 BenchmarkZzz 打头的函数并接收一个 *testing.B 类型的参数,比如:

1
2
3
4
5
6
7
8
9
// 示例:测试 fmt.Sprintf() 的性能
// 输出结果为:BenchmarkRepeat-12     10000000   116 ns/op。
// 重解释下输出的结果值,看到函数后面的-12了吗?这个表示运行时对应的 GOMAXPROCS 的值。接着的 10000000 表示运行 for 循环的次数,也就是调用被测试代码的次数,最后的 116 ns/op表示每次需要话费 116 纳秒。以上测试时间默认是1秒,也就是1秒的时间,调用 10000000 次,每次调用花费 116 纳秒。
func BenchmarkSprintf(b *testing.B) {
    b.ResetTimer() // 用于忽略基本测试启动时的消耗时间
    for i := 0; i < b.N; i++ {
        fmt.Sprintf("%d", 20) // //执行 b.N 次 fmt.Sprintf() 函数
    }
}

基准测试是测量一个程序在固定工作负载下的性能。在Go语言中,基准测试函数和普通测试函数写法类似,但是以Benchmark为前缀名,并且带有一个*testing.B类型的参数;testing.B参数除了提供和testing.T类似的方法,还有额外一些和性能测量相关的方法。它还提供了一个整数N,用于指定操作执行的循环次数。

基准测试基本原则

在进行基准测试之前,你必须要有一个稳定的环境以得到可重复的输出结果。

  • 机器必须是空闲状态 – 不能在共享的硬件上采集数据,当长时间运行基准测试时不能浏览网页等
  • 机器是否关闭了节能模式。一般笔记本电脑上会默认开启该模式。
  • 避免使用虚拟机和云主机。一般情况下,为了尽可能地提高资源的利用率,虚拟机和云主机 CPU 和内存一般会超分配,超分机器的性能表现会非常地不稳定。

第三方测试包

github.com/stretchr/testify

断言功能强大

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 相等
func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
func NotEqual(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
// 是否为 nil
func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
// 是否为空
func Empty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
func NotEmpty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
// 是否存在错误
func NoError(t TestingT, err error, msgAndArgs ...interface{}) bool
func Error(t TestingT, err error, msgAndArgs ...interface{}) bool
// 是否为 0 值
func Zero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool
func NotZero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool
// 是否为布尔值
func True(t TestingT, value bool, msgAndArgs ...interface{}) bool
func False(t TestingT, value bool, msgAndArgs ...interface{}) bool
// 断言长度一致
func Len(t TestingT, object interface{}, length int, msgAndArgs ...interface{}) bool
// 断言包含、子集、非子集
func NotContains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool
func Subset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool)
func NotSubset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool)
// 断言文件和目录存在
func FileExists(t TestingT, path string, msgAndArgs ...interface{}) bool
func DirExists(t TestingT, path string, msgAndArgs ...interface{}) bool

Visualising the Costs and Benefits

在受益于单元测试的好处的同时,也必然增加了代码量以及维护成本(单元测试代码也是要维护的)。下面这张成本/价值象限图很清晰的阐述了在不同性质的系统中单元测试成本和价值之间的关系。

成本与收益的象限图

  1. 依赖很少的简单的代码(左下)

    对于外部依赖少,代码又简单的代码。自然其成本和价值都是比较低的。举Go官方库里errors包为例,整个包就两个方法 New()和 Error(),没有任何外部依赖,代码也很简单,所以其单元测试起来也是相当方便。

  2. 依赖较多但是很简单的代码(右下)

    依赖一多,mock和stub就必然增多,单元测试的成本也就随之增加。但代码又如此简单(比如上述errors包的例子),这个时候写单元测试的成本已经大于其价值,还不如不写单元测试。

  3. 依赖很少的复杂代码 (左上)

    像这一类代码,是最有价值写单元测试的。比如一些独立的复杂算法(银行利息计算,保险费率计算,TCP协议解析等),像这一类代码外部依赖很少,但却很容易出错,如果没有单元测试,几乎不能保证代码质量。

  4. 依赖很多又很复杂(右上)

    这种代码显然是单元测试的噩梦。写单元测试吧,代价高昂;不写单元测试吧,风险太高。像这种代码我们尽量在设计上将其分为两部分:1.处理复杂的逻辑部分 2.处理依赖部分 然后1部分进行单元测试

结论:一般编写依赖较少的单元测试代码,收益较高

我们遇到最常见的依赖无非下面几种:

  • 网络依赖——函数执行依赖于网络请求,比如第三方http-api,rpc服务,消息队列等等
  • 数据库依赖
  • I/O依赖(文件)

参考文章

搞定Go单元测试(一)——基础原理