Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[golang] 测试 #301

Open
wayou opened this issue Aug 8, 2021 · 0 comments
Open

[golang] 测试 #301

wayou opened this issue Aug 8, 2021 · 0 comments

Comments

@wayou
Copy link
Owner

wayou commented Aug 8, 2021

[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 接口,但不必完整实现,只实现用到的那个方法即可。方法是将接口放到结构体中:

{% raw %}

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)
		}
	}
}

{% endraw %}

上面的方法只适用于单个或小范围测试中,如果存在大量测试用例都需要使用该 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{{Name: "Pet Foo"}}, 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)
			}
		})
	}
}

以上。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant