[golang] 测试
[golang] 测试
测试文件以 *_test.go
结尾,和被测文件放在一起。
.
├── adder
│ ├── adder.go
│ └── adder_test.go
其中 adder.go 的内容为:
// adder.go
package adder
func addNum(x, y int) int {
return x + x
}
adder_test.go 的内容为:
// adder_test.go
package adder
import "testing"
func Test_addNum(t *testing.T) {
result := addNum(1, 2)
if result != 3 {
t.Error("expect 3,got ", result)
}
}
可以将测试脚本添加到 makefile 中以方便执行:
// makefile
test:
go test ./...
.PHONY:test
运行上面的测试:
$ make test
...
--- FAIL: Test_addNum (0.00s)
adder_test.go:8: expect 3,got 1
FAIL
FAIL example.com/hello/adder 0.308s
FAIL
make: *** [test] Error 1
通过结果发现不符合预期,修正 addNum
再次测试:
func addNum(x, y int) int {
- return x + y
+ return x * y
}
运行结果:
$ make test
ok example.com/hello/adder
测试函数的名称
关于测试名,如上,一般以 Test*
开头,然后取个能够表示测试功能的名字即可,比如 TestDbConnection
;如果是函数的单测,直接 Test
加函数名,对于未导出的函数,用下划线连接函数名。
测试命令
go test
会运行当前目录下的测试,go test ./...
则运行所有测试,其中 ./...
表示所有 target,如果用过 bazel 肯定不会陌生。
打印错误
除上前面使用过的 t.Error
,*testing.T
上还有其他很多方法可用来打印信息。
类似于 fmt.Printf
的 t.Errorf
可对字符串进行参数格式化:
t.Errorf("expect %d, got %d", 3, result)
Error
和 Errorf
只是打印错误,测试仍然正常执行。如果想要失败时立即停止执行,可换用与之对应的 Fatal
和 Fatalf
。但只是停止当前测试中后续逻辑的执行,其他测试用例仍然正常执行不受影响。
前置及收尾操作
通常情况下,需要为测试准备一些数据,设置一些变量,同时在测试结束收进行一些清理工作,这样的操作可放在 TestMain
函数中。
var testTime time.Time
func TestMain(m *testing.M) {
fmt.Println("setup stuff for tests...")
testTime = time.Now()
exitVal := m.Run() // 执行测试用例
fmt.Println("clear stuff after tests...")
os.Exit(exitVal)
}
func Test1(t *testing.T) {
fmt.Println("Test1 use stuff setup in TestMain:", testTime)
}
func Test2(t *testing.T) {
fmt.Println("Test2 use stuff setup in TestMain:", testTime)
}
当 TestMain
函数存在时,运行测试会调用该函数而非直接运行各个测试函数。其中 m.Run()
负责调用实际的用例。
进入包目录运行 go test
,以下是运行结果:
$ go test
setup stuff for tests...
Test1 use stuff setup in TestMain: 2021-07-30 14:59:23.978121 +0800 CST m=+0.000281767
Test2 use stuff setup in TestMain: 2021-07-30 14:59:23.978121 +0800 CST m=+0.000281767
PASS
clear stuff after tests...
ok example.com/hello/adder 0.096s
注意对于单个包只能有一个 TestMain
函数。
*testing
上还有个 Cleanup
方法可用于收尾清理工作,会在每个刚测试用例完成时执行,作用与 defer
类似。
func createFile(t *testing.T) (string, error) {
f, err := os.Create("tmp")
if err != nil {
return "", err
}
t.Cleanup(func() {
os.Remove(f.Name())
})
return f.Name(), nil
}
func TestFileProcessing(t *testing.T) {
fName, err := createFile(t)
if err != nil {
t.Fatal(err)
}
// 后续的测试逻辑,无需再关心文件清理的工作了
fmt.Println(fName)
}
运行:
$ go test
setup stuff for tests...
tmp
PASS
clear stuff after tests...
ok example.com/hello/adder 0.444s
测试数据
如果测试过程涉及临时数据,比如文件读写,可以包内创建 testdata
的目录用以存放对应数据。
以下是一个示例:
// text.go
package text
import (
"io/ioutil"
"unicode/utf8"
)
func CountCharacters(fileName string) (int, error) {
data, err := ioutil.ReadFile(fileName)
if err != nil {
return 0, err
}
return utf8.RuneCount(data), nil
}
// text_test.go
package text
import "testing"
func TestCountCharacters(t *testing.T) {
total, err := CountCharacters("testdata/sample1.txt")
if err != nil {
t.Error("Unexpected error:", err)
}
if total != 35 {
t.Error("Expected 35, got", total)
}
_, err = CountCharacters("testdata/no_file.txt")
if err == nil {
t.Error("Expected an error")
}
}
测试结果的缓存
类似编译结果会被缓存,测试通过的用例其结果也会缓存,除非代码有变动才会重跑。可在运行 go test
时添加 -count=1
参数来忽略缓存。
Table Test
考察如下代码:
func DoMath(num1, num2 int, op string) (int, error) {
switch op {
case "+":
return num1 + num2, nil
case "-":
return num1 - num2, nil
case "*":
return num1 * num2, nil
case "/":
if num2 == 0 {
return 0, errors.New("division by zero")
}
return num1 / num2, nil
default:
return 0, fmt.Errorf("unknown operator %s", op)
}
}
如果上测试上面的函数,需要涵盖其中的每个分支,每个用例中都会包含变量初化,返回值检查等冗余逻辑。
此时可声明一个结构体包含每个用例的名称,测试时需要的数据和其他信息,在一个循环中进行测试以减少冗余代码。
func TestDoMath(t *testing.T) {
data := []struct {
name string
num1 int
num2 int
op string
expected int
errMsg string
}{
{"addition", 2, 2, "+", 4, ""},
{"subtraction", 2, 2, "-", 0, ""},
{"multiplication", 2, 2, "*", 4, ""},
{"division", 2, 2, "/", 1, ""},
{"bad_division", 2, 0, "/", 0, "division by zero"},
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
result, err := DoMath(d.num1, d.num2, d.op)
if result != d.expected {
t.Errorf("expected %d, got %d", d.expected, result)
}
var errMsg string
if err != nil {
errMsg = err.Error()
}
if errMsg != d.errMsg {
t.Errorf("expected err msg %s, got %s", d.errMsg, errMsg)
}
})
}
}
循环体中通过调用 t.Run()
执行的测试。通过 go test -v
可看到测试详情,包含上面指定的用例名称:
$ go test -v
=== RUN TestDoMath
=== RUN TestDoMath/addition
=== RUN TestDoMath/subtraction
=== RUN TestDoMath/multiplication
=== RUN TestDoMath/division
=== RUN TestDoMath/bad_division
--- PASS: TestDoMath (0.00s)
--- PASS: TestDoMath/addition (0.00s)
--- PASS: TestDoMath/subtraction (0.00s)
--- PASS: TestDoMath/multiplication (0.00s)
--- PASS: TestDoMath/division (0.00s)
--- PASS: TestDoMath/bad_division (0.00s)
PASS
ok example.com/hello/do_math 0.090s
代码覆盖率
覆盖率反映用例对代码的覆盖情况,可作为测试编写是否全面的参考,但 100% 的覆盖率不代码代码就没 bug,会有其他输入输出未体现在用例中但被覆盖的情况。
通过 -cover
开启覆盖率的计算,-coverprofile
将结果输出到文件。
$ go test -cover -coverprofile c.out
go tool 还提供了将结果输出成 html 形式的功能:
$ go tool cover -html=c.out
html 文件中可直观看出哪些代码是未覆盖的:
通过上面的输出可看到我们未处理 default
分支,修正我们的代码添加一种求知的操作类型:
{"bad_op", 2, 2, "?", 0, "unknown operator ?"},
重新运行测试后查看覆盖率,此时已经完全覆盖到了。
$ go test -cover -coverprofile=c.out
PASS
coverage: 100.0% of statements
ok example.com/hello/do_math 0.308s
Benchmark
基准测试用于衡量程序的性能。请看以下计算计算文件中字符数的函数:
func Filelen(f string, bufsize int) (int, error) {
file, err := os.Open(f)
if err != nil {
return 0, err
}
defer file.Close()
count := 0
for {
buf := make([]byte, bufsize)
num, err := file.Read(buf)
count += num
if err != nil {
break
}
}
return count, nil
}
进行基准测试前需要确定功能符合预期,所以先编写一个用例测试功能:
func TestFilelen(t *testing.T) {
result, err := Filelen("testdata/data.txt", 1)
if err != nil {
t.Fatal(err)
}
if result != 38 {
t.Error("expected 38, got ", result)
}
}
基准测试也是放在测试文件中的,区别与功能测试,它以 Benchmark
开头:
var blackhole int
func BenchmarkFilelen(b *testing.B) {
for i := 0; i < b.N; i++ {
result, err := Filelen("testdata/data.txt", 1)
if err != nil {
b.Fatal(err)
}
blackhole = result
}
}
任何基准测试都包含一个从 0 到 b.N
次的循环,循环体中进行执行被测试的对象。N 动态调整直到得到一可准确结果为止。
go test -bench=<regexp>
指定正则以匹配需要执行的基准测试,或 go test -bench=.
执行所有。-benchmem
则会在结果中包含内存分配信息。下面运行以上准备好的基准测试:
$ go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: example.com/hello/benchmark
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkFilelen-12 27146 47007 ns/op 167 B/op 42 allocs/op
PASS
ok example.com/hello/benchmark 2.029s
带内存信息的结果中包含 5 列,它们分别是:
BenchmarkFilelen-12
本次基准测试的名称27146
运行数次47007 ns/op
完成单次测试需要的时间,单位为纳秒(1/1,000,000,000s)167 B/op
单次测试中分配的字节数42 allocs/op
字节分配次数
使用 table test,这里控制下入参,进行批量测试观测结果:
func BenchmarkFilelen(b *testing.B) {
for _, v := range []int{1, 10, 100, 1000, 10000, 100000} {
b.Run(fmt.Sprintf("Filelen-%d", v), func(b *testing.B) {
for i := 0; i < b.N; i++ {
result, err := Filelen("testdata/data.txt", v)
if err != nil {
b.Fatal(err)
}
blackhole = result
}
})
}
}
运行结果:
$ go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: example.com/hello/benchmark
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkFilelen/Filelen-1-12 27306 43287 ns/op 167 B/op 42 allocs/op
BenchmarkFilelen/Filelen-10-12 71208 17001 ns/op 208 B/op 8 allocs/op
BenchmarkFilelen/Filelen-100-12 70042 15079 ns/op 352 B/op 5 allocs/op
BenchmarkFilelen/Filelen-1000-12 77505 19034 ns/op 2176 B/op 5 allocs/op
BenchmarkFilelen/Filelen-10000-12 59594 18285 ns/op 20608 B/op 5 allocs/op
BenchmarkFilelen/Filelen-100000-12 32460 51944 ns/op 213120 B/op 5 allocs/op
PASS
ok example.com/hello/benchmark 9.596s
可以看出,当 buffer 增大时,内存分配次数减少,性能有所提升,直到 buffer 大于文件大小,内存分配的损耗开始增加,当前文件大小下,buffer 设置为 100 是最优的。
Stubs
以上测试未涉及外部依赖,但真实场景下,会依赖很多接口。所以在测试时,需要 Mock 这些依赖,此时即可为这些依赖编写 Stub。
考察如下的结构体,其依赖一个 Entities
接口。
type Pet struct {
Name string
}
type Entities interface {
GetPets(userId string) ([]Pet, error)
// ... 接口中其他方法
}
type Logic struct {
Entities Entities
}
func (l Logic) GetPetNames(userId string) ([]string, error) {
pets, err := l.Entities.GetPets(userId)
if err != nil {
return nil, err
}
out := make([]string, len(pets))
for _, pet := range pets {
out = append(out, pet.Name)
}
return out, nil
}
Entities
这个接口上可能有很多方法,但此结构体中只用到了 GetPets
这一个方法。为了测试 Logic
的 GetPetNames
,我们需要先实现 Entities
接口,但不必完整实现,只实现用到的那个方法即可。方法是将接口放到结构体中:
type GetPetNamesStub struct {
Entities
}
func (ps GetPetNamesStub) GetPets(userId string) ([]Pet, error) {
switch userId {
case "1":
return []Pet{{Name: "Pet Foo"}}, nil
case "2":
return []Pet{{Name: "Pet Bar"}, {Name: "Pet Blah"}}, nil
default:
return nil, fmt.Errorf("invalid userid :%s", userId)
}
}
func TestLogic_GetPetNames(t *testing.T) {
data := []struct {
name string
userId string
petNames []string
}{
{"case1", "1", []string{"Pet Foo"}},
{"case2", "2", []string{"Pet Bar", "Pet Blah"}},
{"case3", "3", nil},
}
l := Logic{GetPetNamesStub{}}
for _, d := range data {
petNames, err := l.GetPetNames(d.userId)
if err != nil {
t.Error(err)
}
if diff := cmp.Diff(d.petNames, petNames); diff != "" {
t.Error(diff)
}
}
}
上面的方法只适用于单个或小范围测试中,如果存在大量测试用例都需要使用该 Stub,不同用例中输入输出都不一样,这样的话,要么在 Stub 中将所有情形包含,要么各个用例自己实现 Stub,不管哪种都不太好维护。
这种情况下,可以构造这么一个 Stub 结构体,它包含的方法字段与接口所需方法一一对应,在进行方法调时,用代理到结构体的方法字段上,这样每个用例在使用时,提供不同的方法实现即可。
展开来讲。
还是上面的例子,假设接口包含这么三个方法,我们构造如下结构体:
type EntitiesStub struct {
getUser func(id string) (User, error)
getPets func(userId string) ([]Pet, error)
saveUser func(user User) error
}
然后为结构体定义方法,与接口方法一一对应:
func (es EntitiesStub) GetUser(id string) (User, error) {
return es.getUser(id)
}
func (es EntitiesStub) GetPets(userId string) ([]Pet, error) {
return es.getPets(userId)
}
func (es EntitiesStub) SaveUser(user User) error {
return es.saveUser(user)
}
这样,不同用例在使用时,只需要提供不同实现即可,然后我们可以很方便地进行 Table Test:
data := []struct {
name string
userId string
petNames []string
getPets func(userId string) ([]Pet, error)
errMsg string
}{
{"case1", "1", []string{"Pet Foo"}, func(userId string) ([]Pet, error) {
return []Pet, nil
}, ""},
{"case2", "3", []string{"Pet Foo"}, func(userId string) ([]Pet, error) {
return nil, errors.New("invalid id: 3")
}, "invalid id: 3"},
}
l := Logic{}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
l.Entities = EntitiesStub{getPets: d.getPets}
petNames, err := l.GetPetNames(d.userId)
if diff := cmp.Diff(d.petNames, petNames); diff != "" {
t.Error(diff)
}
var errMsg string
if err != nil {
errMsg = err.Error()
}
if errMsg != d.errMsg {
t.Error("expected error %s, got %s", d.errMsg, errMsg)
}
})
}
}
网络测试
真实场景涉及网络请求会比较常见。通过 Go 提供的 net/http/httptest
这些包可完成网络的测试。
下面看个示例,
type RemoteSolver struct {
MathServerURL string
Client *http.Client
}
func (rs RemoteSolver) Resolve(ctx context.Context,
expression string)(float64,error) {
req,err:=http.NewRequestWithContext(ctx,http.MethodGet,
rs.MathServerURL+"?expression="+url.QueryEscape(expression),nil)
if err!=nil{
return 0,err
}
resp,err:=rs.Client.Do(req)
if err!=nil{
return 0,err
}
defer resp.Body.Close()
contents,err:=ioutil.ReadAll(resp.Body)
if err!=nil{
return 0,nil
}
if resp.StatusCode!=http.StatusOK{
return 0,errors.New(string(contents))
}
result,err:=strconv.ParseFloat(string(contents),64)
if err!=nil{
return 0,err
}
return result,nil
}
下面通过 httptest
来测试上面的方法,而不用真实请求到服务器。
首先定义一个结构体保存请求的入参和结果:
type info struct {
expression string
code int
body string
}
var io info
接下来设置 mock server 接收请求:
func TestRemoteSolver_Resolve(t *testing.T) {
var io info
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
expression := req.URL.Query().Get("expression")
if expression != io.expression {
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte("invalid expressoin:" + io.expression))
return
}
rw.WriteHeader(io.code)
rw.Write([]byte(io.body))
}))
defer server.Close()
rs := RemoteSolver{
MathServerURL: server.URL,
Client: server.Client(),
}
data := []struct {
name string
io info
result float64
}{
{
"case1", info{
"2+2=10",
http.StatusOK,
"22",
},
22,
},
//... 其他用例
}
for _, d := range data {
t.Run(d.name, func(t *testing.T) {
io = d.io
result, err := rs.Resolve(context.Background(), d.io.expression)
if result != d.result {
t.Errorf("io %f, got %f", d.result, result)
}
})
}
}
以上。