3

Go测试总结

 2 years ago
source link: https://duyanghao.github.io/go-testing/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Table of Contents

本文总结日常开发中基础的Go测试知识,以便可以更加快速和高效进行Go测试用例编写

testing 为 Go 语言 package 提供自动化测试的支持,通过 go test 命令,能够自动执行如下形式的任何函数:

func TestXxx(*testing.T)

Xxx 可以是任何字母数字字符串,但是第一个字母不能是小写字母

在这些函数中,使用 ErrorFail 或相关方法来发出失败信号

要编写一个新的测试文件,需要创建一个名称以 _test.go 结尾的文件,该文件包含 TestXxx 函数,如上所述。 将该文件放在与被测试文件相同的包中。该文件将被排除在正常的程序包之外,但在运行 go test 命令时将被包含

通常功能函数和测试函数是一对一的关系,如下:

示例代码:

// fib.go
func Fib(n int) int {
        if n < 2 {
                return n
        }
        return Fib(n-1) + Fib(n-1)
}

测试代码:

// fib_test.go
func TestFib(t *testing.T) {
        var (
                in       = 7
                expected = 13
        )
        actual := Fib(in)
        if actual != expected {
                t.Fatalf("Fib(%d) = %d; expected %d", in, actual, expected)
        }
}

执行go test .显示失败,输出:

$ go test .
--- FAIL: TestFib (0.00s)
    fib_test.go:15: Fib(7) = 64; expected 13
FAIL
FAIL    _/root/test     0.002s
FAIL

这里再添加一个测试用例:

func TestFib2(t *testing.T) {
        // ...
}

执行go test -v,结果如下:

$ go test -run TestFib -v -cover
=== RUN   TestFib
    fib_test.go:15: Fib(7) = 64; expected 13
--- FAIL: TestFib (0.00s)
=== RUN   TestFib2
--- PASS: TestFib2 (0.00s)
FAIL
coverage: 100.0% of statements
exit status 1
FAIL    _/root/test     0.002s

通过上述例子可以总结如下:

  • 运行 go test,该 package 下所有的测试用例都会被执行
  • go test -v-v 参数会显示每个用例的测试结果,另外 -cover 参数可以查看覆盖率
  • 如果只想运行其中的一个用例,例如 TestFib,可以用 -run 参数指定,该参数支持通配符 *和部分正则表达式,例如 ^$

table driven tests

对于一些测试类型相同,测试目的相同的样例可以以表格的形式集中在一起进行测试,这样代码会更加精巧,不会显得那么重复和多余,也即table driven tests:

示例代码:

package split

import (
        "strings"
)

// Split slices s into all substrings separated by sep and
// returns a slice of the substrings between those separators.
func Split(s, sep string) []string {
        var result []string
        i := strings.Index(s, sep)
        for i > -1 {
                result = append(result, s[:i])
                s = s[i+len(sep):]
                i = strings.Index(s, sep)
        }
        return append(result, s)
}

测试代码:

func TestSplit(t *testing.T) {
        tests := []struct {
                input string
                sep   string
                want  []string
        }{
                {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
                {input: "a/b/c", sep: ",", want: []string{"a/b"}},
                {input: "abc", sep: "/", want: []string{"ab"}},
        }

        for _, tc := range tests {
                got := Split(tc.input, tc.sep)
                if !reflect.DeepEqual(tc.want, got) {
                        t.Fatalf("expected: %v, got: %v", tc.want, got)
                }
        }
}

运行如下:

$ go test -run=TestSplit -v
=== RUN   TestSplit
    split_test.go:45: expected: [a/b], got: [a/b/c]
--- FAIL: TestSplit (0.00s)
FAIL
exit status 1
FAIL    _/root/test/split       0.002s

如果换成t.Errorf,则运行结果如下:

$ go test -run=TestSplit -v
=== RUN   TestSplit
    split_test.go:45: expected: [a/b], got: [a/b/c]
    split_test.go:45: expected: [ab], got: [abc]
--- FAIL: TestSplit (0.00s)
FAIL
exit status 1
FAIL    _/root/test/split       0.002s

t.Errorf遇错不停,还会继续执行其他的测试用例;而t.Fatalf遇错即停

子测试(Subtests)

在上述table driven test示例中,我们利用表格将多个测试用例集成在一个测试函数中,这样虽然可以解决因为功能类似导致代码重复的问题,但是如果测试出现问题则只能根据日志查看,可读性稍微差点;另外,如果使用了t.Fatalf,这样其中任意一个测试失败,则会终止整个函数执行,最终无法判断剩余用例的正确性

因此我们可以在上述基础上添加子测试。子测试是 Go 语言内置支持的,可以在某个测试用例中,根据测试场景使用 t.Run创建不同的子测试用例,示例如下:

func TestSplit(t *testing.T) {
        tests := map[string]struct {
                input string
                sep   string
                want  []string
        }{
                "simple":       {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
                "trailing sep": {input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}},
                "wrong sep":    {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
                "no sep":       {input: "abc", sep: "/", want: []string{"abc"}},
        }

        for name, tc := range tests {
                t.Run(name, func(t *testing.T) {
                        got := Split(tc.input, tc.sep)
                        if !reflect.DeepEqual(tc.want, got) {
                                t.Fatalf("expected: %v, got: %v", tc.want, got)
                        }
                })
        }
}

运行如下:

$ go test -run=TestSplit -v
=== RUN   TestSplit
=== RUN   TestSplit/simple
=== RUN   TestSplit/trailing_sep
    split_test.go:25: expected: [a b c], got: [a b c ]
=== RUN   TestSplit/wrong_sep
=== RUN   TestSplit/no_sep
--- FAIL: TestSplit (0.00s)
    --- PASS: TestSplit/simple (0.00s)
    --- FAIL: TestSplit/trailing_sep (0.00s)
    --- PASS: TestSplit/wrong_sep (0.00s)
    --- PASS: TestSplit/no_sep (0.00s)
FAIL
exit status 1
FAIL    _/root/test/split       0.002s

可以看到当trailing_sep子测试失败后,其它测试依旧可以正常完成,而且每个子测试有对应相关信息输出

而关于子测试的好处可以总结如下:

  • 新增用例非常简单,只需给 cases 新增一条测试数据即可
  • 测试代码可读性好,可以直观看到每个子测试的参数和期待的返回值
  • 用例失败时,报错信息的格式比较统一,测试报告易于阅读

对一些重复的逻辑,抽取出来作为公共的帮助函数(helpers),可以增加测试代码的可读性和可维护性。 借助帮助函数,可以让测试用例的主逻辑看起来更清晰。例如,我们可以将创建子测试功能的逻辑抽取出来:

package mul

import "testing"

type calcCase struct{ A, B, Expected int }

func createMulTestCase(t *testing.T, c *calcCase) {
        // t.Helper()
        if ans := Mul(c.A, c.B); ans != c.Expected {
                t.Fatalf("%d * %d expected %d, but %d got",
                        c.A, c.B, c.Expected, ans)
        }

}

func TestMul(t *testing.T) {
        createMulTestCase(t, &calcCase{2, 3, 6})
        createMulTestCase(t, &calcCase{2, -3, -6})
        createMulTestCase(t, &calcCase{2, 0, 1}) // wrong case
}

运行如下:

$ go test -v
=== RUN   TestMul
    calc_test.go:10: 2 * 0 expected 1, but 0 got
--- FAIL: TestMul (0.00s)
FAIL
exit status 1
FAIL    _/root/test/mul 0.002s

可以看到,错误发生在第11行,也就是帮助函数 createMulTestCase 内部。17, 18, 19行都调用了该方法,我们第一时间并不能够确定是哪一行发生了错误。有些帮助函数还可能在不同的函数中被调用,报错信息都在同一处,不方便问题定位。因此,Go 语言在 1.9 版本中引入了 t.Helper(),用于标注该函数是帮助函数,报错时将输出帮助函数调用者的信息,而不是帮助函数的内部信息

修改 createMulTestCase,调用 t.Helper(),测试如下:

$ go test -v
=== RUN   TestMul
    calc_test.go:19: 2 * 0 expected 1, but 0 got
--- FAIL: TestMul (0.00s)
FAIL
exit status 1
FAIL    _/root/test/mul 0.002s

可以看到错误信息变成createMulTestCase(t, &calcCase{2, 0, 1})这一行了

另外,如果换成子测试,则运行结果又会不一样,如下:

package mul

import "testing"

type calcCase struct {
        Name     string
        A        int
        B        int
        Expected int
}

func createMulTestCase(t *testing.T, c *calcCase) {
        t.Helper()
        t.Run(c.Name, func(t *testing.T) {
                if ans := Mul(c.A, c.B); ans != c.Expected {
                        t.Fatalf("%d * %d expected %d, but %d got",
                                c.A, c.B, c.Expected, ans)
                }
        })
}

func TestMul(t *testing.T) {
        createMulTestCase(t, &calcCase{"subtest#1", 2, 3, 6})
        createMulTestCase(t, &calcCase{"subtest#2", 2, -3, -6})
        createMulTestCase(t, &calcCase{"subtest#3", 2, 0, 1}) // wrong case
}

测试如下:

$ go test -v
=== RUN   TestMul
=== RUN   TestMul/subtest#1
=== RUN   TestMul/subtest#2
=== RUN   TestMul/subtest#3
    calc_test.go:16: 2 * 0 expected 1, but 0 got
--- FAIL: TestMul (0.00s)
    --- PASS: TestMul/subtest#1 (0.00s)
    --- PASS: TestMul/subtest#2 (0.00s)
    --- FAIL: TestMul/subtest#3 (0.00s)
FAIL
exit status 1
FAIL    _/root/test/mul 0.002s

可以看到错误行又变成createMulTestCase函数内部了,而不是帮助函数调用处了。这里其实也比较好理解,因为子测试已经有标识了(TestMul/subtest#3),那么就可以很容易定位到是哪里调用帮助函数了(createMulTestCase(t, &calcCase{“subtest#3”, 2, 0, 1}))

另外,这里给出关于helper函数的2个建议:

  • 不要返回错误,帮助函数内部直接使用t.Errort.Fatal即可;在用例主逻辑中不应该出现太多的错误处理代码,影响可读性
  • 调用t.Helper()让报错信息更准确,有助于定位

假设需要测试某个 API 接口的 handler 能够正常工作,例如 helloHandler:

// conn.go
func helloHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("hello world"))
}

可以创建真实的网络连接进行测试:

// conn_test.go
package conn

import (
        "io/ioutil"
        "net"
        "net/http"
        "testing"
)

func handleError(t *testing.T, err error) {
        t.Helper()
        if err != nil {
                t.Fatal("failed", err)
        }
}

func TestConn(t *testing.T) {
        ln, err := net.Listen("tcp", "127.0.0.1:80")
        handleError(t, err)
        defer ln.Close()

        http.HandleFunc("/hello", helloHandler)
        go http.Serve(ln, nil)

        resp, err := http.Get("http://" + ln.Addr().String() + "/hello")
        handleError(t, err)

        defer resp.Body.Close()
        body, err := ioutil.ReadAll(resp.Body)
        handleError(t, err)

        if string(body) != "hello world" {
                t.Fatal("expected hello world, but got", string(body))
        }
}

运行如下:

$ go test -v
=== RUN   TestConn
--- PASS: TestConn (0.00s)
PASS
ok      _/root/test/conn        0.004s
  • net.Listen("tcp", "127.0.0.1:80"):监听一个未被占用的端口,并返回 Listener
  • 调用 http.Serve(ln, nil) 启动 http 服务
  • 使用 http.Get 发起一个 Get 请求,检查返回值是否正确
  • 尽量不对 httpnet 库使用 mock,这样可以覆盖较为真实的场景

而针对 http 开发的场景,使用标准库 net/http/httptest 进行测试则更为高效,上述测试用例可以改写如下:

package conn

import (
        "io/ioutil"
        "net/http/httptest"
        "testing"
)

func TestConn(t *testing.T) {
        req := httptest.NewRequest("GET", "http://example.com/foo", nil)
        w := httptest.NewRecorder()
        helloHandler(w, req)
        bytes, _ := ioutil.ReadAll(w.Result().Body)

        if string(bytes) != "hello world" {
                t.Fatal("expected hello world, but got", string(bytes))
        }
}

运行如下:

$ go test -v
=== RUN   TestConn
--- PASS: TestConn (0.00s)
PASS
ok      _/root/test/conn        0.003s

这里使用 httptest 模拟请求对象(req)和响应对象(w),达到了相同的目的,而且不需要专门写defer释放相关资源

基准测试是测量一个程序在固定工作负载下的性能。在Go语言中,基准测试函数和单元测试函数写法类似,但是以Benchmark为前缀名,并且带有一个*testing.B类型的参数;*testing.B参数除了提供和*testing.T类似的方法,还有额外一些和性能测量相关的方法。它还提供了一个整数N,用于指定操作执行的循环次数:

func BenchmarkFib10(b *testing.B) {
        for n := 0; n < b.N; n++ {
                Fib(10)
        }
}

基准函数会运行目标代码 b.N 次。在基准执行期间,程序会自动调整 b.N 直到基准测试函数持续足够长的时间。执行如下:

$ go test -bench=Fib10 -benchmem
goos: linux
goarch: amd64
BenchmarkFib10-16         496747              2395 ns/op               0 B/op          0 allocs/op
PASS
ok      _/root/test     1.219s
  • BenchmarkFib10-16:16,表示运行时对应的GOMAXPROCS的值,这对于一些与并发相关的基准测试是重要的信息
  • 496747 :基准测试的迭代总次数 b.N
  • 2395 ns/op:平均每次迭代所消耗的纳秒数
  • 0 B/op:平均每次迭代内存所分配的字节数
  • 0 allocs/op:平均每次迭代的内存分配次数

由于这里Fib没有使用内存(除了函数栈桢以外),所以这里关于内存的两个指标都为0

比较型基准测试

比较型的基准测试通常是单参数的函数,由几个不同数量级的基准测试函数调用,例如:

func benchmarkFib(b *testing.B, size int) {
        for n := 0; n < b.N; n++ {
                Fib(size)
        }
}

func BenchmarkFib1(b *testing.B)  { benchmarkFib(b, 1) }
func BenchmarkFib10(b *testing.B) { benchmarkFib(b, 10) }
func BenchmarkFib20(b *testing.B) { benchmarkFib(b, 20) }

比较型的基准测试反映出的模式在程序设计阶段是很有帮助的,它可以用来比较不同数量级下的基准测试数据:

$ go test -bench=. -benchmem
goos: linux
goarch: amd64
BenchmarkFib1-16        478089406                2.51 ns/op            0 B/op          0 allocs/op
BenchmarkFib10-16         500757              2394 ns/op               0 B/op          0 allocs/op
BenchmarkFib20-16            484           2458204 ns/op               0 B/op          0 allocs/op
PASS
ok      _/root/test     4.123s

默认情况下,每个基准测试最少运行 1 秒。如果基准测试函数返回时,还不到 1 秒钟,b.N 的值会按照序列 1,2,5,10,20,50,… 增加,同时再次运行基准测试函数

并发基准测试

可以使用 RunParallel 测试并发基准性能,如下:

func BenchmarkParallel(b *testing.B) {
        templ := template.Must(template.New("test").Parse("Hello, !"))
        b.RunParallel(func(pb *testing.PB) {
                var buf bytes.Buffer
                for pb.Next() {
                        // 所有 goroutine 一起,循环一共执行 b.N 次
                        buf.Reset()
                        templ.Execute(&buf, "World")
                }
        })
}

运行如下:

$ go test -benchmem -bench=BenchmarkParallel .
goos: linux
goarch: amd64
BenchmarkParallel-16            21238836                56.9 ns/op            48 B/op          1 allocs/op
PASS
ok      _/root/test     2.251s

GoMock

当待测试的函数/对象的依赖关系很复杂,并且有些依赖不能直接创建,例如数据库连接、文件I/O等。这种场景就非常适合使用 mock/stub 测试。简单来说,就是用 mock 对象模拟依赖项的行为

常用的Go mock/stub框架主要有:

  • GoStub:支持全局变量,函数,过程打桩;但是不支持为接口以及方法打桩
  • Monkey:支持函数,过程,方法打桩
  • GoMock:支持接口打桩

日常工作中会结合使用上述工具,本文主要介绍如何使用GoMock测试框架:

GoMock是由Go官方开发维护的测试框架,实现了较为完整的基于interface的Mock功能,能够与Go内置的testing包良好集成,也能用于其它的测试环境中。GoMock测试框架包含了GoMock包和mockgen工具两部分,其中GoMock包完成对Mock对象生命周期的管理,mockgen工具用来生成interface对应的Mock类源文件。使用如下命令即可安装:

$ go get -u github.com/golang/mock/gomock
$ go get -u github.com/golang/mock/mockgen

文档如下:

Standard usage:

    (1) Define an interface that you wish to mock.
          type MyInterface interface {
            SomeMethod(x int64, y string)
          }
    (2) Use mockgen to generate a mock from the interface.
    (3) Use the mock in a test:
          func TestMyThing(t *testing.T) {
            mockCtrl := gomock.NewController(t)
            defer mockCtrl.Finish()

            mockObj := something.NewMockMyInterface(mockCtrl)
            mockObj.EXPECT().SomeMethod(4, "blah")
            // pass mockObj to a real object and play with it.
          }

这里以一个实际例子来说明上述GoMock使用步骤:

step1 - 构建接口

整个目录结构如下:

server/
|-- server.go
|-- server_test.go
db/
|-- db.go
|-- db_mock.go

编写db源文件如下,其中包含MyDB接口和User结构体:

// db.go
package db

type User struct {
        ID   string `json:"id"`
        Name string `json:"name"`
        Age int `json:age`
}

type MyDB interface {
        Retrieve(key string) (*User, error)
        // TODO
}

step2 - 生成mock

通过mockgen生成mock文件,如下:

mockgen -source=./db/db.go -destination=./db/db_mock.go -package=db

db_mock.go文件内容如下:

// Code generated by MockGen. DO NOT EDIT.
// Source: db/db.go

// Package db is a generated GoMock package.
package db

import (
        gomock "github.com/golang/mock/gomock"
        reflect "reflect"
)

// MockMyDB is a mock of MyDB interface
type MockMyDB struct {
        ctrl     *gomock.Controller
        recorder *MockMyDBMockRecorder
}

// MockMyDBMockRecorder is the mock recorder for MockMyDB
type MockMyDBMockRecorder struct {
        mock *MockMyDB
}

// NewMockMyDB creates a new mock instance
func NewMockMyDB(ctrl *gomock.Controller) *MockMyDB {
        mock := &MockMyDB{ctrl: ctrl}
        mock.recorder = &MockMyDBMockRecorder{mock}
        return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockMyDB) EXPECT() *MockMyDBMockRecorder {
        return m.recorder
}

// Retrieve mocks base method
func (m *MockMyDB) Retrieve(key string) (*User, error) {
        m.ctrl.T.Helper()
        ret := m.ctrl.Call(m, "Retrieve", key)
        ret0, _ := ret[0].(*User)
        ret1, _ := ret[1].(error)
        return ret0, ret1
}

// Retrieve indicates an expected call of Retrieve
func (mr *MockMyDBMockRecorder) Retrieve(key interface{}) *gomock.Call {
        mr.mock.ctrl.T.Helper()
        return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Retrieve", reflect.TypeOf((*MockMyDB)(nil).Retrieve), key)
}

step3 - 使用mock

假设有如下代码使用了上述db:

// server.go
package server

import (
        "db"
)

type Server struct {
        db db.MyDB
}

func (s *Server) AddUserAge(key string) (*db.User, error) {
        user, _ := s.db.Retrieve(key)
        user.Age++
        return user, nil
}

该函数逻辑很简单,就是获取用户,并对用户年龄加1

接着编写测试文件如下:

// server_test.go
package server

import (
        "db"
        "github.com/golang/mock/gomock"
        "testing"
)

func TestAddUserAge(t *testing.T) {
        ctl := gomock.NewController(t)
        defer ctl.Finish()

        mockMyDB := db.NewMockMyDB(ctl)

        mockMyDB.EXPECT().Retrieve("1").Return(&db.User{
                ID:   "1",
                Name: "duyanghao",
                Age:  27,
        }, nil)

        server := &Server{
                db: mockMyDB,
        }

        user, _ := server.AddUserAge("1")

        if user.Age != 28 {
                t.Fatal("expected age 28, but got", user.Age)
        }
}

可以看到利用GoMock模拟了MyDB接口的Retrieve方法,整个测试流程如下:

  • ctl := gomock.NewController(t)实例化mock控制器
  • db.NewMockMyDB(ctl):注入控制器创建mock对象
  • Retrieve(“1”) Mock输入参数
  • Return() 定义返回值

运行测试如下:

$ go test .
ok      _/root/test/server      0.002s

除了上述明确规定参数和返回值的基本打桩用法以外,GoMock还支持其它更加高级和灵活的打桩技巧,例如:调用次数(Times)、调用顺序(InOrder or After),动态设置返回值(DoAndReturn)等,这里不展开介绍

Conclusion

本文先概述了Go单元测试,并通过例子展开介绍了table driven tests,子测试,帮助函数以及网络测试,这些都是日常开发过程中经常会遇到的单元测试使用场景。接着介绍了测量程序在固定工作负载下性能的Go基准测试,并引入了比较型基准测试以及并发基准测试。最后介绍了Go mock/stub测试框架GoMock,并以一个例子说明了GoMock的使用流程。希望通过本文对Go测试有一个基本的了解和使用


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK