Golang 单元测试总结


目录:

Golang 单元测试总结

做为“测试驱动开发”(TDD:Test-Driven Development)的主要代表,单元测试是我们大家在程序开发过程中接触最多的一种测试方法,通常情况下我们做了一个函数的改动之后往往需要编写众多的单元测试代码来验证我们所写代码的正确性。

单元测试是用来对一个函数、一个模块或一个类进行正确性校验的测试工作,可以用来保证我们所写的代码能够按照预期的设计运行,验证代码逻辑,提升代码质量。

对于 Golang 语言来说,其自身便有功能非常强大的测试包 testing,但是对于一些复杂的测试场景来说,简单的 testing 并不能很好的满足需求。

因此,本文将介绍 Golang 单元测试中常见的一些测试框架及其使用,这些框架单元测试的各种层次各种方向上都能够帮助大家更好的对 Go 代码进行单元测试,因此建议如下的框架都能进行掌握。

本文主要介绍到的测试框架:

testing

testing 是 Go 官方的测试包,该包可以实现一些基础的单元测试功能。

使用方式也非常简单,只需要在被测试文件同级别的目录下创建一个 测试文件名_test.go 的文件,其中包含一个 Testxxx 的函数,就可以了,xxx一般是需要测试的函数名称,然后再该函数中进行测试原函数就可以了。

比如测试一个如下的代码:

package test_study
import "fmt"

func Hello() string {
    return "Hello, world"
}

func main() {
    fmt.Println(Hello())
}

创建一个对应的测试文件:

package test_study
import "testing"

func TestHello(t *testing.T) {
    got := Hello()
    want := "Hello, world"

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}

test_study 包下的目录结构为:

test_study
—–samples.go
—–samples_test.go

执行测试命令:

>go test -v
=== RUN   TestHello
--- PASS: TestHello (0.00s)
PASS

这就是一个简单的单元测试,基于此可以实现一个常见的单元测试场景,另外介绍一些 go test 常用的参数:

  • -bench regexp 执行相应的 benchmarks,例如 -bench= (基准测试)
  • -cover 查看测试覆盖率
  • -run regexp 只运行 regexp 匹配的函数,例如 -run=Array 那么就执行包含有 Array 开头的函数;
  • -v 显示测试的详细命令

testing 包还可以实现 表格驱动测试,示例:

func TestTDD(t *testing.T) {
    var tests = []struct {
        name   string
        input  string
        want   bool
    }{
        {"test.com", true},
        {"hello.com", false},
    }
    for _, test := range tests {
        t.Run(test.name, func(t *testing.T){
            if got := xxx(test.input); !reflect.DeepEqual(got, test.want) {
                t.Errorf("xxx() = %v, want %v", got, test.want)
            }
        })
    }
}

虽然 go 自带的 testing 包可以完成绝大多数的测试场景,但是对于一些复杂的函数(多种测试用例、接口调用、数据库连接等)这些函数并不能通过 testing 来完成,因此还是需要一些其他测试包的支持。

GoConvey

GoConvey是一款针对Golang的测试框架,可以管理和运行测试用例,同时提供了丰富的断言函数,并支持很多 Web 界面特性。

安装:

go get github.com/smartystreets/goconvey

例如测试一个匹配 url 的方法

func CheckUrl(url string) bool {
    var urlList = [2]string{"baidu.com", "google.com"}
    for v := range urlList {
        if urlList[v] == url {
            return true
        }
    }
    return false
}

使用 GoConvey 对其进行测试:

func TestCheckUrl(t *testing.T) {
    convey.Convey("TestCheckTeachUrl", t, func() {
        ok:=CheckUrl("baidu.com")
        convey.So(ok,convey.ShouldBeTrue)
    })
}
  • convey.Convey 定义了测试用例名称、t 指针、测试代码。
  • convey.So 用来判断预期结果。

convey 提供了大量的断言函数,比如刚才使用的 convey.ShouldBeTrue,就是判断 ok 的值应该为 true。更多方法请前往 GoConvey 官方文档查看。

GoConvey 强大的地方在于其可以管理多种测试用例,比如如下的测试:

func TestCheckUrl(t *testing.T) {
    convey.Convey("TestCheckTeachUrl true", t, func() {
        ok:=CheckUrl("baidu.com")
        convey.So(ok,convey.ShouldBeTrue)
    })

    convey.Convey("TestCheckTeachUrl false", t, func() {
        ok:=CheckUrl("xxxxxx.com")
        convey.So(ok,convey.ShouldBeFalse)
    })

    convey.Convey("TestCheckTeachUrl null", t, func() {
        ok:=CheckUrl("")
        convey.So(ok,convey.ShouldBeFalse)
    })
}

以上三个测试用例都是分开执行的,convey 是可以嵌套执行的(外层再套一个 convey,需要传 t 指针,里面的 convey 都不需要 t 指针), 这样可以更好的将测试用例组织起来。

将上面的测试用例使用嵌套的方式进行重新编写:

func TestCheckUrl(t *testing.T) {
    convey.Convey("TestCheckTeachUrl", t, func() {
        convey.Convey("TestCheckTeachUrl true",  func() {
            ok := CheckUrl("baidu.com")
            convey.So(ok, convey.ShouldBeTrue)
        })
        convey.Convey("TestCheckTeachUrl false", func() {
            ok := CheckUrl("xxxxxx.com")
            convey.So(ok, convey.ShouldBeFalse)
        })
        convey.Convey("TestCheckTeachUrl null",func() {
            ok := CheckUrl("")
            convey.So(ok, convey.ShouldBeFalse)
        })
    })
}

这样再去执行就发现测试结果就比较精简了:

go test -v -run TestCheckUrl
=== RUN   TestCheckUrl
  TestCheckTeachUrl 
    TestCheckTeachUrl true .
    TestCheckTeachUrl false .
    TestCheckTeachUrl false .
3 total assertions
--- PASS: TestCheckUrl (0.00s)

Testify

Testify 也是一个断言库,它的功能相对于 GoConvey 而言比较简单,主要是在提供断言功能之外,提供了 mock 的功能。

安装:

go get github.com/stretchr/testify

例如上面的判断url的函数使用 Testify 来进行测试的话就非常简单了:

func TestCheckUrl2(t *testing.T) {
    ok := CheckUrl("learnku.com")
    assert.True(t, ok)
}

因此在简单的单元测试场景中可以使用 Testify 来进行断言测试。

GoMock

在测试过程中经常会出现需要测试的函数依赖的关系非常复杂的情况(比如依赖网络请求、数据库连接等),这种情况下需要对其进行测试就需要使用 mock/stub 测试方式。简单来说,就是用 mock 对象模拟依赖项的行为,然后使用 stub 来模拟依赖项返回的数据。

gomock 是官方提供的 mock 框架,同时还提供了 mockgen 工具用来辅助生成测试代码。

使用如下命令即可安装:

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

例如要对如下的一个数据库连接函数进行测试:

// db.go
type DB interface {
	Get(key string) (int, error)
}

func GetFromDB(db DB, key string) int {
	if value, err := db.Get(key); err == nil {
		return value
	}

	return -1
}

假设 DB 是代码中负责与数据库交互的部分。这个时候,如果我们需要测试 GetFromDB 这个函数内部的逻辑,就需要 mock 接口 DB。

第一步:使用 mockgen 生成 db_mock.go。

一般传递三个参数:

  • source:源文件
  • destination:目标文件
  • package:包名
mockgen -source=db.go -destination=db_mock.go -package=main

第二步:新建 db_test.go,写测试用例

func TestGetFromDB(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish() // 断言 DB.Get() 方法是否被调用

	m := NewMockDB(ctrl)
	m.EXPECT().Get(gomock.Eq("Tom")).Return(100, errors.New("not exist"))

	if v := GetFromDB(m, "Tom"); v != -1 {
		t.Fatal("expected -1, but got", v)
	}
}

这个测试用例有2个目的:

  • 一是使用 ctrl.Finish() 断言 DB.Get() 被是否被调用,如果没有被调用,后续的 mock 就失去了意义;
  • 二是测试方法 GetFromDB() 的逻辑是否正确(如果 DB.Get() 返回 error,那么 GetFromDB() 返回 -1)。

NewMockDB() 的定义在 db_mock.go 中,由 mockgen 自动生成,最终的代码目录如下:

project/
    |--db.go
    |--db_mock.go // generated by mockgen
    |--db_test.go

执行单元测试:

go test . -cover -v
=== RUN   TestGetFromDB
--- PASS: TestGetFromDB (0.00s)
PASS
coverage: 81.2% of statements
ok      example 0.008s  coverage: 81.2% of statements

在上面的例子中,当 Get() 的参数为 Tom,则返回 error,这称之为打桩 (stub),有明确的参数和返回值是最简单打桩方式。除此之外,检测调用次数、调用顺序,动态设置返回值等方式也经常使用。

  • Eq(value) 表示与 value 等价的值。
  • Any() 可以用来表示任意的入参。
  • Not(value) 用来表示非 value 以外的值。
  • Nil() 表示 None 值
m.EXPECT().Get(gomock.Eq("Tom")).Return(0, errors.New("not exist"))
m.EXPECT().Get(gomock.Any()).Return(630, nil)
m.EXPECT().Get(gomock.Not("Sam")).Return(0, nil) 
m.EXPECT().Get(gomock.Nil()).Return(0, errors.New("nil"))

除了可以对输入的参数进行打桩之外,还可以指定对应的返回值:

  • Return() 返回确定的值
  • Do() Mock 方法被调用时,要执行的操作吗,忽略返回值。
  • DoAndReturn() 可以动态地控制返回值。
m.EXPECT().Get(gomock.Not("Sam")).Return(0, nil)
m.EXPECT().Get(gomock.Any()).Do(func(key string) {
    t.Log(key)
})
m.EXPECT().Get(gomock.Any()).DoAndReturn(func(key string) (int, error) {
    if key == "Sam" {
        return 630, nil
    }
    return 0, errors.New("not exist")
})

同时也支持对函数调用的次数进行打桩:

  • Times() 断言 Mock 方法被调用的次数。
  • MaxTimes() 最大次数。
  • MinTimes() 最小次数。
  • AnyTimes() 任意次数(包括 0 次)。
func TestGetFromDB(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	m := NewMockDB(ctrl)
	m.EXPECT().Get(gomock.Not("Sam")).Return(0, nil).Times(2)
	GetFromDB(m, "ABC")
	GetFromDB(m, "DEF")
}

对于多个打桩,也可以设置对应的调用方式 InOrder():

func TestGetFromDB(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish() // 断言 DB.Get() 方法是否被调用

	m := NewMockDB(ctrl)
	o1 := m.EXPECT().Get(gomock.Eq("Tom")).Return(0, errors.New("not exist"))
	o2 := m.EXPECT().Get(gomock.Eq("Sam")).Return(630, nil)
	gomock.InOrder(o1, o2)
	GetFromDB(m, "Tom")
	GetFromDB(m, "Sam")
}

对于如何编写可 mock 的代码,可以有如下的几点建议:

  • mock 作用的是接口,因此将依赖抽象为 接口,而不是直接依赖具体的类。
  • 不直接依赖的实例,而是使用 依赖注入 降低耦合性。

GoStub

除了使用gomock进行打桩之外,还可以使用一个 GoStub 的框架来实现打桩的需求。

GoStub 框架的使用场景很多,依次为:

  • 基本场景:为一个全局变量打桩
  • 基本场景:为一个函数打桩
  • 基本场景:为一个过程打桩
  • 复合场景:由任意相同或不同的基本场景组合而成

下载使用:

go get github.com/prashantv/gostub

为全局变量打桩

var str="baidu.com"
func main() {
    stubs := Stub(&str, "google")
    defer stubs.Reset()
    fmt.Println(str)
    // 可以多次打桩
    stubs.Stub(&str, "baidu")
    fmt.Println(str)
    }

//输出
//google
//baidu

stubs 是 GoStub 框架的函数接口 Stub() 返回的对象,Reset() 方法将全局变量的值恢复为原值。

不论是调用 Stub() 函数还是 StubFunc() 函数,都会生成一个 Stubs 对象,Stubs 对象仍然有 Stub 方法和 StubFunc 方法,所以在一个测试用例中可以同时对多个全局变量、函数或过程打桩。全局变量、函数或过程会将初始值存在一个 map 中,并在延迟语句中通过 Reset 方法统一做回滚处理。

为函数打桩

针对有参数,有返回值的写法,使用 Stub ()

    var printStr = func(val string) string {
        return val
    }
    stubs := Stub(&printStr, func(val string) string {
        return "hello," + val
    })
        defer stubs.Reset()
    fmt.Println("After stub:", printStr("world"))

//输出
//After stub: hello,world

针对无参数,有返回值的函数打桩,可以使用 StubFunc ()

    var printStr = func(val string) string {
        return val
    }
    // StubFunc 第一个参数必须是一个函数变量的指针,该指针指向的必须是一个函数变量,第二个参数为函数 mock 的返回值
    stubs := StubFunc(&printStr, "hello,google")
    defer stubs.Reset()
    fmt.Println("After stub:", printStr("hello,baidu"))

    //输出
    //After stub: hello,google

通过 StubFunc() 已经设置了 mock 固定返回值。

为过程打桩

没有返回值的函数称为过程

var PrintStr = printStr
var printStr = func(val string) {
    fmt.Println(val)
}

func main() {
stubs := StubFunc(&printStr)
PrintStr("baidu")
defer stubs.Reset()
}

//输出
//baidu

HttpMock

HttpMock 可以用来mock相应的http请求。

下载安装:

go get github.com/jarcoal/httpmock

在 web 项目中,大多接口是处理 http 请求(post、get 之类的),可以利用官方自带的 http 包来进行模拟请求。

假如有一个 HttpGetWithTimeOut 方法,内部逻辑会有一个 get 请求,最后返回内容。我们在测试环境中,是访问不到它发起的 get 请求的 url 的,此时就可以模拟 http 请求来写测试。 代码示例:

func TestHttpGetWithTimeOut(t *testing.T) {

    Convey("TestHttpGetWithTimeOut", t, func() {
        Convey("TestHttpGetWithTimeOut normal", func() {
            ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
                w.Write([]byte("TestHttpGetWithTimeOut success!!"))
                if r.Method != "GET" {
                    t.Errorf("Except 'Get' got '%s'", r.Method)
                }
                if r.URL.EscapedPath() != "/要访问的url" {
                    t.Errorf("Expected request to '/要访问的url', got '%s'", r.URL.EscapedPath())
                }
            }))
            api := ts.URL
            defer ts.Close()
            var header = make(map[string]string)
            HttpGetWithTimeOut(api, header, 30)
        })
    }
}

其中:

  • httptest.NewServer():创建一个 http 请求
  • http.ResponseWriter:响应体
  • http.Request:请求体

这段代码中,通过 w 来设置返回的头内容与写入内容,通过 r 来设置请求方法和请求的 url,最后将模拟好的请求,传参对应方法。

SqlMock

Sqlmock 可以用来模拟数据库请求。

下载安装:

go get -t https://github.com/DATA-DOG/go-sqlmock

Sqlmock 的特点在于模拟任何实现了 sql/driver 接口的 db 驱动,无需关注 db 连接。

使用参考:

1、构建模拟 sql

db, mock, err = sqlmock.New() // mock sql.DB
defer db.Close()

2、执行增删改查语句

mock.ExpectQuery("SELECT (.+) FROM orders AS o INNER JOIN users AS u (.+) FOR UPDATE").
		WithArgs(1).
		WillReturnError(fmt.Errorf("Some error"))
mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("INSERT INTO product_viewers").
		WithArgs(2, 3).
		WillReturnError(fmt.Errorf("some error"))
`

3、执行事务

```go
mock.ExpectBegin()
mock.ExpectRollback()

具体使用可以参考 官方文档