单元测试是每个开发人员必须掌握的开发技能,Go语言特别注重单元测试,所以每个Gopher需要知道如何进行单元测试,使用什么参数控制测试效果,提升我们编写的代码质量,本文讨论相关单测技巧。
在开发过程中,想要直观的看到哪些代码已近被测试代码覆盖,使用 -coverprofile 参数,操作命令如下, 注意下面 ./...
表示递归目录。
go test -coverprofile=coverage.out ./...
执行上述命令会产生一个 coverage.out 文件, 然后使用 go tool cover 命令将 coverage.out 转换为html格式,在浏览器查看,具体命令如下:
go tool cover -html=coverage.out
默认情况下,只对当前包中的代码产生覆盖率。下面举例说明,现有代码结构如下。
各个文件中的代码如下:
package foo
func sub(a, b int) int {
return a - b
}
package foo
import (
"myapp/bar"
"testing"
)
func Test_sub(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
{
name: "sub",
args: args{
a: 2,
b: 1,
},
want: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sub(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("sub() = %v, want %v", got, tt.want)
}
})
}
}
func Test_Add(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
{
name: "Add",
args: args{
a: 1,
b: 2,
},
want: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := bar.Add(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("add() = %v, want %v", got, tt.want)
}
})
}
}
package bar
func add(a, b int) int {
return a + b
}
func Add(a, b int) int {
return a + b
}
package bar
import "testing"
func Test_add(t *testing.T) {
type args struct {
a int
b int
}
tests := []struct {
name string
args args
want int
}{
{
name: "add",
args: args{
a: 1,
b: 2,
},
want: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := add(tt.args.a, tt.args.b); got != tt.want {
t.Errorf("add() = %v, want %v", got, tt.want)
}
})
}
}
在myapp目录下,执行单元测试,得到覆盖率文件 coverage.out文件
将coverage.out转成html形式在浏览器中查看, 可以看到foo.go覆盖率为100%, bar.go覆盖率只有50%, Add函数没有覆盖。
为啥Add函数没有覆盖呢?我们不是在foo_test.go中编写了Add函数测试代码吗?这验证了前面说的,「默认情况下,只对当前包中的代码产生覆盖率」, 因为Add函数不属于当前foo包中的代码,所以没有产生它的覆盖率。有解决办法吗?
当然有,使用参数 -coverpkg
,可以解决上述问题,下面进行验证。
同样在转为html形式在浏览器查看如下, 此时所有代码都是100%覆盖。
「NOTE 不要一味追求代码覆盖率,代码覆盖率100%并不表示代码没有任何bug, 我们更应该关注各种场景功能,而不是覆盖率本身。」
编写单元测试时,有两种关注点,一种是关注内部实现,另一种是关注外在行为。假设对外提供一个API,我们测试的关注重点应该是外在行为,而不是实现细节。因为如果代码重构了或者内部逻辑修改了,对外提供的API通常是不变的,所以测试也将保持不变。具体就是在包外编写测试代码。
在Go语言中,同一个文件夹中的代码都属于同一个包,但是有一种例外情况,测试文件可以属于 _test 包。例如,下面的代码在 counter.go文件中并且属于 counter包。
package counter
import "sync/atomic"
var count uint64
func Inc() uint64 {
atomic.AddUint64(&count, 1)
return count
}
对于counter.go代码的测试文件counter_test.go通常都是放在counter.go所在包中,这样可以直接访问count变量。但也可以放在 counter_test包中,像下面这样。
package counter_test
import (
"counter"
"testing"
)
func TestCount(t *testing.T) {
if counter.Inc() != 1 {
t.Errorf("expected 1")
}
}
整个代码目前结构如下,counter.go和counter_test.go文件虽然都放在counter目录下,但是它们属于不同的包,counter.go的package为counter, counter_test.go的package为counter_test.
执行单元测试,同样输出覆盖率文件。
通过上面这种方法,在测试文件中只能访问被测代码对外提供的函数和可导出变量,不能访问内部变量,像counter.go中的count变量,确保测试代码只关注外在行为,而不是内部实现。
编写测试代码时, 我们可以采用与正式代码不同的方法处理错误。例如,测试函数中需要一个 Customer
对象,我们要创建这样一个结构体对象,考虑到创建过程可以复用,决定编写一个 createCustomer
函数用于构建Customer对象,函数返回值为创建的对象和error,实现代码如下。
func TestCustomer(t *testing.T) {
customer, err := createCustomer("foo")
if err != nil {
t.Fatal(err)
}
// ...
}
func createCustomer(someArg string) (Customer, error) {
// Create customer
if err != nil {
return Customer{}, err
}
return customer, nil
}
在测试函数TestCustomer中调用构造函数createCustomer创建一个Customer对象,并判断err是否非nil, 没有问题执行后续代码。由于现在编写的是测试代码,可以简化错误处理, 具体是将 *testing.T
变量传递给createCustomer函数, 实现代码如下。
func TestCustomer(t *testing.T) {
customer := createCustomer(t, "foo")
// ...
}
func createCustomer(t *testing.T, someArg string) Customer {
// Create customer
if err != nil {
t.Fatal(err)
}
return customer
}
这样在测试代码TestCustomer中只管获取Customer对象,不用关心error,将error处理下沉到创建函数中, 如果创建失败,直接通过 t.Fatal(err)
抛出问题终止代码执行,使得测试代码更清爽易读。
在某些测试场景下,我们需要预先构造测试环境。例如,在集成测试中,我们需要启动一个docker,测试完成后stop掉它。可以使用 setUp
和tearDown
测试固件来执行创建与销毁操作。
在每次测试时调用设置函数预先执行,结合defer函数,调用销毁函数当测试执行完后, 示例代码如下。
func TestMySQLIntegration(t *testing.T) {
setupMySQL()
defer teardownMySQL()
// ...
}
我们可以向t中注册清理函数,当测试逻辑执行完后调用。举例说明,假定TestMySQLIntegration
函数需要通过createConnection
创建一个数据库连接,当测试函数执行完成之后,需要关闭连接。可以将关闭连接逻辑注册到 t.Cleanup中。
func TestMySQLIntegration(t *testing.T) {
// ...
db := createConnection(t, "tcp(localhost:3306)/db")
// ...
}
func createConnection(t *testing.T, dsn string) *sql.DB {
db, err := sql.Open("mysql", dsn)
if err != nil {
t.FailNow()
}
t.Cleanup(
func() {
_ = db.Close()
})
return db
}
当TestMySQLIntegration
执行完后,将会自动调用t.Cleanup方法,关闭数据库连接。通过这种方法,有效降低资源泄露风险。注意,我们可以注册多个cleanup函数,它们调用顺序像defer那样,先注册的后被调用。
// 注册多个cleanup函数
t.Cleanup(func() {
fmt.Println("clean up 1")
})
t.Cleanup(func() {
fmt.Println("clean up 2")
})
通过TestMain
函数可以在运行所有测试前执行一些初始化逻辑(如创建数据库链接),或所有测试都运行结束之后执行一些清理逻辑(释放数据库连接),如果测试文件中定义了这个函数,则go test命令会直接运行这个函数,否则go test会创建一个默认的TestMain()函数。这个函数的默认行为就是运行文件中定义的测试。我们自定义TestMain()函数时,也需要手动调用m.Run()方法运行测试函数,否则测试函数不会运行。
默认的TestMain函数如下:
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
编写自定义的TestMain函数,在测试函数执行前执行后做一些其它逻辑。
func TestMain(m *testing.M) {
setupMySQL()
code := m.Run()
teardownMySQL()
os.Exit(code)
}