Skip to content

测试

单元测试

单元测试(unit test)除用来测试逻辑算法是否复合预期外,还承担者监控代码质量的责任。
任何时候都可用简单的命令来验证全部功能,找出未完成任务(验收)和任何因修改而造成的错误。
它与性能测试、代码覆盖率等一起保障了代码总是在可控范围内,这远比形式化的人工检查要有用得多。

单元测试并非要取代人工代码审查(code review),实际上它也无法切入到代码实现层面。
但可通过测试结果为审查提供筛选依据,避免因烦琐导致代码审查沦为形式主义。
单元测试可自动化进行,能持之以恒。
但测试毕竟只是手段,而非目的,所以如何合理安排测试就需要开发人员因地制宜。

可将测试、版本管理工具,以及自动发布(nightly build)整合。
编写脚本将测试失败结果与代码提交日志相匹配,最终生成报告发往指定邮箱。

很多人认为单元测试代码不好写,不知道怎么测试。
如果非技术原因,那么需要考虑结构设计是否合理,因为可测试性也是代码质量的一个体现。

在我看来,写单元测试本身就是对即将要实现的算法做复核预演。
因为无论什么算法都需要输入条件,返回预期结果。
这些,加上平时写在 main 里面的临时代码,本就是一个完整的单元测试用例,无非换个地方存放而已。

testing

工具链和标准库自带单元测试框架,这让测试工作变得相对容易

  • 测试代码须放在当前包以 _test.go 结尾的文件中
  • 测试函数以 Test 为名称前缀
  • 测试命令(go test)忽略以 _. 开头的测试文件
  • 正常编译操作(go build/install)会忽略测试文件

main_test.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "testing"

func add(x, y int) int {
    return x + y
}

func TestAdd(t *testing.T) {
    if add(1, 2) != 3 {
        t.FailNow()
    }
}

输出:

1
2
3
4
5
$ go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      github.com/YangzhenZhao/account-verify  0.463s

标准库 testing 提供了专用类型 T 来控制测试结果和行为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
方法
    说明
    相关

Fail 失败: 继续执行当前测试函数     
FailNow 失败: 立即终止执行当前测试函数 Failed
SkipNow 跳过: 停止执行当前测试花束 Skip,Skipf,Skipped
Log 输出错误信息。仅失败时或 -v 输出 Logf    
Parallel 与有同样设置的测试函数并执行
Error Fail+Log Errorf
Fatal FailNow+Log Fatalf

使用 Parallel 可有效利用多核并行优势,缩短测试时间

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
    "os"
    "testing"
    "time"
)

func TestA(t *testing.T) {
    t.Parallel()
    time.Sleep(time.Second * 2)
}

func TestB(t *testing.T) {
    if os.Args[len(os.Args)-1] == "b" {
        t.Parallel()
    }

    time.Sleep(time.Second * 2)
}

输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
$ go test -v
=== RUN   TestA
=== PAUSE TestA
=== RUN   TestB
--- PASS: TestB (2.00s)
=== CONT  TestA
--- PASS: TestA (2.00s)
PASS
ok      test  4.895s

$ go test -v -args "b"
=== RUN   TestA
=== PAUSE TestA
=== RUN   TestB
=== PAUSE TestB
=== CONT  TestA
=== CONT  TestB
--- PASS: TestB (2.00s)
--- PASS: TestA (2.00s)
PASS
ok      github.com/YangzhenZhao/account-verify  2.724s

从测试总耗时可以看到并行执行的结果只有 2 秒

只有一个测试函数调用 Parallel 方法并没有效果,且 go test 执行参数 parallel 必须大于 1

常用测试参数

参数 说明 示例
-args 命令行参数
-v 输出详细信息
-parallel 并发执行,默认只为 GOMAXPROCS -parallel 2
-run 指定测试函数,正则表达式 -run "Add"
-timeout 全部测试累计时间超时将引发 panic,默认值为 10ms -timeout 1m30s
-count 重复测试次数,默认值为 1

对于测试是否应该和目标放在同一目录,一直有不同看法。
某些人认为应该另建一个专门的包用来存放单元测试,且只测试目标公开接口。
好处是,当目标内部发生变化时,无须同步维护测试代码。
每个人对于测试都有不同理解,就像覆盖率是否要做到 90% 以上,也是见仁见智

table driven

单元测试代码一样要写得简洁优雅,要做到这点并不容易。
好在多数时候,我们可用一种类似数据表的模式来批量输入条件并依次比对结果。

 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 (
    "testing"
)

func add(x, y int) int {
    return x + y
}

func TestAdd(t *testing.T) {
    var tests = []struct {
        x      int
        y      int
        expect int
    }{
        {1, 1, 2},
        {2, 2, 4},
        {3, 2, 5},
    }

    for _, tt := range tests {
        actual := add(tt.x, tt.y)
        if actual != tt.expect {
            t.Errorf("add(%d, %d): expect %d, actual %d", tt.x, tt.y, tt.expect, actual)
        }
    }
}

这种方式将测试数据和测试逻辑分离,更便于维护。
另外,使用 Error 是为了让整个表全部完成测试,以便知道具体是哪组条件出现问题

test main

某些时候,须为测试用例提供初始化和清理操作,但 testing 并没有 setup/teardown 机制。
解决方法是自定义一个名为 TestMain 的函数,go test 会改为执行该函数,而不再是具体的测试用例。

1
2
3
4
5
6
func TestMain(m *testing.M) {
    // setup
    code := m.Run()  // 调用测试用例函数
    // teardown
    os.Exit(code)  // 注意: os.Exit 不会执行 defer
}

M.Run 会调用具体的测试用例,但麻烦的是不能为每个测试文件写一个 TestMain。

1
multiple definitions of TestMain

要实现用例组合套件(suite),须借助 MainStart 自行构建 M 对象。
通过与命令行参数相配合,即可实现不同测试组合。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func TestMain(m *testing.M) {
    match := func(pat, str string) (bool, error) {  // 命令行参数 -run 提供的过滤条件
        return true, nil  // str:InternalTest.Name
    }

    tests := []testing.InternalTest{  // 用例列表,可排序
        {"b", TestB},
        {"a", TestA},
    }

    benchmarks := []testing.InternalBenchmark{}
    examples := []testing.InternalExample{}

    m = testing.MainStart(match, tests, benchmarks, examples)
    os.Exit(m.run())
}

example

例代码最大的用途不是测试,而是导入到 GoDoc 等工具生成的帮助文档中。
它通过比对输出(stdout)结果和内部 output 注释是否一直来判断是否成功

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import (
    "fmt"
)

func add(x, y int) int {
    return x + y
}

func ExampleAdd() {
    fmt.Println(add(1, 2))
    fmt.Println(add(2, 2))

    // Output:
    // 3
    // 4
}

输出:

1
2
3
4
5
$ go test -v          
=== RUN   ExampleAdd
--- PASS: ExampleAdd (0.00s)
PASS
ok  test  0.651s

如果没有 output 注释,该示例函数就不会被执行。
另外,不能使用内置函数 print/println,因为它们输出到 stderr

性能测试

性能测试函数以 Benchmark 为名称前缀,同样保存在 _test.go 文件里

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import "testing"

func add(x, y int) int {
    return x + y
}

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = add(1, 2)
    }
}
1
2
3
4
5
6
7
8
9
$ go test -bench .

goos: darwin
goarch: amd64
pkg: github.com/YangzhenZhao/account-verify
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkAdd-4          1000000000               0.3399 ns/op
PASS
ok      test  1.183s

测试工具默认不会执行性能测试,须使用 bench 参数。
它通过逐步调整 B.N 值,反复执行测试函数,直到能获得准确的测量结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package test_module

import "testing"

func add(x, y int) int {
    return x + y
}

func BenchmarkAdd(b *testing.B) {
    println("B.N = ", b.N)

    for i := 0; i < b.N; i++ {
        _ = add(1, 2)
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ go test -bench .

B.N =  1
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkAdd-16         B.N =  100
B.N =  10000
B.N =  1000000
B.N =  100000000
B.N =  1000000000
1000000000               0.2425 ns/op
PASS
ok      _/Users/yangzhen.zhao/tmp/test_module   2.321s

如果希望仅执行性能测试,那么可以用 run=NONE 忽略所有单元测试用例

默认就以并发方式执行测试,但可用 cpu 参数设定多个并发限制来观察结果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ go test -bench . -cpu 1,2,4,8

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkAdd            1000000000               0.2578 ns/op
BenchmarkAdd-2          1000000000               0.2519 ns/op
BenchmarkAdd-4          1000000000               0.2546 ns/op
BenchmarkAdd-8          1000000000               0.2322 ns/op
PASS
ok      _/Users/yangzhen.zhao/tmp/test_module   1.830s

某些耗时的目标,默认循环次数过少,取平均值不足以准确计量性能。
可用 benchtime 设定最小测试时间来增加循环次数,以便返回更准确的结果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import (
    "testing"
    "time"
)

func sleep() {
    time.Sleep(time.Second)
}
func BenchmarkSleep(b *testing.B) {
    for i := 0; i < b.N; i++ {
        sleep()
    }
}

输出:

1
2
3
4
5
6
7
8
9
$ go test -bench . -benchtime 5s

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkSleep-16              5        1001351177 ns/op
PASS
ok      _/Users/yangzhen.zhao/tmp/test_module   11.836s
➜  test_module 

timer

如果在测试函数中要执行一些额外操作,那么应该临时阻止计数器工作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package test_module

import (
    "testing"
    "time"
)

func add(x, y int) int {
    return x + y
}

func BenchmarkAdd(b *testing.B) {
    time.Sleep(time.Second)
    b.ResetTimer() // 重置
    for i := 0; i < b.N; i++ {
        _ = add(1, 2)

        if i == 1 {
            b.StopTimer() // 暂停
            time.Sleep(time.Second)
            b.StartTimer() // 恢复
        }
    }
}

输出:

1
2
3
4
5
6
7
8
$ go test -bench .

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkAdd-16         1000000000               0.2398 ns/op
PASS
ok      _/Users/yangzhen.zhao/tmp/test_module   11.832s

memory

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package test_module

import (
    "testing"
)

func heap() []byte {
    return make([]byte, 1024*10)
}

func BenchmarkHeap(b *testing.B) {

    for i := 0; i < b.N; i++ {
        _ = heap()
    }
}

输出:

1
2
3
4
5
6
7
8
9
# 禁用内联和优化,以便观察结果
$ go test -bench . -benchmem -gcflags "-N -l"

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkHeap-16          962241              1044 ns/op           10240 B/op          1 allocs/op
PASS
ok      _/Users/yangzhen.zhao/tmp/test_module   1.810s

输出结果包括单次执行堆内存分配总量和次数

也可以将测试函数设置为总是输出内存分配信息,无论使用 benchmem 参数与否

1
2
3
4
5
6
7
func BenchmarkHeap(b *testing.B) {
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = heap()
    }
}

代码覆盖率

test2333/utils.go:

1
2
3
4
5
package test2333

func Add(x, y int) int {
    return x + y
}

test2333/my_test.go:

```go package test2333

import "testing"

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

1
输出:  

$ go test -cover

PASS coverage: 100.0% of statements ok _/Users/yangzhen.zhao/tmp/test2333 1.605s

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
为获取更详细的信息,可指定 cover-mode 和 coverprofile 参数

- set: 是否执行    
- count: 执行次数
- atomic: 执行次数,支持并发模式

```bash
$ go test -cover -covermode count -coverprofile cover.out

PASS
coverage: 100.0% of statements
ok      _/Users/yangzhen.zhao/tmp/test2333      2.013s

$ go tool cover -func=cover.out

/Users/yangzhen.zhao/tmp/test2333/utils.go:3:   Add             100.0%
total:                                          (statements)    100.0%

还可以在浏览器中查看包括具体的执行次数等信息

1
$ go tool cover -html=cover.out

示例

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

import (
    "fmt"
    "testing"
)

func IntMin(a, b int) int {
    if a < b {
        return a
    }
    return b
}

func TestIntMinBasic(t *testing.T) {
    ans := IntMin(2, -2)
    if ans != -2 {
        t.Errorf("IntMin(2, -2) = %d; want -2", ans)
    }
}

func TestIntMinTableDriven(t *testing.T) {
    var tests = []struct {
        a, b int
        want int
    }{
        {0, 1, 0},
        {1, 0, 0},
        {2, -2, -2},
        {0, -1, -1},
        {-1, 0, -1},
    }

    for _, tt := range tests {
        testname := fmt.Sprintf("%d,%d", tt.a, tt.b)
        t.Run(testname, func(t *testing.T) {
            ans := IntMin(tt.a, tt.b)
            if ans != tt.want {
                t.Errorf("got %d, want %d", ans, tt.want)
            }
        })
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ go test -v
== RUN   TestIntMinBasic
--- PASS: TestIntMinBasic (0.00s)
=== RUN   TestIntMinTableDriven
=== RUN   TestIntMinTableDriven/0,1
=== RUN   TestIntMinTableDriven/1,0
=== RUN   TestIntMinTableDriven/2,-2
=== RUN   TestIntMinTableDriven/0,-1
=== RUN   TestIntMinTableDriven/-1,0
--- PASS: TestIntMinTableDriven (0.00s)
    --- PASS: TestIntMinTableDriven/0,1 (0.00s)
    --- PASS: TestIntMinTableDriven/1,0 (0.00s)
    --- PASS: TestIntMinTableDriven/2,-2 (0.00s)
    --- PASS: TestIntMinTableDriven/0,-1 (0.00s)
    --- PASS: TestIntMinTableDriven/-1,0 (0.00s)
PASS
ok      examples/testing    0.023s