为什么写单元测试?
关于测试,有一张很经典的图,如下:
说明:
测试类型 | 成本 | 速度 | 频率 |
---|---|---|---|
E2E 测试 | 高 | 慢 | 低 |
集成测试 | 中 | 中 | 中 |
单元测试 | 低 | 快 | 高 |
也就是说,单元测试是最快、最便宜的测试方式。这不难理解,单元测试往往用来验证代码的最小单元,比如一个函数、一个方法,这样的测试我们一个命令就能跑完整个项目的单元测试,而且速度还很快,所以单元测试是我们最常用的测试方式。
而 E2E 测试和集成测试,往往需要启动整个项目,然后需要真实用户进行手动操作,这样的测试成本高,速度慢,所以我们往往不会频繁地运行这样的测试。只有在项目的最后阶段,我们才会运行这样的测试。而单元测试,我们可以在开发的过程中,随时随地地运行,这样我们就能及时发现问题,及时解决问题。
一个基本的 Go 单元测试
Go 从一开始就支持单元测试,Go 的测试代码和普通代码一般是放在同一个包下的,只是测试代码的文件名是 _test.go
结尾的。比如我们有一个 add.go
文件,那么我们的测试文件就是 add_test.go
:
// add.go
package main
func Add(a int, b int) int {
return a + b
}
// add_test.go
package main
import "testing"
func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1 + 2 did not equal 3")
}
}
我们可以通过 go test
命令来运行测试:
go test
输出:
PASS
ok go-test 0.004s
注意:
- 测试函数的命名必须以
Test
开头,后面的名字必须以大写字母开头,比如TestAdd
。 - 测试函数的参数是
*testing.T
类型。 go test
加上-v
参数可以输出详细的测试信息,加上-cover
参数可以输出测试覆盖率。
go test 命令的参数详解
基本参数
-v
:输出详细的测试信息。比如输出每个测试用例的名称。-run regexp
:只运行匹配正则表达式的测试用例。如-run TestAdd
。-bench regexp
:运行匹配正则表达式的基准测试用例。-benchtime t
:设置基准测试的时间,默认是 1s,也就是让基准测试运行 1s。也可以指定基准测试的执行次数,格式如-benchtime 100x
,表示基准测试执行 100 次。-count n
:运行每个测试函数的次数,默认是 1 次。如果指定了-cpu
参数,那么每个测试函数会运行n * GOMAXPROCS
次。但是示例测试只会运行一次,该参数对模糊测试无效。-cover
:输出测试覆盖率。-covermode set,count,atomic
:设置测试覆盖率的模式。默认是set
,也就是记录哪些语句被执行过。-coverpkg pkg1,pkg2,pkg3
:用于指定哪些包应该生成覆盖率信息。这个参数允许你指定一个或多个包的模式,以便在运行测试时生成这些包的覆盖率信息。-cpu 1,2,4
:设置并行测试的 CPU 数量。默认是 GOMAXPROCS。这个参数对模糊测试无效。-failfast
:一旦某个测试函数失败,就停止运行其他的测试函数了。默认情况下,一个测试函数失败了,其他的测试函数还会继续运行。-fullpath
:测试失败的时候,输出完整的文件路径。-fuzz regexp
:运行模糊测试。-fuzztime t
:设置模糊测试的时间,默认是 1s。又或者我们可以指定模糊测试的执行次数,格式如-fuzztime 100x
,表示模糊测试执行 100 次。-fuzzminimizetime t
:设置模糊测试的最小化时间,默认是 1s。又或者我们可以指定模糊测试的最小化执行次数,格式如-fuzzminimizetime 100x
,表示模糊测试最小化执行 100 次。在模糊测试中,当发现一个失败的案例后,系统会尝试最小化这个失败案例,以找到导致失败的最小输入。-json
:以 json 格式输出-list regexp
:列出所有匹配正则表达式的测试用例名称。-parallel n
:设置并行测试的数量。默认是 GOMAXPROCS。-run regexp
:只运行匹配正则表达式的测试用例。-short
:缩短长时间运行的测试的测试时间。默认关闭。-shuffle off,on,N
:打乱测试用例的执行顺序。默认是off
,也就是不打乱,这会由上到下执行测试函数。-skip regexp
:跳过匹配正则表达式的测试用例。-timeout t
:设置测试的超时时间,默认是 10m,也就是 10 分钟。如果测试函数在超时时间内没有执行完,那么测试会panic
。-vet list
:设置go vet
的检查列表。默认是all
,也就是检查所有的。
性能相关
-benchmem
:输出基准测试的内存分配情况(也就是go test -bench .
的时候可以显示每次基准测试分配的内存)。-blockprofile block.out
:输出阻塞事件的分析数据。-blockprofilerate n
:设置阻塞事件的采样频率。默认是 1(单位纳秒)。如果没有设置采样频率,那么就会记录所有的阻塞事件。-coverprofile coverage.out
:输出测试覆盖率到文件coverage.out
。-cpuprofile cpu.out
:输出 CPU 性能分析信息到文件cpu.out
。-memprofile mem.out
:输出内存分析信息到文件mem.out
。-memprofilerate n
:设置内存分析的采样频率。-mutexprofile mutex.out
:输出互斥锁事件的分析数据。-mutexprofilefraction n
:设置互斥锁事件的采样频率。-outputdir directory
:设置输出文件的目录。-trace trace.out
:输出跟踪信息到文件trace.out
。
子测试
使用场景:当我们有多个测试用例的时候,我们可以使用子测试来组织测试代码,使得测试代码更具组织性和可读性。
package main
import (
"testing"
)
func TestAdd2(t *testing.T) {
cases := []struct {
name string
a, b, sum int
}{
{"case1", 1, 2, 3},
{"case2", 2, 3, 5},
{"case3", 3, 4, 7},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sum := Add(c.a, c.b)
if sum != c.sum {
t.Errorf("Sum was incorrect, got: %d, want: %d.", sum, c.sum)
}
})
}
}
输出:
➜ go-test go test
--- FAIL: TestAdd2 (0.00s)
--- FAIL: TestAdd2/case1 (0.00s)
add_test.go:21: Sum was incorrect, got: 4, want: 3.
--- FAIL: TestAdd2/case2 (0.00s)
add_test.go:21: Sum was incorrect, got: 6, want: 5.
--- FAIL: TestAdd2/case3 (0.00s)
add_test.go:21: Sum was incorrect, got: 8, want: 7.
FAIL
exit status 1
FAIL go-test 0.004s
我们可以看到,上面的输出中,失败的单元测试带有每个子测试的名称,这样我们就能很方便地知道是哪个测试用例失败了。
setup 和 teardown
在一般的单元测试框架中,都会提供 setup
和 teardown
的功能,setup
用来初始化测试环境,teardown
用来清理测试环境。
方法一:通过 Go 的 TestMain 方法
很遗憾的是,Go 的测试框架并没有直接提供这样的功能,但是我们可以通过 Go 的特性来实现这样的功能。
在 Go 的测试文件中,如果有 TestMain
函数,那么执行 go test
的时候会执行这个函数,而不会执行其他测试函数了,其他的测试函数需要通过 m.Run
来执行,如下面这样:
package main
import (
"fmt"
"os"
"testing"
)
func setup() {
fmt.Println("setup")
}
func teardown() {
fmt.Println("teardown")
}
func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1 + 2 != 3")
}
}
func TestMain(m *testing.M) {
setup()
code := m.Run()
teardown()
os.Exit(code)
}
在这个例子中,我们在 TestMain
函数中调用了 setup
和 teardown
函数,这样我们就实现了 setup
和 teardown
的功能。
方法二:使用 testify
框架
我们也可以使用 Go 中的第三方测试框架 testify
来实现 setup
和 teardown
的功能(使用 testify
中的 suite
功能)。
package main
import (
"fmt"
"github.com/stretchr/testify/suite"
"testing"
)
type AddSuite struct {
suite.Suite
}
func (suite *AddSuite) SetupTest() {
fmt.Println("Before test")
}
func (suite *AddSuite) TearDownTest() {
fmt.Println("After test")
}
func (suite *AddSuite) TestAdd() {
suite.Equal(Add(1, 2), 3)
}
func TestAddSuite(t *testing.T) {
suite.Run(t, new(AddSuite))
}
go test
输出:
➜ go-test go test
Before test
After test
--- FAIL: TestAddSuite (0.00s)
--- FAIL: TestAddSuite/TestAdd (0.00s)
add_test.go:22:
Error Trace: /Users/ruby/GolandProjects/go-test/add_test.go:22
Error: Not equal:
expected: 4
actual : 3
Test: TestAddSuite/TestAdd
FAIL
exit status 1
FAIL go-test 0.006s
我们可以看到,这里也同样执行了 SetupTest
和 TearDownTest
函数。
testing.T 可用的方法
最后,我们可以直接从 testing.T
提供的 API 来学习如何编写测试代码。
基本日志输出
t.Log(args ...any)
:打印信息,不会标记测试函数为失败。t.Logf(format string, args ...any)
:打印格式化的信息,不会标记测试函数为失败。
可能有读者会有疑问,输出不用 fmt
而用 t.Log
,这是因为:
t.Log
和t.Logf
打印的信息默认不会显示,只有在测试函数失败的时候才会显示。又或者我们使用-v
参数的时候才显示,这让我们的测试输出更加清晰,只有必要的时候日志才会显示。t.Log
和t.Logf
打印的时候,还会显示是哪一行代码打印的信息,这样我们就能很方便地定位问题。fmt.Println
打印的信息一定会显示在控制台上,就算我们的测试函数通过了,也会显示,这样会让控制台的输出很乱。
例子:
// add.go
package main
func Add(a int, b int) int {
return a + b
}
// add_test.go
package main
import (
"testing"
)
func TestAdd(t *testing.T) {
t.Log("TestAdd is running")
if Add(1, 2) != 3 {
t.Error("Expected 3")
}
}
输出:
➜ go-test go test
PASS
ok go-test 0.004s
我们修改一下 Add
函数,让测试失败,再次运行,输出如下:
➜ go-test go test
--- FAIL: TestAdd (0.00s)
add_test.go:8: TestAdd is running
add_test.go:10: Expected 3
FAIL
exit status 1
FAIL go-test 0.004s
我们可以发现,在测试成功的时候,t.Log
打印的日志并没有显示,只有在测试失败的时候才会显示。
如果我们想要在测试成功的时候也显示日志,可以使用 -v
参数:go test -v
。
标记测试函数为失败
t.Fail()
:标记测试函数为失败,但是测试函数后续代码会继续执行。(让你在测试函数中标记失败情况,并收集所有失败的情况,而不是在遇到第一个失败时就立即停止测试函数的执行。)t.FailNow()
:标记测试函数为失败,并立即返回,后续代码不会执行(通过调用runtime.Goexit
,但是defer
语句还是会被执行)。t.Failed()
:返回测试函数是否失败。t.Fatal(args ...any)
:标记测试函数为失败,并输出信息,然后立即返回。等价于t.Log
+t.FailNow
。t.Fatalf(format string, args ...any)
:标记测试函数为失败,并输出格式化的信息,然后立即返回。等价于t.Logf
+t.FailNow
。
如:
package main
import (
"testing"
)
func TestAdd(t *testing.T) {
if Add(1, 2) != 3 {
t.Fatal("Expected 3")
}
if Add(2, 3) != 5 {
t.Fatal("Expected 4")
}
}
这里只会输出第一个失败的测试用例,因为 t.Fatal
会立即返回。
标记测试函数为失败并输出信息
t.Error(args ...any)
:标记测试函数为失败,并打印错误信息。等价于t.Log
+t.Fail
。t.Errorf(format string, args ...any)
:标记测试函数为失败,并打印格式化的错误信息。等价于t.Logf
+t.Fail
。
这两个方法会让测试函数立即返回,不会继续执行后面的代码。
测试超时控制
t.Deadline()
:返回测试函数的截止时间(这是通过go test -timeout 60s
这种形式指定的超时时间)。
注意:如果我们通过
-timeout
指定了超时时间,当测试函数超时的时候,测试会panic
。
跳过测试函数中后续代码
作用:可以帮助测试代码在特定条件下灵活地跳过测试,避免不必要的测试执行,同时提供清晰的信息说明为什么跳过测试。
t.Skip(args ...any)
:跳过测试函数中后续代码,标记测试函数为跳过。等同于t.Log
+t.SkipNow
。t.Skipf(format string, args ...any)
:跳过测试函数中后续代码,并打印格式化的跳过信息。等同于t.Logf
+t.SkipNow
。t.SkipNow()
:跳过测试函数中后续代码,标记测试函数为跳过。这个方法不会输出内容,前面两个会输出一些信息t.Skipped()
:返回测试函数是否被跳过。
测试清理函数
t.Cleanup(f func())
:注册一个函数,这个函数会在测试函数结束后执行。这个函数会在测试函数结束后执行,不管测试函数是否失败,都会执行。(可以注册多个,执行顺序类似defer
,后注册的先执行)
package main
import (
"fmt"
"testing"
)
func TestAdd(t *testing.T) {
t.Cleanup(func() {
fmt.Println("cleanup 0")
})
t.Cleanup(func() {
fmt.Println("cleanup 1")
})
}
输出:
➜ go-test go test
cleanup 1
cleanup 0
PASS
ok go-test 0.004s
使用临时文件夹
t.TempDir()
:返回一个临时文件夹,这个文件夹会在测试函数结束后被删除。可以调用多次,每次都是不同的文件夹。
package main
import (
"fmt"
"testing"
)
func TestAdd(t *testing.T) {
fmt.Println(t.TempDir())
fmt.Println(t.TempDir())
}
输出:
➜ go-test go test
/var/folders/dm/r_hly4w5557b000jh31_43gh0000gp/T/TestAdd4259799402/001
/var/folders/dm/r_hly4w5557b000jh31_43gh0000gp/T/TestAdd4259799402/002
PASS
ok go-test 0.004s
临时的环境变量
t.Setenv(key, value string)
:设置一个临时的环境变量,这个环境变量会在测试函数结束后被还原。
在单元测试中,使用 Setenv
函数可以模拟不同的环境变量设置,从而测试代码在不同环境下的行为。例如,你可以在测试中设置特定的环境变量值,然后运行被测试的代码,以验证代码在这些环境变量设置下的正确性。
子测试
可以将一个大的测试函数拆分成多个子测试,使得测试代码更具组织性和可读性。
t.Run(name string, f func(t *testing.T))
:创建一个子测试,这个子测试会在父测试中执行。子测试可以有自己的测试函数,也可以有自己的子测试。
获取当前测试的名称
t.Name()
:返回当前测试的名称(也就是测试函数名)。
t.Helper()
t.Helper()
:标记当前测试函数是一个辅助函数,这样会让测试输出更加清晰,只有真正的测试函数会被标记为失败。
例子:
// add.go
package main
func Add(a int, b int) int {
return a + b + 1
}
// add_test.go
package main
import (
"testing"
)
func test(a, b, sum int, t *testing.T) {
result := Add(a, b)
if result != sum {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, result, sum)
}
}
func TestAdd(t *testing.T) {
test(1, 2, 3, t)
test(2, 3, 5, t)
}
输出如下:
➜ go-test go test -v
=== RUN TestAdd
add_test.go:10: Add(1, 2) = 4; want 3
add_test.go:10: Add(2, 3) = 6; want 5
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL go-test 0.004s
我们可以看到,两个测试失败输出的报错行都是 test
函数里面的 t.Errorf
,而不是 test
函数的调用者 TestAdd
,也就是说,在这种情况下我们不好知道是 test(1, 2, 3, t)
还是 test(2, 3, 5, t)
失败了(当然我们这里还是挺明显的,只是举个例子),这时我们可以使用 t.Helper()
:
func test(a, b, sum int, t *testing.T) {
t.Helper() // 在助手函数中加上这一行
result := Add(a, b)
if result != sum {
t.Errorf("Add(%d, %d) = %d; want %d", a, b, result, sum)
}
}
输出如下:
➜ go-test go test -v
=== RUN TestAdd
add_test.go:16: Add(1, 2) = 4; want 3
add_test.go:17: Add(2, 3) = 6; want 5
--- FAIL: TestAdd (0.00s)
FAIL
exit status 1
FAIL go-test 0.004s
这个时候,我们就很容易知道是哪一个测试用例失败了,这对于我们需要封装 helper 函数的时候很有用。