提示:本节课最终代码为:feature/s23

在实际开发中,不仅要开发功能,更重要的是确保这些功能稳定可靠,并且拥有一个不错的性能,要确保这些,就要对代码进行测试。测试分为很多种,例如:功能测试、性能测试、集成测试、端到端测试、单元测试等。

对于开发者,需要执行的测试种类一般是单元测试和性能测试。除此之外,Go 还提供了其他类型的测试,例如:模糊测试、示例测试。本节课,我就来详细介绍下开发者可以执行的测试种类,以及如何编写和执行测试用例。

Go 单元测试现状

这里,想跟大家分享下,在实际项目开发中,开发人员编写并执行单元测试的现状。

其实,即使在像腾讯、阿里这样的大厂,也并没有多少开发人员能够在开发过程中,编写高质量、高覆盖率的单元测试用例。并不是说编写单元测试用例不重要,而是,在项目开发过程中,你可能经常需要忙于追赶项目进度,而没时间去编写单元测试用例。

在实际开发中,开发者一般不太习惯随时编写单元测试用例,并不是说编写单元测试用例不重要,而是因为很多时候,实现功能需求优先级更高。这里分享下我对编写单元测试用例的一点思考:

  • 编写单元测试用例很重要,但单元测试用例并不一定需要边开发边编写。很多时候,我们需要在功能开发进度和编写单元测试用例之间进行权衡;

  • 对于开发过程中,需要编写代码测试某块功能的时候,不妨顺便将测试代码变成单元测试用例;

  • 单元测试用例对后期的代码维护很重要,在项目上线后,如果有时间,建议补全单元测试用例。

Go 语言如何编写测试用例?

Go 语言有自带的测试框架 testing,可以用来实现单元测试和性能测试,通过 go test 命令来执行单元测试和性能测试。

go test 执行测试用例时,是以 Go 包为单位进行测试的。执行时需要指定包名,比如:go test 包名,如果没有指定包名,默认会选择执行命令时所在的包。go test 在执行时会遍历以 _test.go 结尾的源码文件,执行其中以 TestBenchmarkExampleFuzz 开头的测试函数。其中源码文件需要满足以下规范:

  • 文件名必须是 _test.go 结尾,跟源文件在同一个包;
  • 测试用例函数必须以 TestBenchmarkExampleFuzz 开头;
  • 执行测试用例时的顺序,会按照源码中的顺序依次执行;
  • 单元测试函数 TestXxx() 的参数是 testing.T,可以使用该类型来记录错误或测试状态;
  • 性能测试函数 BenchmarkXxx() 的参数是 testing.B,函数内以 b.N 作为循环次数,其中 N 会动态变化;
  • 示例函数 ExampleXxx() 没有参数,执行完会将输出与注释 // Output: 进行对比;
  • 测试函数原型:func TestXxx(t *testing.T)Xxx 部分为任意字母数字组合,首字母大写,例如: TestgenShortId 是错误的函数名,TestGenShortId 是正确的函数名;
  • 通过调用 testing.TErrorErrorfFailNowFatalFatalIf 方法来说明测试不通过,通过调用 LogLogf 方法来记录测试信息:
  1. t.Log t.Logf # 正常信息
  2. t.Error t.Errorf # 测试失败信息
  3. t.Fatal t.Fatalf # 致命错误,测试程序退出的信息
  4. t.Fail # 当前测试标记为失败
  5. t.Failed # 查看失败标记
  6. t.FailNow # 标记失败,并终止当前测试函数的执行,需要注意的是,我们只能在运行测试函数的 Goroutine 中调用 t.FailNow 方法,而不能在我们在测试代码创建出的 Goroutine 中调用它
  7. t.Skip # 调用 t.Skip 方法相当于先后对 t.Log 和 t.SkipNow 方法进行调用,而调用 t.Skipf 方法则相当于先后对 t.Logf 和 t.SkipNow 方法进行调用。方法 t.Skipped 的结果值会告知我们当前的测试是否已被忽略
  8. t.Parallel # 标记为可并行运算

Go 语言测试种类及用例编写方法

上面,我介绍了 Go 语言层面对测试用例的支持,这里我就简单介绍下常见的测试用例编写及执行方法。

在实际项目开发中,我们编写最多的是单元测试用例,接着是性能测试用例,在某些场景还可能会需要编写模糊测试用例。

单元测试

pkg/util/id/ 目录下创建文件 id_test.go,内容为:

  1. package id
  2. import (
  3. "testing"
  4. "github.com/stretchr/testify/assert"
  5. )
  6. func TestGenShortID(t *testing.T) {
  7. shortID := GenShortID()
  8. assert.NotEqual(t, "", shortID)
  9. assert.Equal(t, 6, len(shortID))
  10. }

你可以通过运行 go test 命令来执行测试用例。go test 通过不同的参数,来支持不同的测试效果,常用的 go test 命令如下:

  • 执行默认的测试用例

pkg/util/id/ 目录下执行命令 go test

  1. $ go test
  2. PASS
  3. ok github.com/marmotedu/miniblog/pkg/util/id 0.003s

根据 go test 的输出可以知道 TestGenShortID 用例测试通过。

  • 查看更详细的执行信息

要查看更详细的执行信息可以执行 go test -v

  1. $ go test -v
  2. === RUN TestGenShortID
  3. --- PASS: TestGenShortID (0.00s)
  4. PASS
  5. ok github.com/marmotedu/miniblog/pkg/util/id 0.003s
  • 执行测试 N

如果要执行测试 N 次可以使用 -count N

  1. $ go test -v -count 2
  2. === RUN TestGenShortID
  3. --- PASS: TestGenShortID (0.00s)
  4. === RUN TestGenShortID
  5. --- PASS: TestGenShortID (0.00s)
  6. PASS
  7. ok github.com/marmotedu/miniblog/pkg/util/id 0.003s
  • 只运行指定的单测用例

此外,你还可以只运行指定的单测用例:

  1. $ go test -run TestGenShortID -v
  2. === RUN TestGenShortID
  3. --- PASS: TestGenShortID (0.00s)
  4. PASS
  5. ok github.com/marmotedu/miniblog/pkg/util/id 0.003s

-run 参数支持正则表达式。

性能测试

在企业应用开发中,也需要你学会编写和运行性能测试用例。

编写性能测试用例

pkg/util/id/id_test.go 测试文件中,新增两个性能测试函数:BenchmarkGenShortIDBenchmarkGenShortIDTimeConsuming

  1. func BenchmarkGenShortID(b *testing.B) {
  2. for i := 0; i < b.N; i++ {
  3. GenShortID()
  4. }
  5. }
  6. func BenchmarkGenShortIDTimeConsuming(b *testing.B) {
  7. b.StopTimer() //调用该函数停止压力测试的时间计数
  8. shortId := GenShortID()
  9. if shortId == "" {
  10. b.Error("Failed to generate short id")
  11. }
  12. b.StartTimer() //重新开始时间
  13. for i := 0; i < b.N; i++ {
  14. GenShortID()
  15. }
  16. }

代码说明:

  • 性能测试函数名必须以 Benchmark 开头,如 BenchmarkXxxBenchmark_xxx

  • go test 默认不会执行性能测试函数,需要通过指定参数 -test.bench 来运行性能测试函数,-test.bench 后跟正则表达式,如 go test -test.bench=".*" 表示执行所有的性能测试函数;

  • 在性能测试中,需要在循环体中指定 testing.B.N 来循环执行性能测试代码。

运行性能测试用例

pkg/util/id/ 目录下执行命令 go test -test.bench=".*"

  1. $ go test -test.bench= ".*"
  2. goos: linux
  3. goarch: amd64
  4. pkg: github.com/marmotedu/miniblog/pkg/util/id
  5. cpu: Intel(R) Xeon(R) Platinum 8255C CPU @ 2.50GHz
  6. BenchmarkGenShortID-16 841854 1367 ns/op
  7. BenchmarkGenShortIDTimeConsuming-16 880039 1349 ns/op
  8. PASS
  9. ok github.com/marmotedu/miniblog/pkg/util/id 2.376s
  • 上面的结果显示,我们没有执行任何 TestXXX 的单元测试函数,只执行了性能测试函数;

  • 第一条显示了 BenchmarkGenShortID 执行了 841854 次,每次的执行平均时间是 1367 纳秒;

  • 第二条显示了 BenchmarkGenShortIDTimeConsuming 执行了 880039,每次的平均执行时间是 1349 纳秒;

  • 最后一条显示总执行时间。

BenchmarkGenShortIdTimeConsumingBenchmarkGenShortID 多了两个调用 b.StopTimer()b.StartTimer()

  • b.StopTimer():调用该函数停止性能测试的时间计数;

  • b.StartTimer():重新开始时间。

b.StopTimer()b.StartTimer() 之间可以做一些准备工作,这样这些时间不影响我们测试函数本身的性能。

查看性能并生成函数调用图

执行以下命令,保存性能测试指标:

  1. $ go test -bench= ".*" -cpuprofile=cpu.profile
  2. goos: linux
  3. goarch: amd64
  4. pkg: github.com/marmotedu/miniblog/pkg/util/id
  5. cpu: Intel(R) Xeon(R) Platinum 8255C CPU @ 2.50GHz
  6. BenchmarkGenShortID-16 909586 1319 ns/op
  7. BenchmarkGenShortIDTimeConsuming-16 916029 1356 ns/op
  8. PASS
  9. ok github.com/marmotedu/miniblog/pkg/util/id 2.583s

上述命令会在当前目录下生成 cpu.profileid.test 文件。

之后,我们可以执行 go tool pprof id.test cpu.profile 查看性能(进入交互界面后执行 top 指令):

  1. $ go tool pprof id.test cpu.profile
  2. File: id.test
  3. Type: cpu
  4. Time: Jan 11, 2023 at 4:18pm (CST)
  5. Duration: 2.58s, Total samples = 2.54s (98.56%)
  6. Entering interactive mode (type "help" for commands, "o" for options)
  7. (pprof) top
  8. Showing nodes accounting for 2040ms, 80.31% of 2540ms total
  9. Dropped 44 nodes (cum <= 12.70ms)
  10. Showing top 10 nodes out of 57
  11. flat flat% sum% cum cum%
  12. 1490ms 58.66% 58.66% 1560ms 61.42% syscall.Syscall
  13. 180ms 7.09% 65.75% 300ms 11.81% runtime.mallocgc
  14. 110ms 4.33% 70.08% 110ms 4.33% time.Now
  15. 60ms 2.36% 72.44% 140ms 5.51% strings.ToLower
  16. 40ms 1.57% 74.02% 2250ms 88.58% github.com/jasonsoft/go-short-id.Generate
  17. 40ms 1.57% 75.59% 40ms 1.57% time.absDate
  18. 30ms 1.18% 76.77% 1630ms 64.17% crypto/rand.(*devReader).Read
  19. 30ms 1.18% 77.95% 30ms 1.18% runtime.nextFreeFast (inline)
  20. 30ms 1.18% 79.13% 40ms 1.57% runtime.reentersyscall
  21. 30ms 1.18% 80.31% 50ms 1.97% runtime.scanobject
  22. (pprof)

pprof 程序中最重要的命令就是 topN,此命令用于显示 profile 文件中的最靠前的 N 个样本(sample),它的输出格式各字段的含义依次是:

  1. 采样点落在该函数中的总时间;

  2. 采样点落在该函数中的百分比;

  3. 上一项的累积百分比;

  4. 采样点落在该函数,以及被它调用的函数中的总时间;

  5. 采样点落在该函数,以及被它调用的函数中的总次数百分比;

  6. 函数名。

此外,在 pprof 程序中还可以使用 svg 来生成函数调用关系图(需要安装 graphviz),例如:

17.项目测试:Go 代码测试种类有哪些,如何编写测试用例? - 图1

该调用图生成方法如下:

  1. 安装 graphviz 命令。
  1. $ sudo yum -y install graphviz.x86_64
  1. 执行 go tool pprof 生成 svg 图。
  1. $ go tool pprof id.test cpu.profile
  2. File: id.test
  3. Type: cpu
  4. Time: Jan 11, 2023 at 4:18pm (CST)
  5. Duration: 2.58s, Total samples = 2.54s (98.56%)
  6. Entering interactive mode (type "help" for commands, "o" for options)
  7. (pprof) svg
  8. Generating report in profile001.svg
  9. (pprof)

svg 子命令会默认在当前目录下生成了一个 svg 文件 profile001.svg

提示:

模糊测试

Fuzzing 是一种自动化的测试技术, 它不断地创建输入用来测试程序的 bug。 Go fuzzing 使用覆盖率智能指导遍历被模糊化测试的代码,发现缺陷并报告给用户。由于模糊测试可以达到人类经常忽略的边缘场景,因此它对于发现安全漏洞和缺陷特别有价值。

Go 语言在 1.18 版本支持了模糊测试用例。

单元测试有局限性,每个测试输入必须由开发者指定加到单元测试的测试用例里。Fuzzing 的优点之一是可以基于开发者代码里指定的测试输入作为基础数据,进一步自动生成新的随机测试数据,用来发现指定测试输入没有覆盖到的边界情况。

然而 Fuzzing 也有一定的局限性, 在单元测试里,因为测试输入是固定的,你可以知道调用 Reverse 函数(Reverse 函数用来将传入的字符串进行翻转)后每个输入字符串得到的反转字符串应该是什么,然后在单元测试的代码里判断 Reverse 的执行结果是否和预期相符。但是使用 Fuzzing 时,我们没办法预期输出结果是什么。所以 Fuzzing 模糊测试和 Go 已有的单元测试以及性能测试框架是互为补充的,并不是替代关系。

接下来就看一下如何编写模糊测试。

internal/miniblog/store 目录下,新增 helper_test.go 文件,内容如下:

  1. package store
  2. import (
  3. "testing"
  4. "github.com/stretchr/testify/assert"
  5. )
  6. // FuzzDefaultLimit 模糊测试用例.
  7. func FuzzDefaultLimit(f *testing.F) {
  8. testcases := []int{0, 1, 2}
  9. for _, tc := range testcases {
  10. f.Add(tc)
  11. }
  12. f.Fuzz(func(t *testing.T, orig int) {
  13. limit := defaultLimit(orig)
  14. if orig == 0 {
  15. assert.Equal(t, defaultLimitValue, limit)
  16. } else {
  17. assert.Equal(t, orig, limit)
  18. }
  19. })
  20. }

FuzzDefaultLimit 就是我们编写的模糊测试用例。

代码说明:

  • 模糊测试用例函数名必须以 Fuzz 开头,例如:如 FuzzXxxFuzz_xxx。函数接收一个 *testing.F 类型的参数, 无返回值;

  • f.Add(tc) 告诉了 Fuzzing 引擎我们需要的数据类型和顺序;

  • f.Fuzz 函数传入一个用于模糊测试的函数,该函数的首个入参必须是 *testing.T,后面的参数就是你希望 Go 的 Fuzzing 引擎帮你生成的随机数据类型。并且要注意,这个函数不能有返回值。

运行模糊测试用例:

我们可以通过以下 2 种方式来运行模糊测试用例。

  • 只使用种子语料库,而不生成随机测试数据。运行命令如下:
  1. $ go test -v -run=FuzzDefaultLimit
  2. === RUN FuzzDefaultLimit
  3. === RUN FuzzDefaultLimit/seed#0
  4. === RUN FuzzDefaultLimit/seed#1
  5. === RUN FuzzDefaultLimit/seed#2
  6. --- PASS: FuzzDefaultLimit (0.00s)
  7. --- PASS: FuzzDefaultLimit/seed#0 (0.00s)
  8. --- PASS: FuzzDefaultLimit/seed#1 (0.00s)
  9. --- PASS: FuzzDefaultLimit/seed#2 (0.00s)
  10. PASS
  11. ok github.com/marmotedu/miniblog/internal/miniblog/store 0.020s

这个方式只会使用种子语料库,而不会生成随机测试数据。通过这种方式可以用来验证种子语料库的测试数据是否可以测试通过。

所谓语料库(seed corpus),就是一组用户提供的语料,Fuzzing 引擎将会使用这个语料来生成随机数据。其实就是一个样板,有了样板,Fuzzing 引擎就知道要生成什么类型的随机数据了。

  • 基于种子语料库生成随机测试数据。运行命令如下:
  1. $ go test -fuzz=Fuzz
  2. fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
  3. fuzz: elapsed: 0s, gathering baseline coverage: 4/4 completed, now fuzzing with 16 workers
  4. fuzz: elapsed: 3s, execs: 52911 (17633/sec), new interesting: 1 (total: 5)
  5. ...
  6. PASS
  7. ok github.com/marmotedu/miniblog/internal/miniblog/store 143.123s

需要注意的是,执行模糊测试的时间是由开发者自己定的,如果你的代码非常强健,不管怎么更换随机数据,测试都能通过,那么 Fuzzing 将一直执行下去。除非找到了 error,或者你手动用 Ctrl^C 来停掉。

go test 跟模糊测试相关的参数还有以下 3 个:

  • -fuzztime: 执行的模糊目标在退出的时候要执行的总时间或者迭代次数,默认是永不结束;

  • -fuzzminimizetime: 模糊目标在每次最少尝试时要执行的时间或者迭代次数,默认是 60 秒。你可以禁用最小化尝试,只需把这个参数设置为 0;

  • -parallel: 同时执行的模糊化数量,默认是 $GOMAXPROCS。当前进行模糊化测试时设置 -cpu无效果。

测试工具介绍

在编写测试用例的时候,我们还可以借助众多的优秀工具/包,来协助我们快速编写高质量的测试用例。这些工具,按功能可以分为测试框架和 Mock 工具两类。测试框架,能够协助我们编写高质量的测试用例。Mock 工具,可以使我们在编写测试用例时,摆脱一些限制,使代码变得可测,提高代码的可测性。

测试框架

  • Testify 框架:Testify是 Go test 的预判工具,它能让你的测试代码变得更优雅和高效,测试结果也变得更详细。miniblog 项目中,testify 的使用案例见 helper_test.gouser_test.goid_test.go 文件;
  • GoConvey 框架:GoConvey 是一款针对 Go 语言的测试框架,可以管理和运行测试用例,同时提供了丰富的断言函数,并支持很多 Web 界面特性。

Mock 工具

Mock 工具用的最多的是 Go 官方提供的 Mock 框架 GoMock。关于 GoMock 的使用方法,可参考:Go Mock (gomock)简明教程

此外,还有一些其他的优秀 Mock 工具可供我们使用。这些 Mock 工具分别用在不同的 Mock 场景中,常用的 Mock 工具如下:

  • sqlmock:可以用来模拟数据库连接。数据库是项目中比较常见的依赖,在遇到数据库依赖时都可以用它。

  • httpmock:可以用来 Mock HTTP 请求。

  • bouk/monkey:猴子补丁,能够通过替换函数指针的方式来修改任意函数的实现。如果 GoMock、sqlmock 和 httpmock 这几种方法都不能满足我们的需求,我们可以尝试用猴子补丁的方式来 Mock 依赖。可以这么说,猴子补丁提供了单元测试 Mock 依赖的最终解决方案。

测试覆盖率

我们写单元测试的时候应该想得很全面,能够覆盖到所有的测试用例,但有时也会漏过一些 case,Go 提供了 cover 工具来统计测试覆盖率。

可以通过以下 2 个命令来进行覆盖率测试:

  • go test -coverprofile=cover.out:在测试文件目录下运行测试并统计测试覆盖率;

  • go tool cover -func=cover.out:分析覆盖率文件,可以看出哪些函数没有测试,哪些函数内部的分支没有测试完全,cover 工具会通过执行代码的行数与总行数的比例表示出覆盖率。

测试覆盖率

执行以下命令进行测试:

  1. $ cd miniblog/pkg/util/id
  2. $ go test -coverprofile=cover.out
  3. PASS
  4. coverage: 100.0% of statements
  5. ok github.com/marmotedu/miniblog/pkg/util/id 0.003s
  6. $ go tool cover -func=cover.out
  7. github.com/marmotedu/miniblog/pkg/util/id/id.go:15: GenShortID 100.0%
  8. total: (statements) 100.0%

可以看到 TestGenShortID 函数测试覆盖率为 100%

小结

本节课简单介绍了如何用 testing 包,编写单元测试用例和性能测试用例。在实际的开发中,要养成编写单元测试用例的好习惯,在项目上线前,最好对一些业务逻辑比较复杂的函数做一些性能测试,提前发现性能问题。

至于怎么去分析性能,比如查找耗时最久的函数等,我链接了郝林大神专业的分析方法 go tool pprof。更深的分析技巧需要你在实际开发中自己去探索。