测试

测试金字塔

最下面是单元测试,单元测试对代码进行测试。再而上是集成测试,它对一个服务的接口进行测试。继而是端到端的测试,我也称他为链路测试,它负责从一个链路的入口输入测试用例,验证输出的系统的结果。再上一层是我们最常用的UI测试,就是测试人员在UI界面上根据功能进行点击测试。

单元测试

所有以_test.go为后缀名的源代码文件都是go test测试的一部分,不会被go build编译到最终的可执行文件中,xxx_test.go测试文件代码和被测试的源代码文件xxx.go放在同一个包内,一一对应。

比如项目下有某个包内有个源代码文件xxx.go有这么一个函数:

func lengthOfNonRepeatingSubStr(s string) int {
	lastOccurred := make(map[rune]int)
	start := 0
	maxLength := 0

	for i, ch := range []rune(s) {
		if lastI, ok := lastOccurred[ch]; ok && lastI >= start {
			start = lastI + 1
		}
		if i-start+1 > maxLength {
			maxLength = i - start + 1
		}
		lastOccurred[ch] = i
	}

	return maxLength
}

那么单元测试的文件xxx_test.go就放在相同的包内,测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头,需要一个*testing.T类型的参数,可以像下面这样按组测试,又称表格测试法:

func TestSubstr(t *testing.T) {
	tests := []struct {
		s   string
		ans int
	}{
		// Normal cases
		{"abcabcbb", 3},
		{"pwwkew", 3},

		// Edge cases
		{"", 0},
		{"b", 1},
		{"bbbbbbbbb", 1},
		{"abcabcabcd", 4},

		// Chinese support
		{"一二三二一", 3},
		{"黑化肥挥发发灰会花飞灰化肥挥发发黑会飞花", 8},
	}

	for _, tt := range tests {
		actual := lengthOfNonRepeatingSubStr(tt.s)
		if actual != tt.ans {
			t.Errorf("got %d for input %s; "+
				"expected %d",
				actual, tt.s, tt.ans)
		}
	}
}

执行测试程序

在项目根目录下直接执行go test .含义是:仅从当前目录下查找以_test.go为后缀的以Test开头的单元测试函数。

go test ./...含义是:找出当前的目录以及所有子目录(包括多级子目录)下所有的 xxx_test.go 测试代码。

go test ./xxx/xxx含义是:找出./xxx/xxx路径下的 xxx_test.go 测试代码,如果想执行指定目录以及其子目录的单元测试,需要指定目录为:./xxx/xxx/...,即在路径后加上/...,注意是 3 个.

默认不执行基准测试,加上-bench参数来执行,如:go test -bench=.

测试项目某个模块下的指定单元测试和指定的基准测试,如:go test -v service/*.go -bench BenchMethodName -benchmem -run TestMethodName

MethodName可以写正则表达式匹配,比如如果只想执行benchmark,可以把-run参数写成none或者^$,不存在正则匹配的单元测试也就只会执行基准测试了。

基准测试

基准测试就是在一定的工作负载之下检测程序性能的一种方法。基准测试函数以Benchmark为前缀,需要一个*testing.B类型的参数。

func BenchmarkSubstr(b *testing.B) {
	s := "黑化肥挥发发灰会花飞灰化肥挥发发黑会飞花"
	for i := 0; i < 13; i++ {
		s = s + s
	}
	b.Logf("len(s) = %d", len(s))
	ans := 8
	b.ResetTimer() // 重置时间起始计算点,上面参数的初始化不计入

	for i := 0; i < b.N; i++ { // b.N 的值是系统根据实际情况去调整的
		actual := lengthOfNonRepeatingSubStr(s)
		if actual != ans {
			b.Errorf("got %d for input %s; "+
				"expected %d",
				actual, s, ans)
		}
	}
}

可以为基准测试添加-benchmem参数,来获得内存分配的统计数据:

zhhnzw$ go test -v -bench=Substr -benchmem
=== RUN   TestSubstr
--- PASS: TestSubstr (0.00s)
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-7920HQ CPU @ 3.10GHz
BenchmarkSubstr
    nonrepeating_test.go:41: len(s) = 491520
    nonrepeating_test.go:41: len(s) = 491520
    nonrepeating_test.go:41: len(s) = 491520
BenchmarkSubstr-8            231           5152279 ns/op          655590 B/op          2 allocs/op
PASS
  • BenchmarkSubstr-8中数字8表示GOMAXPROCS的值,即并发的线程数(并发的协程比该值大)

  • 数字231的含义:是本次Benchmark循环体共执行的次数

  • 5152279 ns/op:本次Benchmark循环体每循环一次耗费5152279纳秒

  • 655590 B/op:本次Benchmark循环体每循环一次分配了655590字节

  • 2 allocs/op:本次Benchmark循环体每循环一次进行了2次内存分配

-benchtime=3s为基准测试限定执行时间。

benchstat

$ go test -v -run=^$ -bench=Substr$ -benchmem -count=10 | tee before.txt  # -count=10表示执行10次
$ go test -v -run=^$ -bench=Substr$ -benchmem -count=10 | tee after.txt
$ benchstat before.txt after.txt

如果没有benchstat就先安装:go get golang.org/x/perf/cmd/benchstat

集成测试

集成测试和单元测试不一样,它不属于某个文件,集成测试可能涉及到多个文件中多个接口的测试,所以它需要放在一个单独的文件夹,完整项目

suite和上面的assert在同一个包下:github.com/stretchr/testify

假设有如下2个函数需要编写单元测试:

func foo() {
	fmt.Println("foo...")
}

func goo() {
	fmt.Println("goo...")
}

单元测试文件代码如下:

import (
	"fmt"
	"github.com/stretchr/testify/suite"
	"testing"
)

type _Suite struct {
	suite.Suite
}

func (s *_Suite) SetupSuite() {
	fmt.Println("SetupSuite()...")
}

func (s *_Suite) SetupTest() {
	fmt.Println("SetupTest()...")
}

func (s *_Suite) BeforeTest(suiteName, testName string) {
	fmt.Printf("BeforeTest: suiteName=%s, testName=%s\n", suiteName, testName)
}

func (s *_Suite) TestFoo() {
	foo()
}

func (s *_Suite) TestGoo() {
	goo()
}

func (s *_Suite) AfterTest(suiteName, testName string) {
	fmt.Printf("AfterTest: suiteName=%s, testName=%s\n", suiteName, testName)
}

func (s *_Suite) TearDownTest() {
	fmt.Println("TearDownTest()...")
}

func (s *_Suite) TearDownSuite() {
	fmt.Println("TearDownSuite()...")
}

// 让 go test 执行测试
func TestGooFoo(t *testing.T) {
	suite.Run(t, new(_Suite))
}
$ go test -v main.go  main_test.go
=== RUN   TestGooFoo
SetupSuite()...
=== RUN   TestGooFoo/TestFoo
SetupTest()...
BeforeTest: suiteName=_Suite, testName=TestFoo
foo...
AfterTest: suiteName=_Suite, testName=TestFoo
TearDownTest()...
=== RUN   TestGooFoo/TestGoo
SetupTest()...
BeforeTest: suiteName=_Suite, testName=TestGoo
goo...
AfterTest: suiteName=_Suite, testName=TestGoo
TearDownTest()...
TearDownSuite()...
--- PASS: TestGooFoo (0.00s)
    --- PASS: TestGooFoo/TestFoo (0.00s)
    --- PASS: TestGooFoo/TestGoo (0.00s)
PASS
ok      command-line-arguments  3.063s

SetupSuite()、TearDownSuite() 仅执行一次,典型使用场景是各个单元测试共用的数据库连接

而 SetupTest()、BeforeTest()、AfterTest()、TearDownTest()对套件中的每个测试都会执行一次

测试覆盖率

  1. 取得coverprofilego test -v -coverprofile=coverage.out

  2. 查看:go tool cover -html=coverage.out

编译到服务器上执行单元测试

编译: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go test -c *.go -o testxxx

连上服务器后执行单元测试: ./testxxx -test.run TestXxx_Xxx

gomock

gomock是对interface方法功能的模拟。

通常有的功能的单元测试不好写,比如依赖了第三方服务提供的接口,但它还没实现好,此时单元测试的编写就需要mock第三方服务接口的调用。

通过mock还可以模拟异常场景。

安装

go install github.com/golang/mock/[email protected]

使用

新建person/male.go,写入如下内容:

package person

type Male interface {
	Get(id int64) error
}

新建user/user.go,写入以下内容:

package user

import "github.com/zhhnzw/go_demos/mock_test/person"

type User struct {
	Person person.Male
}

func NewUser(p person.Male) *User {
	return &User{Person: p}
}

func (u *User) GetUserInfo(id int64) error {
	return u.Person.Get(id)
}

生成mock代码: mockgen -source=./person/male.go -destination=./mock/male_mock.go -package=mock

新建user/user_test.go,写入如下内容:

package user

import (
	"errors"
	"github.com/golang/mock/gomock"
	"github.com/stretchr/testify/assert"
	"github.com/zhhnzw/go_demos/mock_test/mock"
	"testing"
	"time"
)

func TestUser_GetUserInfo(t *testing.T) {
	ctl := gomock.NewController(t)
	var id int64 = 1
	mockMale := mock.NewMockMale(ctl)     // 实现了male.go中的interface的实例
	mockMale.EXPECT().Get(id).Return(nil) // 模拟正常情况,给Get方法传了id=1,return nil。那么后面GetUserInfo对于Get的调用就是这个mock的调用情况。
	user := NewUser(mockMale)
	err := user.GetUserInfo(id)
	assert.Nil(t, err)

	id = 999
	mockMale = mock.NewMockMale(ctl)
	mockMale.EXPECT().Get(id).DoAndReturn(func(id int64) error {
		time.Sleep(3 * time.Second)
		return errors.New("timeout")
	}) // 用一个匿名函数模拟Get方法的执行,模拟经过3s时间返回了timeout的err
	user = NewUser(mockMale)
	err = user.GetUserInfo(id)
	assert.Equal(t, "timeout", err.Error())
}

文件目录:

├── mock
│   └── male_mock.go
├── person
│   └── male.go
└── user
    ├── user.go
    └── user_test.go

gomonkey

上一节介绍的gomock是需要在设计了interface的前提下的mock,但如果没有设计interface,要怎么mock呢?此时就需要用到gomonkey了。

gomonkey的实现原理是通过reflect包实现,在运行时替换指定变量、指定函数。

注:gomonkey不是线程安全的,所以不要把它用到并发的单元测试中。

安装

go get github.com/agiledragon/gomonkey/[email protected]

Mac下直接使用gomonkey会有如下这样的错误抛出:

panic: permission denied [recovered]
panic: permission denied
cd `go env GOPATH`
git clone https://github.com/eisenxp/macos-golink-wrapper.git
mv `go env GOTOOLDIR`/link `go env GOTOOLDIR`/original_link
cp `go env GOPATH`/macos-golink-wrapper/link  `go env GOTOOLDIR`/link
chmod +x `go env GOTOOLDIR`/link

注意:使用gomonkey在执行单元测试时需要禁用内联go test -gcflags=all=-l,否则mock不会成功。

典型用法

package mock_test

import (
	"fmt"
	"github.com/agiledragon/gomonkey/v2"
	"github.com/stretchr/testify/assert"
	"testing"
)

var num = 10

func networkCompute(a, b int) (int, error) {
	c := a + b
	return c, nil
}

func Compute(a, b int) (int, error) {
	sum, err := networkCompute(a, b)
	return sum, err
}

type Person struct {
	name string
}

func (p *Person) eat() {
	fmt.Sprintf("%s is eating", p.name)
}

func TestFunc(t *testing.T) {
	patches := gomonkey.ApplyGlobalVar(&num, 150)
	assert.Equal(t, num, 150)
	patches = gomonkey.ApplyFunc(networkCompute, func(a, b int) (int, error) {
		return 2, nil
	})
	defer patches.Reset()
	sum, err := Compute(1, 2)
	assert.Nil(t, err)
	assert.Equal(t, 2, sum)
}

// 把gomonkey.ApplyMethod的例子写上

Last updated

Was this helpful?