原文链接:https://xiets.blog.csdn.net/article/details/130866925
版权声明:原创文章禁止转载
专栏目录:Golang 专栏(总目录)
Go 提供了用于测试代码的 go test
子命令,测试框架的相关接口在 testing
包中,官方文档:https://pkg.go.dev/testing 。
Go 的测试文件以 _test.go
结尾命名(这些文件会被 go build
忽略),go test
命令会自动查找所有 *_test.go
文件,找到文件中的测试函数,并临时生成一个 main() 函数去调用它,然后编译运行并报告结果,最后清空临时生成的文件。
go test
工具在 *_test.go
文件内主要处理三种特殊的函数,分别为以 Test
开头命名 功能测试函数、以 Benchmark
开头命名的 基准测试函数、以 Example
开头命名的 示例函数。
go test
运行这些测试函数时,对不同类型的测试函数做出不同的报告:
- 功能测试函数
TestXxx()
:用于检测一些函数的程序逻辑是否正确,go test 报告测试结果是 成功(PASS
) 还是 失败(FAIL
)。 - 基准测试函数
BenchmarkXxx()
:用于测试一些复杂程序逻辑的性能,go test 报告程序的平均执行时间、内存 的使用情况等。 - 示例函数
ExampleXxx()
:用于展示程序中某个包或函数的用法。
1. 功能测试
所有测试文件必须导入 testing
包,功能测试函数的签名为:
func TestXxx(t *testing.T) {
// ...
}
参数 t
提供了 汇报测试失败 和 记录日志 的一些方法。
另外 基准测试函数 和 示例函数 的签名分别为
BenchmarkXxx(b *testing.B)
和ExampleXxx()
。
1.1 简单示例
测试文件固定以 _test.go
结尾,一般以要测试的 go 源文件的文件名为前缀(非强制)。
测试文件在工程中的结构示例:
project1
└── demo1
│ ├── demo.go
│ └── demo_test.go
└── go.mod
demo1 包下有两个文件,demo.go
为源码文件,demo_test.go
是用于编写测试函数的测试文件。
demo.go
文件中写两个函数:
package demo1
func Add(a, b int) int {
return a + b
}
func Sub(a, b int) int {
return a - b
}
demo_test.go
文件中写功能测试函数:
package demo1_test
import (
"project1/demo1"
"testing"
)
func TestAdd(t *testing.T) {
a, b := 2, 3
expect := 5
// 用测试用例验证函数
retVal := demo1.Add(a, b)
// 如果函数的返回值和预期不一致, 打印错误
if expect != retVal {
t.Errorf("Add(%d, %d) return %d, expect %d", a, b, retVal, expect)
}
}
func TestSub(t *testing.T) {
a, b := 2, 3
expect := -1
retVal := demo1.Sub(a, b)
if expect != retVal {
t.Errorf("Sub(%d, %d) return %d, expect %d", a, b, retVal, expect)
}
}
测试文件的包名为
*_test.go
文件所在包加上_test
后缀。每一个TestXxx()
测试函数都是相互独立的,传给测函数的参数t
也是不同的实例。
进入到源文件目录,运行 go test
测试命令:
project1/demo1$ go test
PASS
ok project1/demo1 0.103s
输出了 PASS 表示通过测试,并报告当前被测试的包路径,测试所花费的时间。
默认情况下,测试通过时不输出详细日志,测试失败时才输出日志,可以加上 -v
参数指定要输出日志:
project1/demo1$ go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestSub
--- PASS: TestSub (0.00s)
PASS
ok project1/demo1 0.111s
把 Add() 函数中的 a + b,改为 a - b,再运行测试测试命令:
project1/demo1$ go test -v
=== RUN TestAdd
demo_test.go:17: Add(2, 3) return -1, expect 5
--- FAIL: TestAdd (0.00s)
=== RUN TestSub
--- PASS: TestSub (0.00s)
FAIL
exit status 1
FAIL project1/demo1 0.379s
可以看出,FAIL: TestAdd
和 PASS: TestSub
。
测试文件可以放在任意包下,为了不把测试文件和源码文件混在一起,也可以单独创建一个包用来存放测试文件。
1.2 递归扫描
go test
命令不指定包参数的情况下,默认以当前目录所在包为参数,即扫描当前目录下的所有 *_test.go
文件(不递归),相当于 go test .
。
如果想要递归扫描工程中的所有 *_test.go
文件,可以在工程根目录下运行 go test ./...
:
project1$ go test ./...
? project1 [no test files]
ok project1/demo1 (cached)
如果包内没有测试文件,会提示 [no test files]
,已经测试过的包在没有改动代码的情况下会直接从缓存中获取结果 (cached)
。可以运行 go clean -testcache
删除测试缓存。
也可以明确指定要测试的包,可以在工程目录下任意路径测试任意的包:go test pkg1 pkg2 ...
project1$ go test project1/demo1
ok project1/demo1 0.102s
还可以直接指定要测试的测试文件:go test test_file1 test_file2 ...
project1$ go test demo1/demo_test.go
ok command-line-arguments 0.104s
go test
命令必须在项目根目录下运行(go.mod 所在目录或其子目录)。
1.3 testing.T
testing.T
是传递给功能测试函数的参数类型,用于管理测试状态和支持格式化的测试日志。
当测试函数返回或调用任何方法 t.FailNow()
、t.Fatal()
、t.Fatalf()
、t.SkipNow()
、t.Skip()
或 t.Skipf()
时,测试结束(这些方法内部调用了进程退出函数或抛出了异常)。
*testing.T
的一些方法(testing.T
继承自 testing.common
):
// 注册一个清理函数, 当测试 (或子测试) 及其所有子测试完成时调用。清理函数将按照后进先出(栈)的顺序调用。
c.Cleanup(f func())
// 截止日期, 根据 -timeout 参数报告超时时间
t.Deadline() (deadline time.Time, ok bool)
// 输出测试日志
c.Log(args ...any)
c.Logf(format string, args ...any)
// 标记为测试失败但继续执行
c.Fail()
// 标记为失败并通过调用 runtime.Goexit() 停止执行
c.FailNow()
// 判断是否已测试失败
c.Failed() bool
// 相当于 Log() + Fail()
c.Error(args ...any)
// 相当于 Logf() + Fail()
c.Errorf(format string, args ...any)
// 相当于 Log() + FailNow()
c.Fatal(args ...any)
// 相当于 Logf() + FailNow()
c.Fatalf(format string, args ...any)
// 将测试标记为已跳过并通过调用 runtime.Goexit() 停止执行。如果已测试失败然后被跳过, 它仍被视为失败。
c.SkipNow()
// 相当于 Log() + SkipNow()
c.Skip(args ...any)
// 相当于 Logf() + SkipNow()
c.Skipf(format string, args ...any)
// 是否已被跳过
c.Skipped() bool
// 将调用函数标记为测试辅助函数。当打印文件和行信息时, 该函数将被跳过。
c.Helper()
// 返回运行 (子) 测试或基准测试的名称
c.Name() string
// 表示此测试将与 (且仅与) 其他并行测试并行运行
t.Parallel()
// 设置测试函数内有效的环境变量, 调用 os.Setenv(key, value) 并在测试后使用 Cleanup() 将环境变量恢复为原始值
t.Setenv(key, value string)
// 返回供测试使用的临时目录。当测试及其所有子测试完成时, Cleanup() 会自动删除该目录。
// 随后每次调用 t.TempDir 都会返回一个唯一的目录; 如果目录创建失败, TempDir() 通过调用 Fatal() 终止测试。
c.TempDir() string
// f() 作为 t 的子测试函数以 name 为名称运行测试。它在单独的 goroutine 中运行 f() 并阻塞直到 f() 返回或调用 t.Parallel() 成为并行测试。
// Run() 报告 f() 是否成功(或至少在调用 t.Parallel() 之前没有失败)。
// 可以从多个 goroutines 同时调用 Run(), 但所有此类调用必须在 t 的外部测试函数返回之前返回。
t.Run(name string, f func(t *testing.T)) bool
1.4 子测试
测试每一个 TestXxx()
测试函数,都要先单独做一些初始化操作,可以利用子测试把多个要执行的测试函数合并到一个测试函数中。
子测试主要使用 t.Run()
方法完成,该方法接收两个参数,第 1 个参数是子测试的名称,第 2 个参数是子测试函数。子测试函数可以任意命名,不需要以 TestXxx() 的形式,但需要和普通测试函数一样接收一个 *testing.T
类型的参数。
1.4.1 子测试示例
子测试示例,测试文件在工程中的结构示例:
project1
└── demo1
│ ├── demo.go
│ └── demo_test.go
└── go.mod
demo1/demo.go
源文件内容:
package demo1
func Add(a, b int) int {
return a + b
}
func Sub(a, b int) int {
return a - b
}
demo1/demo_test.go
测试文件内容:
package demo1_test
import (
"project1/demo1"
"testing"
)
// 子测试函数, 函数名可以任意命名, 但不能以 Test 开头 (如果以 Test 开头就变成普通测试函数了)
func subTest1(t *testing.T) {
a, b := 2, 3
expect := 5
retVal := demo1.Add(a, b)
if expect != retVal {
t.Errorf("Add(%d, %d) return %d, expect %d", a, b, retVal, expect)
} else {
t.Logf("subTest1: PASS, t = %p", t)
}
}
// 子测试函数
func subTest2(t *testing.T) {
a, b := 2, 3
expect := -1
retVal := demo1.Sub(a, b)
if expect != retVal {
t.Errorf("Sub(%d, %d) return %d, expect %d", a, b, retVal, expect)
} else {
t.Logf("subTest2: PASS, t = %p", t)
}
}
// 测试函数, 运行 go test 时将被调用
func TestDemo(t *testing.T) {
t.Logf("TestDemo START, t = %p", t)
// 调用子测试 (返回子测试结果)
result1 := t.Run("A=1", subTest1)
result2 := t.Run("B=2", subTest2)
t.Logf("TestDemo END, result1 = %v, result2 = %v", result1, result2)
}
在项目根目录下运行 go test
,并加上 -v
参数输出详细日志:
project1$ go test -v ./...
? project1 [no test files]
=== RUN TestDemo
demo_test.go:38: TestDemo START, t = 0x1400010a820
=== RUN TestDemo/A=1
demo_test.go:18: subTest1: PASS, t = 0x1400010ab60
=== RUN TestDemo/B=2
demo_test.go:32: subTest2: PASS, t = 0x1400010aea0
=== NAME TestDemo
demo_test.go:44: TestDemo END
--- PASS: TestDemo (0.00s)
--- PASS: TestDemo/A=1 (0.00s)
--- PASS: TestDemo/B=2 (0.00s)
PASS
ok project1/demo1 0.430s
主测试函数的测试名称就是函数名本身,子测试最终的名称为 “父测试名称/Run的第1个参数”,如
TestXxx/RunName
。
从输出可以看出,最终运行了 3 个测试,主测试TestDemo
,两个子测试 TestDemo/A=1
和 TestDemo/B=2
。即使在同一个主测试内,每子测试接收到的参数 t
也是全新的一个实例,也就是子测试之间也相互独立,一个子测试失败不影响其他子测试,但子测试函数失败,父测试函数也会失败。
传给 t.Run()
方法的子测试函数也在单独的 goroutine 中运行,t.Run()
方法同时也会阻塞直到子测试函数 f()
返回,返回值为子测试函数成功失败的 bool 标记。
1.4.2 子测试并发
如果在子测试函数中调用了 t.Parallel()
则当前子函数启动并发测试,并通知父测试函数结束 Run()
方法阻塞,此时父测试函数调用的 Run()
方法的返回值为子测试函数调用 t.Parallel()
之前的状态。
修改 demo1/demo_test.go
测试文件,把 subTest2()
子测试函数修改为如下所示:
// 子测试函数
func subTest2(t *testing.T) {
// 当前子测试函数启动并发, 父测试函数调用的 Run() 方法将结束阻塞, 并返回子测试函数此刻的成功失败标记
t.Parallel()
// 短暂等待, 让父测试函数先结束
time.Sleep(1 * time.Second)
// 标记为测试失败
t.FailNow()
}
运行测试命令:
project1$ go test -v ./...
? project1 [no test files]
=== RUN TestDemo
demo_test.go:35: TestDemo START, t = 0x1400011c820
=== RUN TestDemo/A=1
demo_test.go:19: subTest1: PASS, t = 0x1400011cb60
=== RUN TestDemo/B=2
=== PAUSE TestDemo/B=2
=== NAME TestDemo
demo_test.go:41: TestDemo END, result1 = true, result2 = true
=== CONT TestDemo/B=2
--- FAIL: TestDemo (0.00s)
--- PASS: TestDemo/A=1 (0.00s)
--- FAIL: TestDemo/B=2 (1.00s)
FAIL
FAIL project1/demo1 1.101s
FAIL
从输出可以看出,子测试 subTest2()
函数中启动并发后,父测试 TestDemo()
先执行完,并且 t.Run()
返回值是成功的(因为在子测试调用 t.Parallel()
之前并没有失败),但最终子测试 subTest2()
是失败的。
1.5 测试覆盖率
测试覆盖率是指测试文件对一个待测试包内的代码测试的覆盖比例。Go 的 cover 工具,可用于分析 go test 测试时对包内的代码语句测试了多少。
用上面的的示例工程做覆盖率示例,首选需要确保测试通过:
project1$ go test ./...
? project1 [no test files]
ok project1/demo1 0.387s
再此运行 go test 带上 -coverprofile 参数:
project1$ go test -coverprofile=c.out ./...
? project1 [no test files]
ok project1/demo1 0.119s coverage: 100.0% of statements
从结果可以看出,对 project1/demo1
包内的代码的测试覆盖率是 100%。
测试覆盖率生成的数据文件 c.out
,可以用使用 cover 工具在浏览器打开:
project1$ go tool cover -html=c.out
命令运行后会自动打开浏览器,可查询覆盖率测试详情。
2. 基准测试
基准测试就是在一定的工作负载之下检测程序性能的一种方式。在 *_test.go
测试文件中,函数名以 Benchmark
开头并拥有一个 *testing.B
类型参数的函数就是 go test 的 基准测试函数。
*testing.B
与 *testing.T
一样都是继承自 testing.common
,两者拥有许多相同的方法,比如 日志输出、标记成功失败等方法都是定义在 testing.common
类型中。除此之外 *testing.B
还额外增加了一些与性能检测相关的方法。
基准测试示例,有下面一个工程:
project2
└── demo2
│ ├── demo.go
│ └── demo_test.go
└── go.mod
demo2/demo.go
源码文件:
package demo2
import (
"bytes"
"fmt"
)
// JoinNumsWithAdd 返回 [0, num) 之间的整数用逗号拼接的字符串
func JoinNumsWithAdd(num int) string {
// 每次循环直接使用字符串拼接
s := ""
for i := 0; i < num; i++ {
s += fmt.Sprintf("%d,", i)
}
return s
}
// JoinNumsWithBuf 返回 [0, num) 之间的整数用逗号拼接的字符串
func JoinNumsWithBuf(num int) string {
// 先保存在缓存区, 最后再转换为字符串
buf := bytes.NewBuffer(nil)
for i := 0; i < num; i++ {
buf.WriteString(fmt.Sprintf("%d,", i))
}
return buf.String()
}
demo2/demo_test.go
测试文件:
package demo2_test
import (
"project1/demo2"
"testing"
)
const NUM = 1000
func BenchmarkJoinNumsWithAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
demo2.JoinNumsWithAdd(NUM)
}
}
func BenchmarkJoinNumsWithBuf(b *testing.B) {
for i := 0; i < b.N; i++ {
demo2.JoinNumsWithBuf(NUM)
}
}
demo.go
源码文件中有两个函数,功能相同,都是传入一个 num 参数,返回 [0, num) 之间的整数用逗号拼接的字符串,但实现方式不同,一个直接使用字符串拼接的方式,另一个则是先保存到缓冲区,最后再一次性转换为字符串。
demo_test.go
测试文件中的 BenchmarkXxx()
基准测试函数内使用循环的方式调用被测试函数,循环的次数为 b.N
。BenchmarkXxx()
函数会被调用多次,每次 N 值可能都不一样,我们不需要关系它的具体值,go test 会自动根据每次的测试情况动态调整。基准测试运行时由于不知道被测试函数具体需要的耗时,所以开始的时候 go test 会尝试用较小的 N 值去做检测,然后为了得到稳定的运行时间逐渐加大 N 值。
和 TestXxx()
不一样,go test 默认不会运行任何基准测试函数,需要加上 -bench
参数指定要运行的基准测试函数。它的参数值是匹配 BenchmarkXxx()
函数名称的正则表达式(不是全匹配),其中 -bench=.
表示匹配所有 BenchmarkXxx()
函数。
在工程根目录下运行 go test 基准测试,./...
表示查找目录下(包括子目录)的所有测试文件:
project2$ go test -bench=. ./...
? project2 [no test files]
goos: darwin
goarch: arm64
pkg: project1/demo2
BenchmarkJoinNumsWithAdd-8 4394 235424 ns/op
BenchmarkJoinNumsWithBuf-8 20131 59324 ns/op
PASS
ok project1/demo2 2.963s
基准测试函数名称后面的 -8
表示 GOMAXPROCS
值,这对于并发基准测试很有用。4394 和 20131 表示运行循环的总次数,也就是调用被测试函数的次数,后面的 235424 ns/op 和 59324 ns/op 表示平均每次的耗时。从结果可以看出,使用缓冲区的效率明显比较高。
因为每次字符串拼接都需要重新申请内存,而使用缓冲区申请内测的次数会少很多,因此缓冲区的效率会更高。-benchmem
参数则可以报告每次操作的内存分配统计:
project2$ go test -bench=. -benchmem ./...
? project2 [no test files]
goos: darwin
goarch: arm64
pkg: project1/demo2
BenchmarkJoinNumsWithAdd-8 4551 233250 ns/op 2044245 B/op 2745 allocs/op
BenchmarkJoinNumsWithBuf-8 20462 59277 ns/op 25063 B/op 1752 allocs/op
PASS
ok project1/demo2 2.991s
从结果可以看出,使用缓冲区时平均每次调用被测试函数分配的内存大小,以及每次申请内存的次数都明显比直接拼接字符串要少很多。
3. 示例函数
go test 还会处理 *_test.go
测试文件中的函数名以 Example
开头函数,这种函数称为 示例函数。该函数没有参数,函数内的代码用于演示某个函数的使用。示例函数的函数名以 Example
开头,后面拼接的必须是包内真实存在的一个函数名,表示演示的是该真实函数的示例。go 源码中的许多函数也都有对应的示例函数。
ExampleXxx()
示例函数是 go doc 文档的一部分,提取文档时作为对应函数的示例代码。
示例函数 代码示例,有下面一个工程:
project3
└── demo3
│ ├── demo.go
│ └── demo_test.go
└── go.mod
demo3/demo.go
源文件:
package demo3
import "strings"
// JoinStrings 使用 sep 拼接字符串
func JoinStrings(elems []string, sep string) string {
var b strings.Builder
for _, s := range elems {
b.WriteString(s)
b.WriteString(sep)
}
return b.String()
}
demo3/demo_test.go
测试文件:
package demo3_test
import (
"fmt"
"project1/demo3"
)
func ExampleJoinStrings() {
fmt.Println(demo3.JoinStrings([]string{"Hello", "World"}, ","))
//Output:
//Hello,World,
}
demo.go
文件中有一个函数 JoinStrings()
函数。demo_test.go
测试文件内的 ExampleJoinStrings()
函数演示了 JoinStrings()
函数的用法。测试函数中的注释 //Output:
表示下面的注释是运行此测试函数要输出的正确内容,虽然在注释中,但演示输出的内容必须正确,否则 go test 不通过。
在工程根目录下运行 go test:
project3$ go test -v ./...
? project3 [no test files]
=== RUN ExampleJoinStrings
--- PASS: ExampleJoinStrings (0.00s)
PASS
ok project1/demo3 0.102s
测试通过。
尝试把示例函数中的注释输出改掉:
func ExampleJoinStrings() {
fmt.Println(demo3.JoinStrings([]string{"Hello", "World"}, ","))
//Output:
//Hello-World
}
然后再次运行 go test:
project3$ go test -v ./...
? project3 [no test files]
=== RUN ExampleJoinStrings
--- FAIL: ExampleJoinStrings (0.00s)
got:
Hello,World,
want:
Hello-World
FAIL
FAIL project1/demo3 0.432s
FAIL
可以看出,go test 失败,提示真实输出与注释中说明的输出不一致。
示例中的 Output:
虽然在注释中,但也必须符合调用示例函数时的真实输出。如果升级版本时修改了原函数的逻辑导致输出不一致,go test 时就能发现,然后必须修正示例代码的输出。如此可以确保示例中的代码始终是和最新代码逻辑保持一致的。
ExampleJoinXxx()
示例函数函数可以提取为对应函数的文档说明,例如在 IDE 工具中查询原函数的注释时就有提示: