Golang_23: Go语言 go test 测试

原文链接: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: TestAddPASS: 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=1TestDemo/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.NBenchmarkXxx() 函数会被调用多次,每次 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 工具中查询原函数的注释时就有提示:

go_test_1.webp

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

谢TS

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值