从现在开始,我将用两讲的篇幅带大家学会如何对 Go 源码开展测试和性能优化工作。本讲的内容是测试部分,具体包含单元测试基准测试

我们都知道,测试的最基本目的在于发现错误,其次是程序运行的性能和效率是否满足需要。一些优秀的测试用例往往能发现从未发生过的错误。单元测试是保证代码运行正确性的常用做法基准测试是测量运行时,空间和时间资源消耗的常用做法

# 单元测试

正如前文所述,单元测试可以保证代码运行的正确性,这个“正确性”不仅指测试时的结果,还有代码修复或优化后的执行结果。但它无法保证程序的功能是正确的,换句话说,就是无法确保程序在业务逻辑上的正确性。举例来说,一个文件重命名的函数即使通过了单元测试,但无法保证程序在正确的时机调用它。如果错误地执行,就很可能与业务需求相悖,单元测试无法察觉这类错误。

我们先一起来看这样的源码文件(calc.go):

go
复制代码package calc
func AddCalc(num1 int, num2 int) int {
   return num1 + num2
}
func SubtractCalc(num1 int, num2 int) int {
   return num1 - num2
}
💡 提示:本例中的 calc.go 位于单独的 calc 包中。

可以看到,这个源码文件中总共包含了两个函数:AddCalc() 和 SubstractCalc()。从命名和各自的函数体代码中很容易看出,它们的作用就是将传入的 int 型参数相加和相减,并将计算结果返回给它们的调用者。

现在,我们要对这两个函数进行单元测试,看其是否正常运行。

在 Go 语言中,官方推荐的做法是单元测试的代码与被测的代码文件放在一起,并添加“_test”作为文件名的结尾。对于本例而言,将创建名为 calc_test.go 文件,内容是单元测试的代码。

紧接着,如果我们要对 calc.go 源码中的 AddCalc() 函数进行测试,就要创建相应的测试函数。测试函数名通常是在被测函数名前加“Test”前缀,且要求测试函数有且只有一个参数,即 *testing.T(用于函数测试)、*testing.B(用于基准测试)、*testing.M(用于测试 main) 三种类型之一

对于本例中的 AddCalc() 函数,其测试代码可写为:

go
复制代码func TestAddCalc(t *testing.T) {
   if result := AddCalc(1, 2); result != 3 {
      t.Errorf("传入的参数是1、2,相加应该是3。实际运行结果却是%d", result)
   }
}

我们都知道,1 和 2 相加,结果为 3。若 AddCalc() 函数执行正确,其结果应该就是 3。否则,代码将通过 t(*testing.T类型) 变量调用 Errorf()(或 Error()) 方法输出相关信息,但不会打断后续的测试。变量 t 的另一组方法是 Fatalf() 和 Fatal(),这两个方法会打断后续的测试

最后便是运行单元测试了,启动终端。由于 calc.go 源码属于 calc 包,在源码根目录的 calc 子目录中,因此先定位到 calc 子目录下,然后执行:

shell
复制代码go test

稍等片刻,终端将输出测试结果:

PASS

ok main/calc 0.216s

测试通过。

接下来编写一个名为 TestSubtractCalc() 的测试函数,内容是测试 SubtractCalc() 函数。具体如下:

go
复制代码func TestSubtractCalc(t *testing.T) {
   if result := SubtractCalc(1, 2); result != -1 {
      t.Errorf("传入的参数是1、2,相减应该是-1。实际运行结果却是%d", result)
   }
}

别急着运行,go test 除了可以直接使用外,还可以添加 -v 参数输出每个用例的结果,-cover 参数输出测试覆盖率, -run 参数指定要执行的测试函数。现在,尝试只运行 TestSubtractCalc() 函数,并输出代码覆盖率,执行语句是:

shell
复制代码go test -run TestSubtractCalc -cover

稍等片刻,终端输出如下结果:

PASS

coverage: 50.0% of statements

ok main/calc 0.217s

测试通过,代码覆盖率是 50%。这是因为 calc.go 中仅包含两个函数,测试了其中一个,覆盖率当然是 50%。

接下来,再尝试运行所有测试,并输出每个测试的结果和整体覆盖率,执行的语句是:

shell
复制代码go test -v -cover

稍等片刻,终端如下输出:

=== RUN TestAddCalc

--- PASS: TestAddCalc (0.00s)

=== RUN TestSubtractCalc

--- PASS: TestSubtractCalc (0.00s)

PASS

coverage: 100.0% of statements

ok main/calc 0.213s

可以看到,每条用例的测试结果都被输出出来了。

对于需要分组测试的情况,Go 语言允许将其作为子测试进行归类。假如对于本例中的 AddCalc() 函数,希望进行正整数、负整数和零值的运算测试,就可以将其作为 3 条子测试进行。具体请参考下面的代码:

go
复制代码func TestAddCalc(t *testing.T) {
   testCases := []struct {
      Name                       string
      Num1, Num2, ExpectedResult int
   }{
      {"positive", 5, 6, 11},
      {"negative", -2, -3, -5},
      {"zero", 0, 0, 5},
   }
   for _, singleCase := range testCases {
      t.Run(singleCase.Name, func(t *testing.T) {
         if result := AddCalc(singleCase.Num1, singleCase.Num2); result != singleCase.ExpectedResult {
            t.Errorf("%s:传入的参数是%d、%d,相加应该是%d。实际运行结果却是%d",
               singleCase.Name, singleCase.Num1, singleCase.Num2, singleCase.ExpectedResult, result)
         }
      })
   }
}

上述代码中,所有的测试用例数据被存放在 testCases 的切片中,随后遍历该切片执行每个子测试。这种测试方式又被称为:表格驱动测试。这里对于零值的计算,我故意写错了期望结果,添加 -v 参数执行测试后,可以看到终端的输出结果如下:

=== RUN TestAddCalc

=== RUN TestAddCalc/positive

=== RUN TestAddCalc/negative

=== RUN TestAddCalc/zero

calc_test.go:17: zero:传入的参数是0、0,相加应该是5。实际运行结果却是0

--- FAIL: TestAddCalc (0.00s)

--- PASS: TestAddCalc/positive (0.00s)

--- PASS: TestAddCalc/negative (0.00s)

--- FAIL: TestAddCalc/zero (0.00s)

FAIL

exit status 1

FAIL main/calc 0.208s

💡 提示:编写测试用例时,比较好的做法是输出足够的信息,帮助测试人员和开发者更高效地纠正错误。

对于某些需要初始化一些公共数据或资源用于测试的情况,可以增加 setup() 、 teardown() 及 TestMain() 函数组织它们。setup() 函数用于初始化数据或申请资源,teardown() 函数用于测试收尾时的释放资源,TestMain() 函数用于指挥它们及启动执行测试。下面的代码演示了它们是如何在一起配合执行的:

go
复制代码func setup() {
   fmt.Println("预处理数据")
}
func teardown() {
   fmt.Println("释放资源")
}
func TestMain(m *testing.M) {
   setup()
   resultCode := m.Run()
   teardown()
   os.Exit(resultCode)
}
func TestAddCalc(t *testing.T) {
   testCases := []struct {
      Name                       string
      Num1, Num2, ExpectedResult int
   }{
      {"positive", 5, 6, 11},
      {"negative", -2, -3, -5},
      {"zero", 0, 0, 0},
   }
   for _, singleCase := range testCases {
      t.Run(singleCase.Name, func(t *testing.T) {
         if result := AddCalc(singleCase.Num1, singleCase.Num2); result != singleCase.ExpectedResult {
            t.Errorf("%s:传入的参数是%d、%d,相加应该是%d。实际运行结果却是%d",
               singleCase.Name, singleCase.Num1, singleCase.Num2, singleCase.ExpectedResult, result)
         }
      })
   }
}

执行测试,终端输出如下:

预处理数据

PASS

释放资源

ok main/calc 0.210s

💡 提示:setup() 和 teardown() 函数的名称是固定的吗?当然不是!根据个人喜好不同,可以将其命名为自己喜欢的名称,比如 initData() 和 release()。甚至在单个测试用例中调用某个自定义函数都是允许的。

# 基准测试

当我们需要衡量代码的运行效率(CPU 时间消耗、内存空间占用)时,就需要用到基准测试了。

编写 Go 代码的基准测试函数,要求用例函数名必须以 Benchmark 开头,习惯上后面接待测函数名。用例函数的参数,必须为 b *testing.B。

现在,我们要衡量 AddCalc() 函数的运行效率,基准测试用例函数如下:

go
复制代码func BenchmarkAddCalc(b *testing.B) {
   for i := 0; i < b.N; i++ {
      fmt.Println(AddCalc(50, 50))
   }
}

接着,来到终端,定位到 calc 包目录,执行:

shell
复制代码go test -benchmem -bench .

这条语句中,-benchmem 用于输出内存消耗量-bench . 表示测试文件中所有的基准测试

等待测试完成,控制台输出示例如下:

36684 33150 ns/op 8 B/op 1 allocs/op

PASS

ok main/calc 1.778s

上述输出内容可解读为:总共执行了 36765 次,每次消耗 35371 纳秒,每次调用分配了 8 个字节,每次调用有 1 次分配操作

此外,还可以自定义测试时间,通过 -benchtime 定义。下面的命令执行了 5 秒测试:

shell
复制代码go test -benchmem -benchtime 5s -bench .

运行这条命令,终端输出变为:

157554 32214 ns/op 8 B/op 1 allocs/op

PASS

ok main/calc 5.686s

由于测试时间发生了变化,所以测试的总次数也会随之变化。

实际上,默认的 benchtime 值是 1 秒。但无论是否指定 benchtime 时长,其真实的测试时长往往并非恰好 1 秒。甚至当给定时长后,测试时长也并非恰好是给定的时长,这是为何呢?

当 Benchmark 基准测试开始后,若测试用例返回时还不到 1 秒,则 b 参数的 N 值(即本例中的循环结束条件)会以 1、2、5、10、20、50、……递增,并立即应用递增后的结果。所以我们看到的最终真实测试时长并非一开始给定的时长。

# 小结

🎉 恭喜,您完成了本次课程的学习!

📌 以下是本次课程的重点内容总结:

  1. Go 代码测试
    • 单元测试
    • 基准测试

本讲开始介绍 Go 程序的测试和性能优化。

首先简单介绍了软件测试的作用,然后阐述了 Go 程序的两大测试种类,即单元测试和基准测试,一个验证正确性,另一个衡量性能

在单元测试模块,从最简单的单次函数测试开始,再到覆盖率分析子用例测试以及输出每个用例的详细结果

在基准测试模块,介绍了基本方法报告数值解读以及修改测试时长的技巧。

➡️ 在下次课程中,我们会介绍 Go 程序的性能分析工具。具体内容如下:

  1. Go 程序性能分析工具的使用