testing - 单元测试

1. testing - 单元测试

testing 为 Go 语言 package 提供自动化测试的支持。通过 go test 命令,能够自动执行如下形式的任何函数:

1
func TestXxx(*testing.T)

注意:Xxx 可以是任何字母数字字符串,但是第一个字母不能是小写字母。

在这些函数中,使用 ErrorFail 或相关方法来发出失败信号。

要编写一个新的测试套件,需要创建一个名称以 _test.go 结尾的文件,该文件包含 TestXxx 函数,如上所述。 将该文件放在与被测试文件相同的包中。该文件将被排除在正常的程序包之外,但在运行 go test 命令时将被包含。 有关详细信息,请运行 go help testgo help testflag 了解。

如果有需要,可以调用 *T*BSkip 方法,跳过该测试或基准测试:

1
2
3
4
5
6
func TestTimeConsuming(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
...
}

1.1. 第一个单元测试

要测试的代码:

1
2
3
4
5
6
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-2)
}

测试代码:

1
2
3
4
5
6
7
8
9
10
func TestFib(t *testing.T) {
var (
in = 7
expected = 13
)
actual := Fib(in)
if actual != expected {
t.Errorf("Fib(%d) = %d; expected %d", in, actual, expected)
}
}

执行 go test .,输出:

1
2
$ go test .
ok chapter09/testing 0.007s

表示测试通过。

我们将 Sum 函数改为:

1
2
3
4
5
6
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-1)
}

再执行 go test .,输出:

1
2
3
4
5
$ go test .
--- FAIL: TestSum (0.00s)
t_test.go:16: Fib(10) = 64; expected 13
FAIL
FAIL chapter09/testing 0.009s

1.2. Table-Driven Test

测试讲究 case 覆盖,按上面的方式,当我们要覆盖更多 case 时,显然通过修改代码的方式很笨拙。这时我们可以采用 Table-Driven 的方式写测试,标准库中有很多测试是使用这种方式写的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestFib(t *testing.T) {
var fibTests = []struct {
in int // input
expected int // expected result
}{
{1, 1},
{2, 1},
{3, 2},
{4, 3},
{5, 5},
{6, 8},
{7, 13},
}

for _, tt := range fibTests {
actual := Fib(tt.in)
if actual != tt.expected {
t.Errorf("Fib(%d) = %d; expected %d", tt.in, actual, tt.expected)
}
}
}

由于我们使用的是 t.Errorf,即使其中某个 case 失败,也不会终止测试执行。

1.3. T 类型

单元测试中,传递给测试函数的参数是 *testing.T 类型。它用于管理测试状态并支持格式化测试日志。测试日志会在执行测试的过程中不断累积,并在测试完成时转储至标准输出。

当测试函数返回时,或者当测试函数调用 FailNowFatalFatalfSkipNowSkipSkipf 中的任意一个时,则宣告该测试函数结束。跟 Parallel 方法一样,以上提到的这些方法只能在运行测试函数的 goroutine 中调用。

至于其他报告方法,比如 Log 以及 Error 的变种, 则可以在多个 goroutine 中同时进行调用。

1.3.1. 报告方法

上面提到的系列包括方法,带 f 的是格式化的,格式化语法参考 fmt 包。

T 类型内嵌了 common 类型,common 提供这一系列方法,我们经常会用到的(注意,这里说的测试中断,都是指当前测试函数):

1)当我们遇到一个断言错误的时候,标识这个测试失败,会使用到:

1
2
Fail : 测试失败,测试继续,也就是之后的代码依然会执行
FailNow : 测试失败,测试中断

FailNow 方法实现的内部,是通过调用 runtime.Goexit() 来中断测试的。

2)当我们遇到一个断言错误,只希望跳过这个错误,但是不希望标识测试失败,会使用到:

1
SkipNow : 跳过测试,测试中断

SkipNow 方法实现的内部,是通过调用 runtime.Goexit() 来中断测试的。

3)当我们只希望打印信息,会用到 :

1
2
Log : 输出信息
Logf : 输出格式化的信息

注意:默认情况下,单元测试成功时,它们打印的信息不会输出,可以通过加上 -v 选项,输出这些信息。但对于基准测试,它们总是会被输出。

4)当我们希望跳过这个测试,并且打印出信息,会用到:

1
2
Skip : 相当于 Log + SkipNow
Skipf : 相当于 Logf + SkipNow

5)当我们希望断言失败的时候,标识测试失败,并打印出必要的信息,但是测试继续,会用到:

1
2
Error : 相当于 Log + Fail
Errorf : 相当于 Logf + Fail

6)当我们希望断言失败的时候,标识测试失败,打印出必要的信息,但中断测试,会用到:

1
2
Fatal : 相当于 Log + FailNow
Fatalf : 相当于 Logf + FailNow

1.3.2. Parallel 测试

包中的 Parallel 方法表示当前测试只会与其他带有 Parallel 方法的测试并行进行测试。

下面例子将演示 Parallel 的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var (
data = make(map[string]string)
locker sync.RWMutex
)

func WriteToMap(k, v string) {
locker.Lock()
defer locker.Unlock()
data[k] = v
}

func ReadFromMap(k string) string {
locker.RLock()
defer locker.RUnlock()
return data[k]
}

测试代码:

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
var pairs = []struct {
k string
v string
}{
{"polaris", " 徐新华 "},
{"studygolang", "Go 语言中文网 "},
{"stdlib", "Go 语言标准库 "},
{"polaris1", " 徐新华 1"},
{"studygolang1", "Go 语言中文网 1"},
{"stdlib1", "Go 语言标准库 1"},
{"polaris2", " 徐新华 2"},
{"studygolang2", "Go 语言中文网 2"},
{"stdlib2", "Go 语言标准库 2"},
{"polaris3", " 徐新华 3"},
{"studygolang3", "Go 语言中文网 3"},
{"stdlib3", "Go 语言标准库 3"},
{"polaris4", " 徐新华 4"},
{"studygolang4", "Go 语言中文网 4"},
{"stdlib4", "Go 语言标准库 4"},
}

// 注意 TestWriteToMap 需要在 TestReadFromMap 之前
func TestWriteToMap(t *testing.T) {
t.Parallel()
for _, tt := range pairs {
WriteToMap(tt.k, tt.v)
}
}

func TestReadFromMap(t *testing.T) {
t.Parallel()
for _, tt := range pairs {
actual := ReadFromMap(tt.k)
if actual != tt.v {
t.Errorf("the value of key(%s) is %s, expected: %s", tt.k, actual, tt.v)
}
}
}

试验步骤:

  1. 注释掉 WriteToMap 和 ReadFromMap 中 locker 保护的代码,同时注释掉测试代码中的 t.Parallel,执行测试,测试通过,即使加上 -race,测试依然通过;
  2. 只注释掉 WriteToMap 和 ReadFromMap 中 locker 保护的代码,执行测试,测试失败(如果未失败,加上 -race 一定会失败);

如果代码能够进行并行测试,在写测试时,尽量加上 Parallel,这样可以测试出一些可能的问题。

关于 Parallel 的更多内容,会在 子测试 中介绍。

当你写完一个函数,结构体,main 之后,你下一步需要的就是测试了。testing 包提供了很简单易用的测试包。

2. 写一个基本的测试用例

测试文件的文件名需要以_test.go 为结尾,测试用例需要以 TestXxxx 的形式存在。

比如我要测试 utils 包的 sql.go 中的函数:

1
func GetOne(db *sql.DB, query string, args ...interface{}) (map[string][]byte, error) {

就需要创建一个 sql_test.go

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
package utils

import (
"database/sql"
_ "fmt"
_ "github.com/go-sql-driver/mysql"
"strconv"
"testing"
)

func Test_GetOne(t *testing.T) {
db, err := sql.Open("mysql", "root:123.abc@tcp(192.168.33.10:3306)/test")
defer func() {
db.Close()
}()
if err != nil {
t.Fatal(err)
}

// 测试 empty
car_brand, err := GetOne(db, "select * from user where id = 999999")
if (car_brand != nil) || (err != nil) {
t.Fatal("emtpy 测试错误 ")
}
}

3. testing 的测试用例形式

测试用例有四种形式:

1
2
3
4
TestXxxx(t *testing.T)    // 基本测试用例
BenchmarkXxxx(b *testing.B) // 压力测试的测试用例
Example_Xxx() // 测试控制台输出的例子
TestMain(m *testing.M) // 测试 Main 函数

给个 Example 的例子 :(Example 需要在最后用注释的方式确认控制台输出和预期是不是一致的)

1
2
3
4
5
6
func Example_GetScore() {
score := getScore(100, 100, 100, 2.1)
fmt.Println(score)
// Output:
// 31.1
}

4. testing 的变量

gotest 的变量有这些:

  • test.short : 一个快速测试的标记,在测试用例中可以使用 testing.Short() 来绕开一些测试
  • test.outputdir : 输出目录
  • test.coverprofile : 测试覆盖率参数,指定输出文件
  • test.run : 指定正则来运行某个 / 某些测试用例
  • test.memprofile : 内存分析参数,指定输出文件
  • test.memprofilerate : 内存分析参数,内存分析的抽样率
  • test.cpuprofile : cpu 分析输出参数,为空则不做 cpu 分析
  • test.blockprofile : 阻塞事件的分析参数,指定输出文件
  • test.blockprofilerate : 阻塞事件的分析参数,指定抽样频率
  • test.timeout : 超时时间
  • test.cpu : 指定 cpu 数量
  • test.parallel : 指定运行测试用例的并行数

5. testing 的结构体

  • B : 压力测试
  • BenchmarkResult : 压力测试结果
  • Cover : 代码覆盖率相关结构体
  • CoverBlock : 代码覆盖率相关结构体
  • InternalBenchmark : 内部使用的结构体
  • InternalExample : 内部使用的结构体
  • InternalTest : 内部使用的结构体
  • M : main 测试使用的结构体
  • PB : Parallel benchmarks 并行测试使用的结构体
  • T : 普通测试用例
  • TB : 测试用例的接口

6. testing 的通用方法

T 结构内部是继承自 common 结构,common 结构提供集中方法,是我们经常会用到的:

1)当我们遇到一个断言错误的时候,我们就会判断这个测试用例失败,就会使用到:

1
2
Fail : case 失败,测试用例继续
FailedNow : case 失败,测试用例中断

2)当我们遇到一个断言错误,只希望跳过这个错误,但是不希望标示测试用例失败,会使用到:

1
SkipNow : case 跳过,测试用例不继续

3)当我们只希望在一个地方打印出信息,我们会用到 :

1
2
Log : 输出信息
Logf : 输出有 format 的信息

4)当我们希望跳过这个用例,并且打印出信息 :

1
2
Skip : Log + SkipNow
Skipf : Logf + SkipNow

5)当我们希望断言失败的时候,测试用例失败,打印出必要的信息,但是测试用例继续:

1
2
Error : Log + Fail
Errorf : Logf + Fail

6)当我们希望断言失败的时候,测试用例失败,打印出必要的信息,测试用例中断:

1
2
Fatal : Log + FailNow
Fatalf : Logf + FailNow

testing - 基准测试

1.testing - 基准测试

在 _test.go 结尾的测试文件中,如下形式的函数:

1
func BenchmarkXxx(*testing.B)

被认为是基准测试,通过 go test 命令,加上 -bench 标志来执行。多个基准测试按照顺序运行。

基准测试函数的形式如下:

1
2
3
4
5
func BenchmarkHello(b *testing.B) {
for i := 0; i < b.N; i++ {
fmt.Sprintf("hello")
}
}

基准函数会运行目标代码 b.N 次。在基准执行期间,程序会自动调整 b.N 直到基准测试函数持续足够长的时间。输出结果形如:

1
BenchmarkHello    10000000    282 ns/op

意味着循环执行了 10000000 次,每次循环花费 282 纳秒 (ns)。

如果基准测试在循环前需要一些耗时的配置,则可以先重置定时器:

1
2
3
4
5
6
7
func BenchmarkBigLen(b *testing.B) {
big := NewBig()
b.ResetTimer()
for i := 0; i < b.N; i++ {
big.Len()
}
}

如果基准测试需要在并行设置中测试性能,则可以使用 RunParallel 辅助函数 ; 这样的基准测试一般与 go test -cpu 标志一起使用:

1
2
3
4
5
6
7
8
9
10
11
12
func BenchmarkTemplateParallel(b *testing.B) {
templ := template.Must(template.New("test").Parse("Hello, {{.}}!"))
b.RunParallel(func(pb *testing.PB) {
// 每个 goroutine 有属于自己的 bytes.Buffer.
var buf bytes.Buffer
for pb.Next() {
// 循环体在所有 goroutine 中总共执行 b.N 次
buf.Reset()
templ.Execute(&buf, "World")
}
})
}

1.1. 基准测试示例

接着上一节的例子,我们对 Fib 进行基准测试:

1
2
3
4
5
func BenchmarkFib10(b *testing.B) {
for n := 0; n < b.N; n++ {
Fib(10)
}
}

执行 go test -bench=.,输出:

1
2
3
4
$ go test -bench=.
BenchmarkFib10-4 3000000 424 ns/op
PASS
ok chapter09/testing 1.724s

这里测试了 Fib(10) 的情况,我们可能需要测试更多不同的情况,这时可以改写我们的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
func BenchmarkFib1(b *testing.B)  { benchmarkFib(1, b) }
func BenchmarkFib2(b *testing.B) { benchmarkFib(2, b) }
func BenchmarkFib3(b *testing.B) { benchmarkFib(3, b) }
func BenchmarkFib10(b *testing.B) { benchmarkFib(10, b) }
func BenchmarkFib20(b *testing.B) { benchmarkFib(20, b) }
func BenchmarkFib40(b *testing.B) { benchmarkFib(40, b) }

func benchmarkFib(i int, b *testing.B) {
for n := 0; n < b.N; n++ {
Fib(i)
}
}

再次执行 go test -bench=.,输出:

1
2
3
4
5
6
7
8
9
$ go test -bench=.
BenchmarkFib1-4 1000000000 2.58 ns/op
BenchmarkFib2-4 200000000 7.38 ns/op
BenchmarkFib3-4 100000000 13.0 ns/op
BenchmarkFib10-4 3000000 429 ns/op
BenchmarkFib20-4 30000 54335 ns/op
BenchmarkFib40-4 2 805759850 ns/op
PASS
ok chapter09/testing 15.361s

默认情况下,每个基准测试最少运行 1 秒。如果基准测试函数返回时,还不到 1 秒钟,b.N 的值会按照序列 1,2,5,10,20,50,… 增加,同时再次运行基准测测试函数。

我们注意到 BenchmarkFib40 一共才运行 2 次。为了更精确的结果,我们可以通过 -benchtime 标志指定运行时间,从而使它运行更多次。

1
2
$ go test -bench=Fib40 -benchtime=20s
BenchmarkFib40-4 30 838675800 ns/op

1.2. B 类型

B 是传递给基准测试函数的一种类型,它用于管理基准测试的计时行为,并指示应该迭代地运行测试多少次。

当基准测试函数返回时,或者当基准测试函数调用 FailNowFatalFatalfSkipNowSkipSkipf 中的任意一个方法时,则宣告测试函数结束。至于其他报告方法,比如 LogError 的变种,则可以在其他 goroutine 中同时进行调用。

跟单元测试一样,基准测试会在执行的过程中积累日志,并在测试完毕时将日志转储到标准错误。但跟单元测试不一样的是,为了避免基准测试的结果受到日志打印操作的影响,基准测试总是会把日志打印出来。

B 类型中的报告方法使用方式和 T 类型是一样的,一般来说,基准测试中也不需要使用,毕竟主要是测性能。这里我们对 B 类型中其他的一些方法进行讲解。

1.2.1. 计时方法

有三个方法用于计时:

  1. StartTimer:开始对测试进行计时。该方法会在基准测试开始时自动被调用,我们也可以在调用 StopTimer 之后恢复计时;
  2. StopTimer:停止对测试进行计时。当你需要执行一些复杂的初始化操作,并且你不想对这些操作进行测量时,就可以使用这个方法来暂时地停止计时;
  3. ResetTimer:对已经逝去的基准测试时间以及内存分配计数器进行清零。对于正在运行中的计时器,这个方法不会产生任何效果。本节开头有使用示例。

1.2.2. 并行执行

通过 RunParallel 方法能够并行地执行给定的基准测试。RunParallel会创建出多个 goroutine,并将 b.N 分配给这些 goroutine 执行,其中 goroutine 数量的默认值为 GOMAXPROCS。用户如果想要增加非 CPU 受限(non-CPU-bound)基准测试的并行性,那么可以在 RunParallel 之前调用 SetParallelism(如 SetParallelism(2),则 goroutine 数量为 2*GOMAXPROCS)。RunParallel 通常会与 -cpu 标志一同使用。

body 函数将在每个 goroutine 中执行,这个函数需要设置所有 goroutine 本地的状态,并迭代直到 pb.Next 返回 false 值为止。因为 StartTimerStopTimeResetTimer 这三个方法都带有全局作用,所以 body 函数不应该调用这些方法; 除此之外,body 函数也不应该调用 Run 方法。

具体的使用示例,在本节开头已经提供!

1.2.3. 内存统计

ReportAllocs 方法用于打开当前基准测试的内存统计功能, 与 go test 使用 -benchmem 标志类似,但 ReportAllocs 只影响那些调用了该函数的基准测试。

测试示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
func BenchmarkTmplExucte(b *testing.B) {
b.ReportAllocs()
templ := template.Must(template.New("test").Parse("Hello, {{.}}!"))
b.RunParallel(func(pb *testing.PB) {
// Each goroutine has its own bytes.Buffer.
var buf bytes.Buffer
for pb.Next() {
// The loop body is executed b.N times total across all goroutines.
buf.Reset()
templ.Execute(&buf, "World")
}
})
}

测试结果类似这样:

1
BenchmarkTmplExucte-4        2000000           898 ns/op         368 B/op           9 allocs/op

1.2.4. 基准测试结果

对上述结果中的每一项,你是否都清楚是什么意思呢?

  • 2000000 :基准测试的迭代总次数 b.N
  • 898 ns/op:平均每次迭代所消耗的纳秒数
  • 368 B/op:平均每次迭代内存所分配的字节数
  • 9 allocs/op:平均每次迭代的内存分配次数

testing 包中的 BenchmarkResult 类型能为你提供帮助,它保存了基准测试的结果,定义如下:

1
2
3
4
5
6
7
type BenchmarkResult struct {
N int // The number of iterations. 基准测试的迭代总次数,即 b.N
T time.Duration // The total time taken. 基准测试的总耗时
Bytes int64 // Bytes processed in one iteration. 一次迭代处理的字节数,通过 b.SetBytes 设置
MemAllocs uint64 // The total number of memory allocations. 内存分配的总次数
MemBytes uint64 // The total number of bytes allocated. 内存分配的总字节数
}

该类型还提供了每次迭代操作所消耗资源的计算方法,示例如下:

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
package main

import (
"bytes"
"fmt"
"testing"
"text/template"
)

func main() {
benchmarkResult := testing.Benchmark(func(b *testing.B) {
templ := template.Must(template.New("test").Parse("Hello, {{.}}!"))
// RunParallel will create GOMAXPROCS goroutines
// and distribute work among them.
b.RunParallel(func(pb *testing.PB) {
// Each goroutine has its own bytes.Buffer.
var buf bytes.Buffer
for pb.Next() {
// The loop body is executed b.N times total across all goroutines.
buf.Reset()
templ.Execute(&buf, "World")
}
})
})

// fmt.Printf("%8d\t%10d ns/op\t%10d B/op\t%10d allocs/op\n", benchmarkResult.N, benchmarkResult.NsPerOp(), benchmarkResult.AllocedBytesPerOp(), benchmarkResult.AllocsPerOp())
fmt.Printf("%s\t%s\n", benchmarkResult.String(), benchmarkResult.MemString())
}

testing - 子测试与子基准测试

从 Go 1.7 开始,引入了一个新特性:子测试(subtests)与子基准测试(sub-benchmarks),它意味着您现在可以拥有嵌套测试,这对于过滤执行特定测试用例非常有用。

T 和 B 的 Run 方法允许定义子单元测试和子基准测试,而不必为它们单独定义函数。这便于创建基于 Table-Driven 的基准测试和层级测试。它还提供了一种共享通用 setuptear-down 代码的方法:

1
2
3
4
5
6
7
func TestFoo(t *testing.T) {
// <setup code>
t.Run("A=1", func(t *testing.T) { ... })
t.Run("A=2", func(t *testing.T) { ... })
t.Run("B=1", func(t *testing.T) { ... })
// <tear-down code>
}

每个子测试和子基准测试都有一个唯一的名称:由顶层测试的名称与传递给 Run 的名称组成,以斜杠分隔,并具有可选的尾随序列号,用于消除歧义。

命令行标志 -run-bench 的参数是非固定的正则表达式,用于匹配测试名称。对于由斜杠分隔的测试名称,例如子测试的名称,它名称本身即可作为参数,依次匹配由斜杠分隔的每部分名称。因为参数是非固定的,一个空的表达式匹配任何字符串,所以下述例子中的 “匹配” 意味着 “顶层/子测试名称包含有”:

1
2
3
4
go test -run ''      # 执行所有测试。
go test -run Foo # 执行匹配 "Foo" 的顶层测试,例如 "TestFooBar"。
go test -run Foo/A= # 对于匹配 "Foo" 的顶层测试,执行其匹配 "A=" 的子测试。
go test -run /A=1 # 执行所有匹配 "A=1" 的子测试。

子测试也可用于程序并行控制。只有子测试全部执行完毕后,父测试才会完成。在下述例子中,所有子测试之间并行运行,此处的 “并行” 只限于这些子测试之间,并不影响定义在其他顶层测试中的子测试:

1
2
3
4
5
6
7
8
9
func TestGroupedParallel(t *testing.T) {
for _, tc := range tests {
tc := tc // capture range variable
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
...
})
}
}

在所有子测试并行运行完毕之前,Run 方法不会返回。下述例子提供了一种方法,用于在子测试并行运行完毕后清理资源:

1
2
3
4
5
6
7
8
9
func TestTeardownParallel(t *testing.T) {
// This Run will not return until the parallel tests finish.
t.Run("group", func(t *testing.T) {
t.Run("Test1", parallelTest1)
t.Run("Test2", parallelTest2)
t.Run("Test3", parallelTest3)
})
// <tear-down code>
}

testing - 运行并验证示例

testing 包除了测试,还提供了运行并验证示例的功能。示例,一方面是文档的效果,是关于某个功能的使用例子;另一方面,可以被当做测试运行。

一个示例的例子如下:

1
2
3
4
func ExampleHello() {
fmt.Println("Hello")
// Output: Hello
}

如果 Output: Hello 改为:Output: hello,运行测试会失败,提示:

1
2
3
4
got:
Hello
want:
hello

一个示例函数以 Example 开头,如果示例函数包含以 “Output:” 开头的行注释,在运行测试时,go 会将示例函数的输出和 “Output:” 注释中的值做比较,就如上面的例子。

有时候,输出顺序可能不确定,比如循环输出 map 的值,那么可以使用 “Unordered output:” 开头的注释。

如果示例函数没有上述输出注释,该示例函数只会被编译而不会被运行。

1.1. 命名约定

Go 语言通过大量的命名约定来简化工具的复杂度,规范代码的风格。对示例函数的命名有如下约定:

  • 包级别的示例函数,直接命名为 func Example() { ... }
  • 函数 F 的示例,命名为 func ExampleF() { ... }
  • 类型 T 的示例,命名为 func ExampleT() { ... }
  • 类型 T 上的 方法 M 的示例,命名为 func ExampleT_M() { ... }

有时,我们想要给 包 / 类型 / 函数 / 方法 提供多个示例,可以通过在示例函数名称后附加一个不同的后缀来实现,但这种后缀必须以小写字母开头,如:

1
2
3
4
func Example_suffix() { ... }
func ExampleF_suffix() { ... }
func ExampleT_suffix() { ... }
func ExampleT_M_suffix() { ... }

通常,示例代码会放在单独的示例文件中,命名为 example_test.go。可以查看 io 包中的 example_test.go 了解示例的编写。

1.2. 实现原理

本节开头提到了示例的两个作用,它们分别是由 godocgo test 这两个命令实现的。

在执行 go test 时,会运行示例。具体的实现原理,可以通过阅读 go test 命令源码和 testing 包中 example.go 文件了解。

testing - 其他功能

1.1. TestMain

在写测试时,有时需要在测试之前或之后进行额外的设置(setup)或拆卸(teardown);有时,测试还需要控制在主线程上运行的代码。为了支持这些需求,testing 包提供了 TestMain 函数 :

1
func TestMain(m *testing.M)

如果测试文件中包含该函数,那么生成的测试将调用 TestMain(m),而不是直接运行测试。TestMain 运行在主 goroutine 中 , 可以在调用 m.Run 前后做任何设置和拆卸。注意,在 TestMain 函数的最后,应该使用 m.Run 的返回值作为参数去调用 os.Exit

另外,在调用 TestMain 时 , flag.Parse 并没有被调用。所以,如果 TestMain 依赖于 command-line 标志(包括 testing 包的标志),则应该显式地调用 flag.Parse。注意,这里的依赖是指,若 TestMain 函数内需要用到 command-line 标志,则必须显式地调用 flag.Parse,否则不需要,因为 m.Run 中调用 flag.Parse

一个包含 TestMain 的例子如下:

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
package mytestmain

import (
"flag"
"fmt"
"os"
"testing"
)

var db struct {
Dns string
}

func TestMain(m *testing.M) {
db.Dns = os.Getenv("DATABASE_DNS")
if db.Dns == "" {
db.Dns = "root:123456@tcp(localhost:3306)/?charset=utf8&parseTime=True&loc=Local"
}

flag.Parse()
exitCode := m.Run()

db.Dns = ""

// 退出
os.Exit(exitCode)
}

func TestDatabase(t *testing.T) {
fmt.Println(db.Dns)
}

m.Run 感兴趣的可以阅读源码,了解其原理。

1.2. Test Coverage

测试覆盖率,这里讨论的是基于代码的测试覆盖率。

Go 从 1.2 开始,引入了对测试覆盖率的支持,使用的是与 cover 相关的工具(go test -covergo tool cover)。虽然 testing 包提供了 cover 相关函数,不过它们是给 cover 的工具使用的。

关于测试覆盖率的更多信息,可以参考官方的博文:The cover story

httptest - HTTP 测试辅助工具

1.httptest - HTTP 测试辅助工具

由于 Go 标准库的强大支持,Go 可以很容易的进行 Web 开发。为此,Go 标准库专门提供了 net/http/httptest 包专门用于进行 http Web 开发测试。

本节我们通过一个社区帖子的增删改查的例子来学习该包。

1.1. 简单的 Web 应用

我们首先构建一个简单的 Web 应用。

为了简单起见,数据保存在内存,并且没有考虑并发问题。

1
2
3
4
5
6
7
8
9
// 保存 Topic,没有考虑并发问题
var TopicCache = make([]*Topic, 0, 16)

type Topic struct {
Id int `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}

对于 Topic 的增删改查代码很简单,可以查看完整代码

接下来,是通过 net/http 包来实现一个 Web 应用。

1
2
3
4
5
func main() {
http.HandleFunc("/topic/", handleRequest)
http.ListenAndServe(":2017", nil)
}
...

/topic/ 开头的请求都交由 handleRequest 处理,它根据不同的 Method 执行相应的增删改查,详细代码可以查看 server.go

准备好 Web 应用后,我们启动它。

go run server.go data.go

通过 curl 进行简单的测试:

增:curl -i -X POST http://localhost:2017/topic/ -H ‘content-type: application/json’ -d ‘{“title”:”The Go Standard Library”,”content”:”It contains many packages.”}’

查:curl -i -X GET http://localhost:2017/topic/1

改:curl -i -X PUT http://localhost:2017/topic/1 -H ‘content-type: application/json’ -d ‘{“title”:”The Go Standard Library By Example”,”content”:”It contains many packages, enjoying it.”}’

删:curl -i -X DELETE http://localhost:2017/topic/1

1.2. 通过 httptest 进行测试

上面,我们通过 curl 对我们的 Web 应用的接口进行了测试。现在,我们通过 net/http/httptest 包进行测试。

我们先测试创建帖子,也就是测试 handlePost 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func TestHandlePost(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/topic/", handleRequest)

reader := strings.NewReader(`{"title":"The Go Standard Library","content":"It contains many packages."}`)
r, _ := http.NewRequest(http.MethodPost, "/topic/", reader)

w := httptest.NewRecorder()

mux.ServeHTTP(w, r)

resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("Response code is %v", resp.StatusCode)
}
}

首先跟待测试代码一样,配置上路由,对 /topic/ 的请求都交由 handleRequest 处理。

1
2
mux := http.NewServeMux()
mux.HandleFunc("/topic/", handleRequest)

因为 handlePost 的函数签名是 func handlePost(w http.ResponseWriter, r *http.Request) error,为了测试它,我们必须创建 http.ResponseWriterhttp.Request 的实例。

接下来的代码就是创建一个 http.Request 实例 和一个 http.ResponseWriter 的实例。这里的关键是,通过 httptest.NewRecorder() 可以获得 httptest.ResponseRecorder 结构,而此结构实现了http.ResponseWriter 接口。

1
2
3
4
reader := strings.NewReader(`{"title":"The Go Standard Library","content":"It contains many packages."}`)
r, _ := http.NewRequest(http.MethodPost, "/topic/", reader)

w := httptest.NewRecorder()

准备好之后,可以测试目标函数了。这里,我们没有直接调用 handlePost(w, r),而是调用 mux.ServeHTTP(w, r),实际上这里直接调用 handlePost(w, r) 也是可以的,但调用 mux.ServeHTTP(w, r) 会更完整地测试整个流程。mux.ServeHTTP(w, r) 最终也会调用到 handlePost(w, r)

最后,通过 go test -v 运行测试。

查、改和删帖子的接口测试代码类似,比如,handleGet 的测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func TestHandleGet(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/topic/", handleRequest)

r, _ := http.NewRequest(http.MethodGet, "/topic/1", nil)

w := httptest.NewRecorder()

mux.ServeHTTP(w, r)

resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("Response code is %v", resp.StatusCode)
}

topic := new(Topic)
json.Unmarshal(w.Body.Bytes(), topic)
if topic.Id != 1 {
t.Errorf("Cannot get topic")
}
}

注意:因为数据没有落地存储,为了保证后面的测试正常,请将 TestHandlePost 放在最前面。

1.3. 测试代码改进

细心的朋友应该会发现,上面的测试代码有重复,比如:

1
2
mux := http.NewServeMux()
mux.HandleFunc("/topic/", handleRequest)

以及:

1
w := httptest.NewRecorder()

这正好是前面学习的 setup 可以做的事情,因此可以使用 TestMain 来做重构。

1
2
3
4
5
6
7
8
9
var w *httptest.ResponseRecorder

func TestMain(m *testing.M) {
http.DefaultServeMux.HandleFunc("/topic/", handleRequest)

w = httptest.NewRecorder()

os.Exit(m.Run())
}