[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.Printft.Errorf 可对字符串进行参数格式化:

t.Errorf("expect %d, got %d", 3, result)

ErrorErrorf 只是打印错误,测试仍然正常执行。如果想要失败时立即停止执行,可换用与之对应的 FatalFatalf。但只是停止当前测试中后续逻辑的执行,其他测试用例仍然正常执行不受影响。

前置及收尾操作

通常情况下,需要为测试准备一些数据,设置一些变量,同时在测试结束收进行一些清理工作,这样的操作可放在 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 文件中可直观看出哪些代码是未覆盖的:

image

通过上面的输出可看到我们未处理 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 这一个方法。为了测试 LogicGetPetNames ,我们需要先实现 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)
			}
		})
	}
}

以上。