Golang - testing包使用

AI 摘要: 介绍了Go语言中的功能测试和性能测试的编写方式,以及注意事项。测试类型支持黑盒和白盒测试。基准测试可以进行性能分析。

如今的软件复杂性,给开发带来了大量的精力,有两个方式可以有效缓解这个问题:软件发布之前的同行评审(业务、产品、技术)、以及软件有效的测试(自动化测试)

Go基于轻量级的测试方式,基于go工具链以及相关的函数进行,同时测试还涉及压力测试和文档示例

1. Golang相关测试基础

1.1. *_test文件

go test扫描以*_test.go结尾的文件(不被作为go build编译的目标),寻求其中的特殊函数,并生成一个临时的main包,让后编译,运行,汇报结果,清空临时文件,该类文件中有3类函数被特殊对待:

  • Test前缀函数:用于功能测试,汇报用例检测程序运行情况;
  • Benchmark前缀函数:用于基准测试(性能测试),汇报平均执行时间;
  • Example前缀函数:用户示例,godoc文档结合,以及playground执行示例

1.2. Test函数

每个测试文件必须导入testing包,其函数签名为:

1
2
3
4
5
6
7
func TestName(t *testing.T)

// 相关信息输出函数
t.Error()
t.Errorf()
t.Logf()
...

1.3. go test命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 运行软件包的测试套件, -v相关明细信息输出 
go test 

// -run 执行指定的测试用例
go test -run "Afn|Bfn" -v

// 测试覆盖情况
go test -v -run TestRandIsPalindrome -covermode=count -coverprofile=c.out
go tool cover -html=c.out

// 运行基准测试,输出CPU相关性能分析
go test -bench=BenchmarkRandPalindrome -cpuprofile=cpu.log .

// 利用pprof查看分析具体的程序性能情况
go tool pprof -text -nodecount=10 ./hw.test cpu.log

2. Golang功能测试

2.1. 测试方式 - 表项测试、随机测试

  • 基于表项测试,模式遵循:got := f(x); got != want
  • 基于随机输入测试策略:
    • 输入是随机的,基于低效但准确的函数做被测试函数或方法验证;
    • 输入是有规律的符合某种模式的输入,导致有规律的随机输出做验证

Tips: 基于随机输入,可以利用到rand包,注意保留seed输出,方便错误时候可以复现!

1
2
3
4
5
6
// 随机函数
seed := time.Now().UinxNano()
rnd := rand.New(rand.NewSource(seed)) // *rand.Rand类型
...
n := rnd.Intn(25) // 生成[0,n)内的整数
c := rune(rnd.Intn(0x1000)) // 随机产生[0, \x10000)的rune字符

2.2. 测试类型 - 白盒、黑盒测试

白盒测试:基于对测试包的内部了解程度来做区分,通过包内部代码的观察和改动以及内部函数调用,白盒测试可能对代码和数据有侵入性。

白盒实施:

  • 可以通过伪实现替代部分产品功能避免数据侵入的副作用(比如信用卡支付、更新产品数据库、邮件发送),需要注意全局变量替换风险,测试完后需要还回来
  • 可以通过数据标记染色后期过滤处理(比如数据打标)

黑盒测试:假设对包的了解仅通过公开的API和文档,对包内部的逻辑规则是不透明的。

2.3. 测试用例代码覆盖率

语句覆盖率:语句覆盖率指部分语句在一次执行中至少执行一次,go中是通过cover工具(被集成到go tes中了)来衡量的。

测试套件覆盖待测试包的比例称为测试的覆盖率,通常我们用语句覆盖率作为简单衡量方式;

Tips:不要一味的追求代码覆盖率,适当的衡量代码错误检测以及测试用例的开发成本!

以下基于命令行中执行代码覆盖率检测,IDE中也可以有类似效果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 相关帮助
$ go tool cove

// 以下测试回文函数的代码覆盖率,进入到指定包内执行命令
$ go test -v -run TestRandIsPalindrome -covermode=count -coverprofile=c.out
=== RUN   TestRandIsPalindrome
--- PASS: TestRandIsPalindrome (0.00s)
    hw_test.go:48: Random seed: 1557913657400098000
    hw_test.go:53: hw: ȡਏ෇෇ਏȡ
    hw_test.go:53: hw: һցƆହʬ཈ޤೆયྡྷྡྷયೆޤ཈ʬହƆցһ
    hw_test.go:53: hw: ࿱ŘԚڋಕ༥௢܏ࣚɨຒɨࣚ܏௢༥ಕڋԚŘ࿱
    hw_test.go:53: hw: ѿഫ/೸శ߷ଵླྀଵ߷శ೸/ഫѿ
    hw_test.go:53: hw: ৛ଏޥौ)Ϸ˽ஒڍʒϊ̓ϊʒڍஒ˽Ϸ)ौޥଏ৛
PASS
coverage: 88.9% of statements
ok      github.com/tkstorm/example/test/hw  0.007s

$ ls
c.out       hw.go       hw_bench.go hw_test.go

// 代码覆盖率查看(html查看)
$ go tool cover -html=c.out

2.4. 外部测试包打破循环依赖

避免包循环依赖的问题,引入一个外部测试包解决

  • net/http 依赖 net/url
  • net/url包内部依赖 net/http 做功能测试,直接使用会引起循环依赖,该问题如何解决?

因为Go规范禁止循环依赖,以上问题,可以通过导入一个外部测试包net/url_test来做集成测试,打破循环依赖:

  • net/url_test => 依赖net/httpnet/url,从而打破循环依赖

2.5. 查看产品代码、包内测试代码、外部测试代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 产品功能文件列表
$ go list -f={{.GoFiles}} fmt
[doc.go format.go print.go scan.go]

// 测试后门,导出内部文件列表
$ go list -f={{.TestGoFiles}} fmt
[export_test.go]

// 包外部测试文件列表
$ go list -f={{.XTestGoFiles}} fmt
[example_test.go fmt_test.go scan_test.go stringer_test.go]

3. 性能测试

基准测试,就是在一定的工作负载之下,检测程序性能的一种方式。通过使用较小的N值检测稳定的运行时间,推断出足够大的N值!

基准测试在程序设计之初,可以很好的体现软件的性能结果,随着时间增长,后续也可以基于此来回顾当初的设计决策!

3.1. Benchmark基准测试

Go中的基准测试,函数命名以Benchmark前缀开头,也是写在_test.go文件之中:

1
2
3
4
5
6
7
8
9
// BenchmarkRandPalindrome压力测试
func BenchmarkRandPalindrome(b *testing.B) {
    seed := time.Now().UnixNano()
    rnd := rand.New(rand.NewSource(seed))

    for i := 0; i < b.N; i++ {
        RandPalindrome(rnd)
    }
}

命令行执行(IDE中也可以执行),压测结果体现有OS操作系统、CPU架构、包信息、基础函数名称,以及压测结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 压测包下所有基准测试函数
$ go test -bench=.
goos: darwin
goarch: amd64
pkg: github.com/tkstorm/example/test/hw
BenchmarkRandPalindrome-4        2000000           631 ns/op
BenchmarkIsPalindrome-4         20000000            85.2 ns/op
PASS
ok      github.com/tkstorm/example/test/hw  3.733s

// 压测包下指定基准测试函数
$ go test -bench=BenchmarkIsPalindrome

3.2. 基准测试结果说明

  • BenchmarkIsPalindrome-4:4代表GOMAXPROCS值,代表CPU个数,对并发基准测试很重要
  • 2000000:执行次数
  • 631 ns/op: 每次函数调用执行的时间,这块并未执行具体这么多次(而是基于较小的N次运行稳定后,推断出来较大的N执行耗时)

基准测试没有和功能测试混合,一方面是职责分清,另一方面是基准测试可以在循环之前做一些初始化的工作!

最快的程序,通常是那些进行内存分配次数最少的程序,可以基于**-benchmem**查看内存分配情况(allocs/op),通过类似的方式,我们可以清晰知道应用内存分配情况!

1
2
3
4
5
6
7
8
$ go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: github.com/tkstorm/example/test/hw
BenchmarkRandPalindrome-4        2000000           628 ns/op          99 B/op          2 allocs/op
BenchmarkIsPalindrome-4         20000000            81.5 ns/op        24 B/op          2 allocs/op
PASS
ok      github.com/tkstorm/example/test/hw  3.630s

3.3. 在基准测试时候,需要考虑的几个问题

  1. 如果一个函数需要1ms处理1000个元素,那么处理1w、100w需要多久?
  2. 都知道临时申请内存慢,那如果提前申请,类似I/O缓存最佳大小是多少?
  3. 对于一个任务,当前的执行程序是否是最优算法?

除此外,我们在针对量级size上面,有如下设计考虑,而不是基于之前的b.N

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// benchmark内部执行IsPalindrome基准测试
func benchmarkIsPalindrome(b *testing.B, size int) {
    for i := 0; i < size; i++ {
        hw.IsPalindrome("aba")
    }
}
func BenchmarkIsPalindrome10(b *testing.B) {
    benchmarkIsPalindrome(b, 10)
}
func BenchmarkIsPalindrome100(b *testing.B) {
    benchmarkIsPalindrome(b, 100)
}
func BenchmarkIsPalindrome1000(b *testing.B) {
    benchmarkIsPalindrome(b, 1000)
}

3.4. 性能剖析 - pprof 1

不要过早优化,但不等同于性能不重要或者放弃优化!!使用性能检测工具,而非直觉!!

性能剖析是通过自动化的手动在程序执行过程中,基于一些性能事件的采样,从多维度来进行性能评测和分析。在获取性能剖析结果后,可以基于pprof工具进行分析。

  1. CPU性能剖析:识别出执行过程中需要CPU最多的函数(每次系统发生时钟中断,记录出相关堆栈内容)!比如Top10
1
2
3
4
5
6
7
// 针对hw当前包,执行基准测试,生成cpu性能剖析文件到cpu.log
$ go test -bench=BenchmarkRandPalindrome -cpuprofile=cpu.log .
$ ls
cpu.log    hw.go      hw.test    hw_test.go

// 基于go tool pprof工具,查询性能剖析中,前10条记录
$ go tool pprof -text -nodecount=10 ./hw.test cpu.log
  1. 内存性能剖析, 识别出负责分配最多内存的语句,对协程内部内存分配调用进行采样。
1
2
3
// 对测试用例,基准测试没有代表性,使用过滤器 -run=none
$ go test -run=none -bench=ConcatStrings100000 -benchmem -cpuprofile=cpu.prof -memprofile=mem.prof ./gomemory/... 
$ go tool pprof -http=:8900 mem.prof

3.5. 阻塞性能剖析

识别出系统调用、锁获取、通道发生和接收阻塞,性能分析在每次上述产生之一阻塞时候,记录一个事件

4. Example函数3个用途

  1. Example函数式通过编译的,可以作为可以执行的文档,胜过文字描述;与godoc结合使用,ExampleFnName将和FnName的文档显示在一起,如果整个包有一个Example,则和包关联
  2. 可以通过go test执行测试,其中的// Output: olleh,就是期望输出
  3. 提供playground的环境;

Example - test测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// stringutil包
package stringutil_test

import (
    "fmt"

    "github.com/golang/example/stringutil"
)

func ExampleReverse() {
    fmt.Println(stringutil.Reverse("hello"))
    // Output: olleh
}

$ go test -v
=== RUN TestReverse
--- PASS: TestReverse (0.00s)
=== RUN: ExampleReverse
--- PASS: ExampleReverse (0.00s)
PASS
ok      github.com/golang/example/stringutil    0.009s

Example - godoc

Godoc示例是编写和维护代码作为文档的好方法。它们还提供了用户可以建立的可编辑,可运行且可运行的示例

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

import (
    "fmt"
    "sort"
)

type Person struct {
    Name string
    Age  int
}

func (p Person) String() string {
    return fmt.Sprintf("%s: %d", p.Name, p.Age)
}

// ByAge implements sort.Interface for []Person based on
// the Age field.
type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }

func Example() {
    people := []Person{
        {"Bob", 31},
        {"John", 42},
        {"Michael", 17},
        {"Jenny", 26},
    }

    fmt.Println(people)
    sort.Sort(ByAge(people))
    fmt.Println(people)

    // Output:
    // [Bob: 31 John: 42 Michael: 17 Jenny: 26]
    // [Michael: 17 Jenny: 26 Bob: 31 John: 42]
}

6. 测试方法论

6.1. 测试用例基本要求

  • 首先确保期望实现的具体新闻,以及针对关键部分的足够的用例场景覆盖(关键覆盖率)
  • 统一、清晰、简洁的测试日志格式输出,记录相关的入参、出参等(适当t.Printf)
  • 出现错误时候,确保现场相关数据得以记录,在后续过程中可以重现(比如seed种子等)
  • 可以在一次测试运行中报告多处错误,而不会出现中断
  • 在测试代码中,不要调用log.Fatalos.Exit,这两个函数会阻止跟踪的过程,应该考虑即使测试用例本身失败,测试驱动程序应该继续工作。
  • 考虑通过伪实现的方式替换部分产品功能(通过共享全局包变量方式)
  • export_test.go,通过将内部的功能暴露给外部测试,提供一个后门
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 消息通知用户
notifyUser = func(user,msg string){..}

// 产品函数
func ProdFn(){
    ...
    notifyUser(user, msg)
}

// 测试函数
func TestFn(t *testing.T){
    saved := notifyUser
    defer func() { notifyUser = saved}() // 测试后还原回来之前的notifyUser

    // 伪实现一个测试用例的notifyUser函数方法,模拟邮件发送
    notifyUser = func(user, msg string) {
        t.Logf(user, msg)
    }
}

6.2. 提升测试程序的健壮性

  • 研发人员:提高程序的健壮性,降低程序的缺陷率(遇到新的合法输入,程序就崩溃)
  • 测试人员:提高测试用例的健壮性,降低测试用例的脆弱性(应用程序升级,测试用例就失败)
    • 解决方案:Care you Care,仅关注需要重点关注的信息,而不用关注所有(比如相关结构中最初始最重要的元素,而不是结构的每一个元素)

7. 总结

概要介绍了Go语言中的功能测试与性能测试的编写方式,以及在测试用例编写过程中的一些需要注意的基本事项。结合go tool工具链可以对Go程序的性能进行剖析!

Golang中的测试(Test、Benchmark、Example),其中功能测试可以通过表驱动、随机方式进行,随机测试需要注意seed种子的保留。测试类型支持黑盒和白盒测试,同时结合Golang的接口替换、染色标记可以做到测试对代码无侵入性。

基准测试,也支持表测试方式,支持不同基准下的应用性能测试结果分析。同时结合pprof的cpu和mem内存分析,可以直观排查和分析应用方面的相关性能。