乐趣区

关于godailylib:Go-每日一库之-testing

简介

testing是 Go 语言规范库自带的测试库。在 Go 语言中编写测试很简略,只须要遵循 Go 测试的 几个约定,与编写失常的 Go 代码没有什么区别。Go 语言中有 3 种类型的测试:单元测试,性能测试,示例测试。上面顺次来介绍。

单元测试

单元测试又称为功能性测试,是为了测试函数、模块等代码的逻辑是否正确。接下来咱们编写一个库,用于将示意罗马数字的字符串和整数互转。罗马数字是由 M/D/C/L/X/V/I 这几个字符依据肯定的规定组合起来示意一个正整数:

  • M=1000,D=500,C=100,L=50,X=10,V=5,I=1;
  • 只能示意 1-3999 范畴内的整数,不能示意 0 和正数,不能示意 4000 及以上的整数,不能示意分数和小数(当然有其余简单的规定来示意这些数字,这里暂不思考);
  • 每个整数只有一种示意形式,个别状况下,连写的字符示意对应整数相加,例如 I=1II=2III=3。然而, 十位字符 I/X/C/M)最多呈现 3 次,所以不能用IIII 示意 4,须要在 V 右边增加一个 I(即IV)来示意,不能用VIIII 示意 9,须要应用 IX 代替。另外 五位字符 V/L/D)不能间断呈现 2 次,所以不能呈现VV,须要用X 代替。
// roman.go
package roman

import (
  "bytes"
  "errors"
  "regexp"
)

type romanNumPair struct {
  Roman string
  Num   int
}

var (romanNumParis []romanNumPair
  romanRegex    *regexp.Regexp
)

var (ErrOutOfRange   = errors.New("out of range")
  ErrInvalidRoman = errors.New("invalid roman")
)

func init() {romanNumParis = []romanNumPair{{"M", 1000},
    {"CM", 900},
    {"D", 500},
    {"CD", 400},
    {"C", 100},
    {"XC", 90},
    {"L", 50},
    {"XL", 40},
    {"X", 10},
    {"IX", 9},
    {"V", 5},
    {"IV", 4},
    {"I", 1},
  }

  romanRegex = regexp.MustCompile(`^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$`)
}

func ToRoman(n int) (string, error) {
  if n <= 0 || n >= 4000 {return "", ErrOutOfRange}
  var buf bytes.Buffer
  for _, pair := range romanNumParis {
    for n > pair.Num {buf.WriteString(pair.Roman)
      n -= pair.Num
    }
  }

  return buf.String(), nil}

func FromRoman(roman string) (int, error) {if !romanRegex.MatchString(roman) {return 0, ErrInvalidRoman}

  var result int
  var index int
  for _, pair := range romanNumParis {for roman[index:index+len(pair.Roman)] == pair.Roman {
      result += pair.Num
      index += len(pair.Roman)
    }
  }

  return result, nil
}

在 Go 中编写测试很简略,只须要在待测试性能所在文件的同级目录中创立一个以 _test.go 结尾的文件。在该文件中,咱们能够编写一个个测试函数。测试函数名必须是 TestXxxx 这个模式,而且 Xxxx 必须以大写字母结尾,另外函数带有一个 *testing.T 类型的参数:

// roman_test.go
package roman

import ("testing")

func TestToRoman(t *testing.T) {_, err1 := ToRoman(0)
  if err1 != ErrOutOfRange {t.Errorf("ToRoman(0) expect error:%v got:%v", ErrOutOfRange, err1)
  }

  roman2, err2 := ToRoman(1)
  if err2 != nil {t.Errorf("ToRoman(1) expect nil error, got:%v", err2)
  }
  if roman2 != "I" {t.Errorf("ToRoman(1) expect:%s got:%s", "I", roman2)
  }
}

在测试函数中编写的代码与失常的代码没有什么不同,调用相应的函数,返回后果,判断后果与预期是否统一,如果不统一则调用 testing.TErrorf()输入错误信息。运行测试时,这些错误信息会被收集起来,运行完结后对立输入。

测试编写实现之后,应用 go test 命令运行测试,输入后果:

$ go test
--- FAIL: TestToRoman (0.00s)
    roman_test.go:18: ToRoman(1) expect:I got:
FAIL
exit status 1
FAIL    github.com/darjun/go-daily-lib/testing  0.172s

我成心将 ToRoman() 函数中写错了一行代码,n > pair.Num> 应该为>=,单元测试胜利找出了谬误。批改之后从新运行测试:

$ go test
PASS
ok      github.com/darjun/go-daily-lib/testing  0.178s

这次测试都通过了!

咱们还能够给 go test 命令传入 -v 选项,输入具体的测试信息:

$ go test -v
=== RUN   TestToRoman
--- PASS: TestToRoman (0.00s)
PASS
ok      github.com/darjun/go-daily-lib/testing  0.174s

在运行每个测试函数前,都输入一行 === RUN,运行完结之后输入--- PASS--- FAIL信息。

表格驱动测试

在下面的例子中,咱们实际上只测试了两种状况,0 和 1。依照这种形式将每种状况都写进去就太繁琐了,Go 中风行应用 表格 的形式将各个测试数据和后果列举进去:

func TestToRoman(t *testing.T) {testCases := []struct {
    num    int
    expect string
    err    error
  }{{0, "", ErrOutOfRange},
    {1, "I", nil},
    {2, "II", nil},
    {3, "III", nil},
    {4, "IV", nil},
    {5, "V", nil},
    {6, "VI", nil},
    {7, "VII", nil},
    {8, "VIII", nil},
    {9, "IX", nil},
    {10, "X", nil},
    {50, "L", nil},
    {100, "C", nil},
    {500, "D", nil},
    {1000, "M", nil},
    {31, "XXXI", nil},
    {148, "CXLVIII", nil},
    {294, "CCXCIV", nil},
    {312, "CCCXII", nil},
    {421, "CDXXI", nil},
    {528, "DXXVIII", nil},
    {621, "DCXXI", nil},
    {782, "DCCLXXXII", nil},
    {870, "DCCCLXX", nil},
    {941, "CMXLI", nil},
    {1043, "MXLIII", nil},
    {1110, "MCX", nil},
    {1226, "MCCXXVI", nil},
    {1301, "MCCCI", nil},
    {1485, "MCDLXXXV", nil},
    {1509, "MDIX", nil},
    {1607, "MDCVII", nil},
    {1754, "MDCCLIV", nil},
    {1832, "MDCCCXXXII", nil},
    {1993, "MCMXCIII", nil},
    {2074, "MMLXXIV", nil},
    {2152, "MMCLII", nil},
    {2212, "MMCCXII", nil},
    {2343, "MMCCCXLIII", nil},
    {2499, "MMCDXCIX", nil},
    {2574, "MMDLXXIV", nil},
    {2646, "MMDCXLVI", nil},
    {2723, "MMDCCXXIII", nil},
    {2892, "MMDCCCXCII", nil},
    {2975, "MMCMLXXV", nil},
    {3051, "MMMLI", nil},
    {3185, "MMMCLXXXV", nil},
    {3250, "MMMCCL", nil},
    {3313, "MMMCCCXIII", nil},
    {3408, "MMMCDVIII", nil},
    {3501, "MMMDI", nil},
    {3610, "MMMDCX", nil},
    {3743, "MMMDCCXLIII", nil},
    {3844, "MMMDCCCXLIV", nil},
    {3888, "MMMDCCCLXXXVIII", nil},
    {3940, "MMMCMXL", nil},
    {3999, "MMMCMXCIX", nil},
    {4000, "", ErrOutOfRange},
  }

  for _, testCase := range testCases {got, err := ToRoman(testCase.num)
    if got != testCase.expect {t.Errorf("ToRoman(%d) expect:%s got:%s", testCase.num, testCase.expect, got)
    }

    if err != testCase.err {t.Errorf("ToRoman(%d) expect error:%v got:%v", testCase.num, testCase.err, err)
    }
  }
}

下面将要测试的每种状况列举进去,而后针对每个整数调用 ToRoman() 函数,比拟返回的罗马数字字符串和谬误值是否与预期的相符。后续要增加新的测试用例也很不便。

分组和并行

有时候对同一个函数有不同维度的测试,将这些组合在一起有利于保护。例如上面对 ToRoman() 函数的测试能够分为非法值,单个罗马字符和一般 3 种状况。

为了分组,我对代码做了肯定水平的重构,首先形象一个 toRomanCase 构造:

type toRomanCase struct {
  num    int
  expect string
  err    error
}

将所有的测试数据划分到 3 个组中:

var (toRomanInvalidCases []toRomanCase
  toRomanSingleCases  []toRomanCase
  toRomanNormalCases  []toRomanCase)

func init() {toRomanInvalidCases = []toRomanCase{{0, "", roman.ErrOutOfRange},
    {4000, "", roman.ErrOutOfRange},
  }

  toRomanSingleCases = []toRomanCase{{1, "I", nil},
    {5, "V", nil},
    // ...
  }

  toRomanNormalCases = []toRomanCase{{2, "II", nil},
    {3, "III", nil},
    // ...
  }
}

而后为了防止代码反复,形象一个运行多个 toRomanCase 的函数:

func testToRomanCases(cases []toRomanCase, t *testing.T) {
  for _, testCase := range cases {got, err := roman.ToRoman(testCase.num)
    if got != testCase.expect {t.Errorf("ToRoman(%d) expect:%s got:%s", testCase.num, testCase.expect, got)
    }

    if err != testCase.err {t.Errorf("ToRoman(%d) expect error:%v got:%v", testCase.num, testCase.err, err)
    }
  }
}

为每个分组定义一个测试函数:

func testToRomanInvalid(t *testing.T) {testToRomanCases(toRomanInvalidCases, t)
}

func testToRomanSingle(t *testing.T) {testToRomanCases(toRomanSingleCases, t)
}

func testToRomanNormal(t *testing.T) {testToRomanCases(toRomanNormalCases, t)
}

在原来的测试函数中,调用 t.Run() 运行不同分组的测试函数,t.Run()第一个参数为子测试名,第二个参数为子测试函数:

func TestToRoman(t *testing.T) {t.Run("Invalid", testToRomanInvalid)
  t.Run("Single", testToRomanSingle)
  t.Run("Normal", testToRomanNormal)
}

运行:

$ go test -v
=== RUN   TestToRoman
=== RUN   TestToRoman/Invalid
=== RUN   TestToRoman/Single
=== RUN   TestToRoman/Normal
--- PASS: TestToRoman (0.00s)
    --- PASS: TestToRoman/Invalid (0.00s)
    --- PASS: TestToRoman/Single (0.00s)
    --- PASS: TestToRoman/Normal (0.00s)
PASS
ok      github.com/darjun/go-daily-lib/testing  0.188s

能够看到,顺次运行 3 个子测试,子测试名是父测试名和 t.Run() 指定的名字组合而成的,如TestToRoman/Invalid

默认状况下,这些测试都是顺次程序执行的。如果各个测试之间没有分割,咱们能够让他们并行以放慢测试速度。办法也很简略,在 testToRomanInvalid/testToRomanSingle/testToRomanNormal 这 3 个函数开始处调用 t.Parallel(),因为这 3 个函数间接调用了testToRomanCases,也能够只在testToRomanCases 函数结尾出增加:

func testToRomanCases(cases []toRomanCase, t *testing.T) {t.Parallel()
  // ...
}

运行:

$ go test -v
...
--- PASS: TestToRoman (0.00s)
    --- PASS: TestToRoman/Invalid (0.00s)
    --- PASS: TestToRoman/Normal (0.00s)
    --- PASS: TestToRoman/Single (0.00s)
PASS
ok      github.com/darjun/go-daily-lib/testing  0.182s

咱们发现测试实现的程序并不是咱们指定的程序。

另外,这个示例中我将 roman_test.go 文件移到了 roman_test 包中,所以须要 import "github.com/darjun/go-daily-lib/testing/roman"。这种形式在测试包有循环依赖的状况下十分有用,例如规范库中net/http 依赖 net/urlurl 的测试函数依赖 net/http,如果把测试放在net/url 包中,那么就会导致循环依赖 url_test(net/url)->net/http->net/url。这时能够将url_test 放在一个独立的包中。

主测试函数

有一种非凡的测试函数,函数名为 TestMain(),承受一个*testing.M 类型的参数。这个函数个别用于在运行 所有测试 前执行一些初始化逻辑(如创立数据库链接),或 所有测试 都运行完结之后执行一些清理逻辑(开释数据库链接)。如果测试文件中定义了这个函数,则 go test 命令会间接运行这个函数,否者 go test 会创立一个默认的 TestMain() 函数。这个函数的默认行为就是运行文件中定义的测试。咱们自定义 TestMain() 函数时,也须要手动调用 m.Run() 办法运行测试函数,否则测试函数不会运行 。默认的TestMain() 相似上面代码:

func TestMain(m *testing.M) {os.Exit(m.Run())
}

上面自定义一个 TestMain() 函数,打印 go test 反对的选项:

func TestMain(m *testing.M) {flag.Parse()
  flag.VisitAll(func(f *flag.Flag) {fmt.Printf("name:%s usage:%s value:%v\n", f.Name, f.Usage, f.Value)
  })
  os.Exit(m.Run())
}

运行:

$ go test -v
name:test.bench usage:run only benchmarks matching `regexp` value:
name:test.benchmem usage:print memory allocations for benchmarks value:false
name:test.benchtime usage:run each benchmark for duration `d` value:1s
name:test.blockprofile usage:write a goroutine blocking profile to `file` value:
name:test.blockprofilerate usage:set blocking profile `rate` (see runtime.SetBlockProfileRate) value:1
name:test.count usage:run tests and benchmarks `n` times value:1
name:test.coverprofile usage:write a coverage profile to `file` value:
name:test.cpu usage:comma-separated `list` of cpu counts to run each test with value:
name:test.cpuprofile usage:write a cpu profile to `file` value:
name:test.failfast usage:do not start new tests after the first test failure value:false
name:test.list usage:list tests, examples, and benchmarks matching `regexp` then exit value:
name:test.memprofile usage:write an allocation profile to `file` value:
name:test.memprofilerate usage:set memory allocation profiling `rate` (see runtime.MemProfileRate) value:0
name:test.mutexprofile usage:write a mutex contention profile to the named file after execution value:
name:test.mutexprofilefraction usage:if >= 0, calls runtime.SetMutexProfileFraction() value:1
name:test.outputdir usage:write profiles to `dir` value:
name:test.paniconexit0 usage:panic on call to os.Exit(0) value:true
name:test.parallel usage:run at most `n` tests in parallel value:8
name:test.run usage:run only tests and examples matching `regexp` value:
name:test.short usage:run smaller test suite to save time value:false
name:test.testlogfile usage:write test action log to `file` (for use only by cmd/go) value:
name:test.timeout usage:panic test binary after duration `d` (default 0, timeout disabled) value:10m0s
name:test.trace usage:write an execution trace to `file` value:
name:test.v usage:verbose: print additional output value:tru

这些选项也能够通过 go help testflag 查看。

其余

另一个函数 FromRoman() 我没有写任何测试,就交给大家了😀

性能测试

性能测试是为了对函数的运行性能进行评测。性能测试也必须在 _test.go 文件中编写,且函数名必须是 BenchmarkXxxx 结尾。性能测试函数承受一个 *testing.B 的参数。上面咱们编写 3 个计算第 n 个斐波那契数的函数。

第一种形式:递归

func Fib1(n int) int {
  if n <= 1 {return n}
  
  return Fib1(n-1) + Fib1(n-2)
}

第二种形式:备忘录

func fibHelper(n int, m map[int]int) int {
  if n <= 1 {return n}

  if v, ok := m[n]; ok {return v}
  
  v := fibHelper(n-2, m) + fibHelper(n-1, m)
  m[n] = v
  return v
}

func Fib2(n int) int {m := make(map[int]int)
  return fibHelper(n, m)
}

第三种形式:迭代

func Fib3(n int) int {
  if n <= 1 {return n}
  
  f1, f2 := 0, 1
  for i := 2; i <= n; i++ {f1, f2 = f2, f1+f2}
  
  return f2
}

上面咱们来测试这 3 个函数的执行效率:

// fib_test.go
func BenchmarkFib1(b *testing.B) {
  for i := 0; i < b.N; i++ {Fib1(20)
  }
}

func BenchmarkFib2(b *testing.B) {
  for i := 0; i < b.N; i++ {Fib2(20)
  }
}

func BenchmarkFib3(b *testing.B) {
  for i := 0; i < b.N; i++ {Fib3(20)
  }
}

须要特地留神的是 Ngo test 会始终调整这个数值,直到测试工夫能得出牢靠的性能数据为止。运行:

$ go test -bench=.
goos: windows
goarch: amd64
pkg: github.com/darjun/go-daily-lib/testing/fib
cpu: Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz
BenchmarkFib1-8            31110             39144 ns/op
BenchmarkFib2-8           582637              3127 ns/op
BenchmarkFib3-8         191600582            5.588 ns/op
PASS
ok      github.com/darjun/go-daily-lib/testing/fib      5.225s

性能测试默认不会执行,须要通过 -bench=. 指定运行。-bench选项的值是一个简略的模式,.示意匹配所有的,Fib示意运行名字中有 Fib 的。

下面的测试后果示意 Fib1 在指定工夫内执行了 31110 次,均匀每次 39144 ns,Fib2在指定工夫内运行了 582637 次,均匀每次耗时 3127 ns,Fib3在指定工夫内运行了 191600582 次,均匀每次耗时 5.588 ns。

其余选项

有一些选项能够管制性能测试的执行。

-benchtime:设置每个测试的运行工夫。

$ go test -bench=. -benchtime=30s

运行了更长的工夫:

$ go test -bench=. -benchtime=30s
goos: windows
goarch: amd64
pkg: github.com/darjun/go-daily-lib/testing/fib
cpu: Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz
BenchmarkFib1-8           956464             38756 ns/op
BenchmarkFib2-8         17862495              2306 ns/op
BenchmarkFib3-8       1000000000             5.591 ns/op
PASS
ok      github.com/darjun/go-daily-lib/testing/fib      113.498s

-benchmem:输入性能测试函数的内存分配情况。

-memprofile file:将内存调配数据写入文件。

-cpuprofile file:将 CPU 采样数据写入文件,方便使用 go tool pprof 工具剖析,详见我的另一篇文章《你不晓得的 Go 之 pprof》

运行:

$ go test -bench=. -benchtime=10s -cpuprofile=./cpu.prof -memprofile=./mem.prof
goos: windows
goarch: amd64
pkg: github.com/darjun/fib
BenchmarkFib1-16          356006             33423 ns/op
BenchmarkFib2-16         8958194              1340 ns/op
BenchmarkFib3-16        1000000000               6.60 ns/op
PASS
ok      github.com/darjun/fib   33.321s

同时生成了 CPU 采样数据和内存调配数据,通过 go tool pprof 剖析:

$ go tool pprof ./cpu.prof
Type: cpu
Time: Aug 4, 2021 at 10:21am (CST)
Duration: 32.48s, Total samples = 36.64s (112.81%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top10
Showing nodes accounting for 29640ms, 80.90% of 36640ms total
Dropped 153 nodes (cum <= 183.20ms)
Showing top 10 nodes out of 74
      flat  flat%   sum%        cum   cum%
   11610ms 31.69% 31.69%    11620ms 31.71%  github.com/darjun/fib.Fib1
    6490ms 17.71% 49.40%     6680ms 18.23%  github.com/darjun/fib.Fib3
    2550ms  6.96% 56.36%     8740ms 23.85%  runtime.mapassign_fast64
    2050ms  5.59% 61.95%     2060ms  5.62%  runtime.stdcall2
    1620ms  4.42% 66.38%     2140ms  5.84%  runtime.mapaccess2_fast64
    1480ms  4.04% 70.41%    12350ms 33.71%  github.com/darjun/fib.fibHelper
    1480ms  4.04% 74.45%     2960ms  8.08%  runtime.evacuate_fast64
    1050ms  2.87% 77.32%     1050ms  2.87%  runtime.memhash64
     760ms  2.07% 79.39%      760ms  2.07%  runtime.stdcall7
     550ms  1.50% 80.90%     7230ms 19.73%  github.com/darjun/fib.BenchmarkFib3
(pprof)

内存:

$ go tool pprof ./mem.prof
Type: alloc_space
Time: Aug 4, 2021 at 10:30am (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top10
Showing nodes accounting for 8.69GB, 100% of 8.69GB total
Dropped 12 nodes (cum <= 0.04GB)
      flat  flat%   sum%        cum   cum%
    8.69GB   100%   100%     8.69GB   100%  github.com/darjun/fib.fibHelper
         0     0%   100%     8.69GB   100%  github.com/darjun/fib.BenchmarkFib2
         0     0%   100%     8.69GB   100%  github.com/darjun/fib.Fib2 (inline)
         0     0%   100%     8.69GB   100%  testing.(*B).launch
         0     0%   100%     8.69GB   100%  testing.(*B).runN
(pprof)

示例测试

示例测试用于演示模块或函数的应用。同样地,示例测试也在文件 _test.go 中编写,并且示例测试函数名必须是 ExampleXxx 的模式。在 Example* 函数中编写代码,而后在正文中编写冀望的输入,go test会运行该函数,而后将理论输入与冀望的做比拟。上面摘取自 Go 源码 net/url/example_test.go 文件中的代码演示了 url.Values 的用法:

func ExampleValuesGet() {v := url.Values{}
  v.Set("name", "Ava")
  v.Add("friend", "Jess")
  v.Add("friend", "Sarah")
  v.Add("friend", "Zoe")
  fmt.Println(v.Get("name"))
  fmt.Println(v.Get("friend"))
  fmt.Println(v["friend"])
  // Output:
  // Ava
  // Jess
  // [Jess Sarah Zoe]
}

正文中 Output: 后是冀望的输入后果,go test会运行这些函数并与冀望的后果做比拟,比拟会疏忽空格。

有时候咱们输入的程序是不确定的,这时就须要应用 Unordered Output。咱们晓得url.Values 底层类型为map[string][]string,所以能够遍历输入所有的键值,然而输入程序不确定:

func ExampleValuesAll() {v := url.Values{}
  v.Set("name", "Ava")
  v.Add("friend", "Jess")
  v.Add("friend", "Sarah")
  v.Add("friend", "Zoe")
  for key, values := range v {fmt.Println(key, values)
  }
  // Unordered Output:
  // name [Ava]
  // friend [Jess Sarah Zoe]
}

运行:

$ go test -v
$ go test -v
=== RUN   ExampleValuesGet
--- PASS: ExampleValuesGet (0.00s)
=== RUN   ExampleValuesAll
--- PASS: ExampleValuesAll (0.00s)
PASS
ok      github.com/darjun/url   0.172s

没有正文,或正文中无 Output/Unordered Output 的函数会被疏忽。

总结

本文介绍了 Go 中的 3 种测试:单元测试,性能测试和示例测试。为了让程序更牢靠,让当前的重构更平安、更释怀,单元测试必不可少。排查程序中的性能问题,性能测试能派上大用场。示例测试次要是为了演示如何应用某个性能。

大家如果发现好玩、好用的 Go 语言库,欢送到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. testing 官网文档: https://golang.google.cn/pkg/testing/
  2. Go 每日一库 GitHub:https://github.com/darjun/go-daily-lib

我的博客:https://darjun.github.io

欢送关注我的微信公众号【GoUpUp】,独特学习,一起提高~

退出移动版