testing - 单元测试
1. testing - 单元测试
testing
为 Go 语言 package 提供自动化测试的支持。通过 go test
命令,能够自动执行如下形式的任何函数:
1 | func TestXxx(*testing.T) |
注意:Xxx 可以是任何字母数字字符串,但是第一个字母不能是小写字母。
在这些函数中,使用 Error
、Fail
或相关方法来发出失败信号。
要编写一个新的测试套件,需要创建一个名称以 _test.go 结尾的文件,该文件包含 TestXxx
函数,如上所述。 将该文件放在与被测试文件相同的包中。该文件将被排除在正常的程序包之外,但在运行 go test
命令时将被包含。 有关详细信息,请运行 go help test
和 go help testflag
了解。
如果有需要,可以调用 *T
和 *B
的 Skip
方法,跳过该测试或基准测试:
1 | func TestTimeConsuming(t *testing.T) { |
1.1. 第一个单元测试
要测试的代码:
1 | func Fib(n int) int { |
测试代码:
1 | func TestFib(t *testing.T) { |
执行 go test .
,输出:
1 | $ go test . |
表示测试通过。
我们将 Sum
函数改为:
1 | func Fib(n int) int { |
再执行 go test .
,输出:
1 | $ go test . |
1.2. Table-Driven Test
测试讲究 case 覆盖,按上面的方式,当我们要覆盖更多 case 时,显然通过修改代码的方式很笨拙。这时我们可以采用 Table-Driven 的方式写测试,标准库中有很多测试是使用这种方式写的。
1 | func TestFib(t *testing.T) { |
由于我们使用的是 t.Errorf
,即使其中某个 case 失败,也不会终止测试执行。
1.3. T 类型
单元测试中,传递给测试函数的参数是 *testing.T
类型。它用于管理测试状态并支持格式化测试日志。测试日志会在执行测试的过程中不断累积,并在测试完成时转储至标准输出。
当测试函数返回时,或者当测试函数调用 FailNow
、 Fatal
、Fatalf
、SkipNow
、Skip
、Skipf
中的任意一个时,则宣告该测试函数结束。跟 Parallel
方法一样,以上提到的这些方法只能在运行测试函数的 goroutine 中调用。
至于其他报告方法,比如 Log
以及 Error
的变种, 则可以在多个 goroutine 中同时进行调用。
1.3.1. 报告方法
上面提到的系列包括方法,带 f
的是格式化的,格式化语法参考 fmt
包。
T 类型内嵌了 common 类型,common 提供这一系列方法,我们经常会用到的(注意,这里说的测试中断,都是指当前测试函数):
1)当我们遇到一个断言错误的时候,标识这个测试失败,会使用到:
1 | Fail : 测试失败,测试继续,也就是之后的代码依然会执行 |
在 FailNow
方法实现的内部,是通过调用 runtime.Goexit()
来中断测试的。
2)当我们遇到一个断言错误,只希望跳过这个错误,但是不希望标识测试失败,会使用到:
1 | SkipNow : 跳过测试,测试中断 |
在 SkipNow
方法实现的内部,是通过调用 runtime.Goexit()
来中断测试的。
3)当我们只希望打印信息,会用到 :
1 | Log : 输出信息 |
注意:默认情况下,单元测试成功时,它们打印的信息不会输出,可以通过加上 -v
选项,输出这些信息。但对于基准测试,它们总是会被输出。
4)当我们希望跳过这个测试,并且打印出信息,会用到:
1 | Skip : 相当于 Log + SkipNow |
5)当我们希望断言失败的时候,标识测试失败,并打印出必要的信息,但是测试继续,会用到:
1 | Error : 相当于 Log + Fail |
6)当我们希望断言失败的时候,标识测试失败,打印出必要的信息,但中断测试,会用到:
1 | Fatal : 相当于 Log + FailNow |
1.3.2. Parallel 测试
包中的 Parallel 方法表示当前测试只会与其他带有 Parallel 方法的测试并行进行测试。
下面例子将演示 Parallel 的使用方法:
1 | var ( |
测试代码:
1 | var pairs = []struct { |
试验步骤:
- 注释掉 WriteToMap 和 ReadFromMap 中 locker 保护的代码,同时注释掉测试代码中的 t.Parallel,执行测试,测试通过,即使加上
-race
,测试依然通过; - 只注释掉 WriteToMap 和 ReadFromMap 中 locker 保护的代码,执行测试,测试失败(如果未失败,加上
-race
一定会失败);
如果代码能够进行并行测试,在写测试时,尽量加上 Parallel,这样可以测试出一些可能的问题。
关于 Parallel 的更多内容,会在 子测试 中介绍。
当你写完一个函数,结构体,main 之后,你下一步需要的就是测试了。testing 包提供了很简单易用的测试包。
2. 写一个基本的测试用例
测试文件的文件名需要以_test.go 为结尾,测试用例需要以 TestXxxx 的形式存在。
比如我要测试 utils 包的 sql.go 中的函数:
1 | func GetOne(db *sql.DB, query string, args ...interface{}) (map[string][]byte, error) { |
就需要创建一个 sql_test.go
1 | package utils |
3. testing 的测试用例形式
测试用例有四种形式:
1 | TestXxxx(t *testing.T) // 基本测试用例 |
给个 Example 的例子 :(Example 需要在最后用注释的方式确认控制台输出和预期是不是一致的)
1 | func Example_GetScore() { |
4. testing 的变量
gotest 的变量有这些:
- test.short : 一个快速测试的标记,在测试用例中可以使用 testing.Short() 来绕开一些测试
- test.outputdir : 输出目录
- test.coverprofile : 测试覆盖率参数,指定输出文件
- test.run : 指定正则来运行某个 / 某些测试用例
- test.memprofile : 内存分析参数,指定输出文件
- test.memprofilerate : 内存分析参数,内存分析的抽样率
- test.cpuprofile : cpu 分析输出参数,为空则不做 cpu 分析
- test.blockprofile : 阻塞事件的分析参数,指定输出文件
- test.blockprofilerate : 阻塞事件的分析参数,指定抽样频率
- test.timeout : 超时时间
- test.cpu : 指定 cpu 数量
- test.parallel : 指定运行测试用例的并行数
5. testing 的结构体
- B : 压力测试
- BenchmarkResult : 压力测试结果
- Cover : 代码覆盖率相关结构体
- CoverBlock : 代码覆盖率相关结构体
- InternalBenchmark : 内部使用的结构体
- InternalExample : 内部使用的结构体
- InternalTest : 内部使用的结构体
- M : main 测试使用的结构体
- PB : Parallel benchmarks 并行测试使用的结构体
- T : 普通测试用例
- TB : 测试用例的接口
6. testing 的通用方法
T 结构内部是继承自 common 结构,common 结构提供集中方法,是我们经常会用到的:
1)当我们遇到一个断言错误的时候,我们就会判断这个测试用例失败,就会使用到:
1 | Fail : case 失败,测试用例继续 |
2)当我们遇到一个断言错误,只希望跳过这个错误,但是不希望标示测试用例失败,会使用到:
1 | SkipNow : case 跳过,测试用例不继续 |
3)当我们只希望在一个地方打印出信息,我们会用到 :
1 | Log : 输出信息 |
4)当我们希望跳过这个用例,并且打印出信息 :
1 | Skip : Log + SkipNow |
5)当我们希望断言失败的时候,测试用例失败,打印出必要的信息,但是测试用例继续:
1 | Error : Log + Fail |
6)当我们希望断言失败的时候,测试用例失败,打印出必要的信息,测试用例中断:
1 | Fatal : Log + FailNow |
testing - 基准测试
1.testing - 基准测试
在 _test.go 结尾的测试文件中,如下形式的函数:
1 | func BenchmarkXxx(*testing.B) |
被认为是基准测试,通过 go test
命令,加上 -bench
标志来执行。多个基准测试按照顺序运行。
基准测试函数的形式如下:
1 | func BenchmarkHello(b *testing.B) { |
基准函数会运行目标代码 b.N 次。在基准执行期间,程序会自动调整 b.N 直到基准测试函数持续足够长的时间。输出结果形如:
1 | BenchmarkHello 10000000 282 ns/op |
意味着循环执行了 10000000 次,每次循环花费 282 纳秒 (ns)。
如果基准测试在循环前需要一些耗时的配置,则可以先重置定时器:
1 | func BenchmarkBigLen(b *testing.B) { |
如果基准测试需要在并行设置中测试性能,则可以使用 RunParallel
辅助函数 ; 这样的基准测试一般与 go test -cpu
标志一起使用:
1 | func BenchmarkTemplateParallel(b *testing.B) { |
1.1. 基准测试示例
接着上一节的例子,我们对 Fib
进行基准测试:
1 | func BenchmarkFib10(b *testing.B) { |
执行 go test -bench=.
,输出:
1 | $ go test -bench=. |
这里测试了 Fib(10)
的情况,我们可能需要测试更多不同的情况,这时可以改写我们的测试代码:
1 | func BenchmarkFib1(b *testing.B) { benchmarkFib(1, b) } |
再次执行 go test -bench=.
,输出:
1 | $ go test -bench=. |
默认情况下,每个基准测试最少运行 1 秒。如果基准测试函数返回时,还不到 1 秒钟,b.N
的值会按照序列 1,2,5,10,20,50,… 增加,同时再次运行基准测测试函数。
我们注意到 BenchmarkFib40
一共才运行 2 次。为了更精确的结果,我们可以通过 -benchtime
标志指定运行时间,从而使它运行更多次。
1 | $ go test -bench=Fib40 -benchtime=20s |
1.2. B 类型
B 是传递给基准测试函数的一种类型,它用于管理基准测试的计时行为,并指示应该迭代地运行测试多少次。
当基准测试函数返回时,或者当基准测试函数调用 FailNow
、Fatal
、Fatalf
、SkipNow
、Skip
、Skipf
中的任意一个方法时,则宣告测试函数结束。至于其他报告方法,比如 Log
和 Error
的变种,则可以在其他 goroutine 中同时进行调用。
跟单元测试一样,基准测试会在执行的过程中积累日志,并在测试完毕时将日志转储到标准错误。但跟单元测试不一样的是,为了避免基准测试的结果受到日志打印操作的影响,基准测试总是会把日志打印出来。
B 类型中的报告方法使用方式和 T 类型是一样的,一般来说,基准测试中也不需要使用,毕竟主要是测性能。这里我们对 B 类型中其他的一些方法进行讲解。
1.2.1. 计时方法
有三个方法用于计时:
- StartTimer:开始对测试进行计时。该方法会在基准测试开始时自动被调用,我们也可以在调用 StopTimer 之后恢复计时;
- StopTimer:停止对测试进行计时。当你需要执行一些复杂的初始化操作,并且你不想对这些操作进行测量时,就可以使用这个方法来暂时地停止计时;
- ResetTimer:对已经逝去的基准测试时间以及内存分配计数器进行清零。对于正在运行中的计时器,这个方法不会产生任何效果。本节开头有使用示例。
1.2.2. 并行执行
通过 RunParallel
方法能够并行地执行给定的基准测试。RunParallel
会创建出多个 goroutine,并将 b.N 分配给这些 goroutine 执行,其中 goroutine 数量的默认值为 GOMAXPROCS。用户如果想要增加非 CPU 受限(non-CPU-bound)基准测试的并行性,那么可以在 RunParallel
之前调用 SetParallelism
(如 SetParallelism(2)
,则 goroutine 数量为 2*GOMAXPROCS)。RunParallel
通常会与 -cpu
标志一同使用。
body
函数将在每个 goroutine 中执行,这个函数需要设置所有 goroutine 本地的状态,并迭代直到 pb.Next
返回 false 值为止。因为 StartTimer
、StopTime
和 ResetTimer
这三个方法都带有全局作用,所以 body
函数不应该调用这些方法; 除此之外,body
函数也不应该调用 Run
方法。
具体的使用示例,在本节开头已经提供!
1.2.3. 内存统计
ReportAllocs
方法用于打开当前基准测试的内存统计功能, 与 go test
使用 -benchmem
标志类似,但 ReportAllocs
只影响那些调用了该函数的基准测试。
测试示例:
1 | func BenchmarkTmplExucte(b *testing.B) { |
测试结果类似这样:
1 | BenchmarkTmplExucte-4 2000000 898 ns/op 368 B/op 9 allocs/op |
1.2.4. 基准测试结果
对上述结果中的每一项,你是否都清楚是什么意思呢?
2000000
:基准测试的迭代总次数 b.N898 ns/op
:平均每次迭代所消耗的纳秒数368 B/op
:平均每次迭代内存所分配的字节数9 allocs/op
:平均每次迭代的内存分配次数
testing
包中的 BenchmarkResult
类型能为你提供帮助,它保存了基准测试的结果,定义如下:
1 | type BenchmarkResult struct { |
该类型还提供了每次迭代操作所消耗资源的计算方法,示例如下:
1 | package main |
testing - 子测试与子基准测试
从 Go 1.7 开始,引入了一个新特性:子测试(subtests)与子基准测试(sub-benchmarks),它意味着您现在可以拥有嵌套测试,这对于过滤执行特定测试用例非常有用。
T 和 B 的 Run
方法允许定义子单元测试和子基准测试,而不必为它们单独定义函数。这便于创建基于 Table-Driven 的基准测试和层级测试。它还提供了一种共享通用 setup
和 tear-down
代码的方法:
1 | func TestFoo(t *testing.T) { |
每个子测试和子基准测试都有一个唯一的名称:由顶层测试的名称与传递给 Run
的名称组成,以斜杠分隔,并具有可选的尾随序列号,用于消除歧义。
命令行标志 -run
和 -bench
的参数是非固定的正则表达式,用于匹配测试名称。对于由斜杠分隔的测试名称,例如子测试的名称,它名称本身即可作为参数,依次匹配由斜杠分隔的每部分名称。因为参数是非固定的,一个空的表达式匹配任何字符串,所以下述例子中的 “匹配” 意味着 “顶层/子测试名称包含有”:
1 | go test -run '' # 执行所有测试。 |
子测试也可用于程序并行控制。只有子测试全部执行完毕后,父测试才会完成。在下述例子中,所有子测试之间并行运行,此处的 “并行” 只限于这些子测试之间,并不影响定义在其他顶层测试中的子测试:
1 | func TestGroupedParallel(t *testing.T) { |
在所有子测试并行运行完毕之前,Run
方法不会返回。下述例子提供了一种方法,用于在子测试并行运行完毕后清理资源:
1 | func TestTeardownParallel(t *testing.T) { |
testing - 运行并验证示例
testing
包除了测试,还提供了运行并验证示例的功能。示例,一方面是文档的效果,是关于某个功能的使用例子;另一方面,可以被当做测试运行。
一个示例的例子如下:
1 | func ExampleHello() { |
如果 Output: Hello
改为:Output: hello
,运行测试会失败,提示:
1 | got: |
一个示例函数以 Example 开头,如果示例函数包含以 “Output:” 开头的行注释,在运行测试时,go 会将示例函数的输出和 “Output:” 注释中的值做比较,就如上面的例子。
有时候,输出顺序可能不确定,比如循环输出 map 的值,那么可以使用 “Unordered output:” 开头的注释。
如果示例函数没有上述输出注释,该示例函数只会被编译而不会被运行。
1.1. 命名约定
Go 语言通过大量的命名约定来简化工具的复杂度,规范代码的风格。对示例函数的命名有如下约定:
- 包级别的示例函数,直接命名为
func Example() { ... }
- 函数 F 的示例,命名为
func ExampleF() { ... }
- 类型 T 的示例,命名为
func ExampleT() { ... }
- 类型 T 上的 方法 M 的示例,命名为
func ExampleT_M() { ... }
有时,我们想要给 包 / 类型 / 函数 / 方法 提供多个示例,可以通过在示例函数名称后附加一个不同的后缀来实现,但这种后缀必须以小写字母开头,如:
1 | func Example_suffix() { ... } |
通常,示例代码会放在单独的示例文件中,命名为 example_test.go
。可以查看 io
包中的 example_test.go
了解示例的编写。
1.2. 实现原理
本节开头提到了示例的两个作用,它们分别是由 godoc
和 go test
这两个命令实现的。
在执行 go test
时,会运行示例。具体的实现原理,可以通过阅读 go test
命令源码和 testing
包中 example.go
文件了解。
testing - 其他功能
1.1. TestMain
在写测试时,有时需要在测试之前或之后进行额外的设置(setup)或拆卸(teardown);有时,测试还需要控制在主线程上运行的代码。为了支持这些需求,testing
包提供了 TestMain
函数 :
1 | func TestMain(m *testing.M) |
如果测试文件中包含该函数,那么生成的测试将调用 TestMain(m)
,而不是直接运行测试。TestMain
运行在主 goroutine 中 , 可以在调用 m.Run
前后做任何设置和拆卸。注意,在 TestMain
函数的最后,应该使用 m.Run
的返回值作为参数去调用 os.Exit
。
另外,在调用 TestMain
时 , flag.Parse
并没有被调用。所以,如果 TestMain
依赖于 command-line 标志(包括 testing
包的标志),则应该显式地调用 flag.Parse
。注意,这里的依赖是指,若 TestMain
函数内需要用到 command-line 标志,则必须显式地调用 flag.Parse
,否则不需要,因为 m.Run
中调用 flag.Parse
。
一个包含 TestMain
的例子如下:
1 | package mytestmain |
对 m.Run
感兴趣的可以阅读源码,了解其原理。
1.2. Test Coverage
测试覆盖率,这里讨论的是基于代码的测试覆盖率。
Go 从 1.2 开始,引入了对测试覆盖率的支持,使用的是与 cover 相关的工具(go test -cover
、go tool cover
)。虽然 testing
包提供了 cover 相关函数,不过它们是给 cover 的工具使用的。
关于测试覆盖率的更多信息,可以参考官方的博文:The cover story
httptest - HTTP 测试辅助工具
1.httptest - HTTP 测试辅助工具
由于 Go 标准库的强大支持,Go 可以很容易的进行 Web 开发。为此,Go 标准库专门提供了 net/http/httptest
包专门用于进行 http Web 开发测试。
本节我们通过一个社区帖子的增删改查的例子来学习该包。
1.1. 简单的 Web 应用
我们首先构建一个简单的 Web 应用。
为了简单起见,数据保存在内存,并且没有考虑并发问题。
1 | // 保存 Topic,没有考虑并发问题 |
对于 Topic
的增删改查代码很简单,可以查看完整代码。
接下来,是通过 net/http
包来实现一个 Web 应用。
1 | func main() { |
/topic/
开头的请求都交由 handleRequest
处理,它根据不同的 Method
执行相应的增删改查,详细代码可以查看 server.go。
准备好 Web 应用后,我们启动它。
go run server.go data.go
通过 curl
进行简单的测试:
增:curl -i -X POST http://localhost:2017/topic/ -H ‘content-type: application/json’ -d ‘{“title”:”The Go Standard Library”,”content”:”It contains many packages.”}’
查:curl -i -X GET http://localhost:2017/topic/1
改:curl -i -X PUT http://localhost:2017/topic/1 -H ‘content-type: application/json’ -d ‘{“title”:”The Go Standard Library By Example”,”content”:”It contains many packages, enjoying it.”}’
删:curl -i -X DELETE http://localhost:2017/topic/1
1.2. 通过 httptest 进行测试
上面,我们通过 curl
对我们的 Web 应用的接口进行了测试。现在,我们通过 net/http/httptest
包进行测试。
我们先测试创建帖子,也就是测试 handlePost
函数。
1 | func TestHandlePost(t *testing.T) { |
首先跟待测试代码一样,配置上路由,对 /topic/
的请求都交由 handleRequest
处理。
1 | mux := http.NewServeMux() |
因为 handlePost
的函数签名是 func handlePost(w http.ResponseWriter, r *http.Request) error
,为了测试它,我们必须创建 http.ResponseWriter
和 http.Request
的实例。
接下来的代码就是创建一个 http.Request
实例 和一个 http.ResponseWriter
的实例。这里的关键是,通过 httptest.NewRecorder()
可以获得 httptest.ResponseRecorder
结构,而此结构实现了http.ResponseWriter
接口。
1 | reader := strings.NewReader(`{"title":"The Go Standard Library","content":"It contains many packages."}`) |
准备好之后,可以测试目标函数了。这里,我们没有直接调用 handlePost(w, r)
,而是调用 mux.ServeHTTP(w, r)
,实际上这里直接调用 handlePost(w, r)
也是可以的,但调用 mux.ServeHTTP(w, r)
会更完整地测试整个流程。mux.ServeHTTP(w, r)
最终也会调用到 handlePost(w, r)
。
最后,通过 go test -v
运行测试。
查、改和删帖子的接口测试代码类似,比如,handleGet
的测试代码如下:
1 | func TestHandleGet(t *testing.T) { |
注意:因为数据没有落地存储,为了保证后面的测试正常,请将 TestHandlePost
放在最前面。
1.3. 测试代码改进
细心的朋友应该会发现,上面的测试代码有重复,比如:
1 | mux := http.NewServeMux() |
以及:
1 | w := httptest.NewRecorder() |
这正好是前面学习的 setup
可以做的事情,因此可以使用 TestMain
来做重构。
1 | var w *httptest.ResponseRecorder |