GoLang快速上手单元测试(思想、框架、实践)

1、单元测试是什么

单元测试可以检查我们的代码能否按照预期进行,代码逻辑是否有问题,以此可以提升代码质量。
简单来说单元测试就是针对某一个函数方法进行测试,我们要先测试正确的传值与获取正确的预期结果,然后再添加更多测试用例,得出多种预期结果。尽可能达到该方法逻辑没有问题,或者问题都能被我们预知到。这就是单元测试的好处。

2、Golang是怎么写单元测试的?

很简单!Golang本身对自动化测试非常友好,并且有许多优秀的测试框架支持,非常好上手。
文中将以新手的角度,快速上手一一实践给大家感受Go单元测试的魅力。

3、写一个最简单的Test吧!

先来了解一下Go官方的testing

要编写一个测试文件,需要创建一个名称以 _test.go 结尾的文件,该文件包含 TestXxx 函数,如上所述。 将该文件放在与被测试文件相同的包中。该文件将被排除在正常的程序包之外,但在运行 go test 命令时将被包含。
测试函数的签名必须接收一个指向testing.T类型的指针,并且不能返回任何值。函数名最好是Test+要测试的方法函数名。

记得一定要先看一下Testing的官方文档!

目录结构
test_study
—–samples.go
—–samples_test.go

被测试代码

package test_study
import "fmt"

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

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

这个代码将输出一句“Hello, world”

测试

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

测试结果

D:\goproj\test_study>go test -v
=== RUN   TestHello
--- PASS: TestHello (0.00s)
PASS
ok      _/D_/goproj/test_study  0.030s

说明我们的测试通过了,返回的值与我们预期的值是相同的。
现在尝试把预期结果修改一下

want := "xdcute.com"

测试结果

D:\goproj\test_study>go test -v
=== RUN   TestHello
    samples_test.go:9: got "Hello, world" want "xdcute.com"
--- FAIL: TestHello (0.00s)
FAIL
exit status 1
FAIL    _/D_/goproj/test_study  0.159s

此时提示测试不通过,得到的值与预期的值不相同。
这就是一个最简单的测试写法,我们可以进行正确或错误的测试。

这里介绍几个常用的参数:

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

再添加一个被测试方法

func Add(a,b int) int{
    return a+b
}

测试代码

func TestAdd(t *testing.T) {
    sum := Add(5,5)
    if sum == 10 {
        t.Log("the result is ok")
    } else {
        t.Fatal("the result is wrong")
    }
}

使用-run来测试,发现只运行了TestAdd测试方法。

D:\goproj\test_study>go test -v -run TestAdd
=== RUN   TestAdd
    samples_test.go:16: the result is ok
--- PASS: TestAdd (0.00s)
PASS
ok      _/D_/goproj/test_study  0.176s

4.看看单元测试覆盖率

写好测试后,可以利用Go自带的工具 test coverage 查看一下单元测试覆盖率
测试覆盖率是一个术语,用于统计通过运行程序包的测试多少代码得到执行。 如果执行测试套件导致80%的语句得到了运行,则测试覆盖率为80%。

来尝试一下吧~

D:\goproj\test_study>go test -cover
PASS
coverage: 66.7% of statements
ok      _/D_/goproj/test_study  0.163s

可以看到刚才我们写的测试,覆盖率为66.7%。
再回过头看一下我们的被测试代码,还有一个main()在测试中没有执行到,只执行了Hello()和Add(),所以覆盖率只有66.7%。试着在测试代码中直接加上“main()”的调用。

现在的测试覆盖率就是100%了,并且在PASS之前输出了一句“Hello, world”

D:\goproj\test_study>go test -cover
Hello, world
PASS
coverage: 100.0% of statements
ok      _/D_/goproj/test_study  0.162s

5.学习GoConvey测试框架

在前面,我们判断预期结果都是用if…else之类的判断语句,如果面对更庞大的测试,有更多的测试用例,可能需要思考更多逻辑判断,加大代码长度与复杂度,不便于编写与管理。所以我们需要用到更好的测试框架来增强测试编写。
GoConvey是一款针对Golang的测试框架,它可以更好的管理和运行测试用例,而且又很丰富的断言函数,能够写出更完善的测试用例,并且还有web界面,是极其常用的测试框架。

添加一个匹配url的方法

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

测试代码
记得先import "github.com/smartystreets/goconvey/convey"
或者在命令行输入go get github.com/smartystreets/goconvey

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

convey.Convey定义了测试用例名称、t指针、测试代码。
convey.So用来判断预期结果。
convey提供了大量的断言函数,比如刚才使用的convey.ShouldBeTrue,就是判断ok的值应该为true。
更多方法请前往GoConvey官方文档查看。

测试结果

D:\goproj\test_study>go test -v -run TestCheckTeachUrl
=== RUN   TestCheckTeachUrl
  TestCheckTeachUrl .

1 total assertion

--- PASS: TestCheckTeachUrl (0.00s)
PASS
ok      _/D_/goproj/test_study  0.187s

因为传入的是正确的url,可以匹配到,所以测试通过。

6.利用GoConvey定义多个测试用例

我们可以定义多个测试用例。
以下分别是输入正确、错误、空值的Url。

func TestCheckUrl(t *testing.T) {
    convey.Convey("TestCheckTeachUrl true", t, func() {
        ok:=CheckUrl("learnku.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)
    })
}

测试结果
三个测试用例都符合我们的预期结果,测试通过。
也可以尝试修改一下错误的预期结果,测试用例就会失败。

D:\goproj\test_study>go test -v -run TestCheckUrl
=== RUN   TestCheckUrl
  TestCheckTeachUrl true .
1 total assertion
  TestCheckTeachUrl false .
2 total assertions
  TestCheckTeachUrl null .
3 total assertions
--- PASS: TestCheckUrl (0.00s)
PASS
ok      _/D_/goproj/test_study  0.197s

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

func TestCheckUrl(t *testing.T) {
    convey.Convey("TestCheckTeachUrl", t, func() {
        convey.Convey("TestCheckTeachUrl true",  func() {
            ok := CheckUrl("learnku.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)
        })
    })
}

看看测试结果,变得非常简短许多了,我比较喜欢这种方式。

D:\goproj\test_study>go test -v -run TestCheckUrl
=== RUN   TestCheckUrl
  TestCheckTeachUrl 
    TestCheckTeachUrl true .
    TestCheckTeachUrl false .
    TestCheckTeachUrl null .
3 total assertions
--- PASS: TestCheckUrl (0.00s)
PASS
ok      _/D_/goproj/test_study  0.191s

经过以上体验,使用GoConvey确实可以更加快捷的编写和管理测试用例,很棒吧。

7.学习Testify测试框架

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

在使用前请记得import "github.com/stretchr/testify"
或者在命令行输入go get -t github.com/stretchr/testify

用Testify下assert来写测试代码

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

避免文章太长,测试结果就不贴出来了,这个测试用例肯定是PASS的。

8.结合表格驱动测试

结合表格测试可以写多个测试用例。testing本身也可以写表格测试,这里使用Testify演示。

测试代码

func TestCheckUrl3(t *testing.T) {
    assert := assert.New(t)
    var tests = []struct {
        input    string
        expected bool
    }{
        {"xdcute.com", true},
        {"xxx.com", false},
    }
    for _, test := range tests {
        fmt.Println(test.input)
        assert.Equal(CheckUrl(test.input), test.expected)
    }
}

这就是关于Testify的快速上手,关于它的mock功能,将在后面引入mock概念后再介绍。

9.使用Gomock框架-模拟接口

特点:
1.基于接口
2.能够与Golang内置的testing包良好集成

我觉得一开始不太好理解它的用法,先来看看这段代码。

目录结构:
test_study
├── db
│ ├── db.go

package main

import (
    "fmt"
    "log"
)
//定义了一个订单接口,有一个获取名称的方法
type OrderDBI interface {
    GetName(orderid int) (string)
}
//定义结构体
type OrderInfo struct {
    orderid int
}
//实现接口的方法
func (order OrderInfo) GetName(orderid int) string {
    log.Println("原本应该连接数据库去取名称")
    return "xdcute"
}

func main() {
    //创建接口实例
    var orderDBI OrderDBI
    orderDBI = new(OrderInfo)
    //调用方法,返回名称
    ret := orderDBI.GetName(1)
    fmt.Println("取到的用户名:",ret)
}

运行这段代码可以得到“取到的用户名:xdcute”。
假设这个GetName是需要连接数据库去取用户名,那我们想针对GetName写测试的话,就要真实连接一个数据库才行,意味着在任何一台电脑上想进行代码测试都必须要依赖数据库。

mock就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试。
意味着我们可以利用gomock来模拟一个假的数据库对象,提前定义好返回内容。

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

通过以下命令安装:

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

安装好后,我们使用mockgen来mock前面的db.go

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

-source 需要mock的源文件
-destination 生成的mock文件存放目录
-package 所属包

(务必开启Modules管理,不然使用mockgen可能会出现这样的提示Loading input failed: Source directory is outside GOPATH

目录结构:
test_study
├── db
│ ├── db.go
│ ├── db_mock.go

命令执行成功后会在目录下生成对应mock文件,点进去查阅一下,代码较长就不贴出来了。
然后会发现里面确实已经自动生成了接口与方法,有一个EXPECT()重点留意一下。

接下来新建一个db_test.go

func TestGetName(t *testing.T) {
    //新建一个mockController
    ctrl := gomock.NewController(t)
    // 断言 DB.GetName() 方法是否被调用
    defer ctrl.Finish()

    //mock接口
    mock := NewMockOrderDBI(ctrl)
    //模拟传入值与预期的返回值
    mock.EXPECT().GetName(gomock.Eq(1225)).Return("xdcutecute")

    //前面定义了传入值与返回值
    //在这里
    if v := mock.GetName(1225); v != "xdcutecute"{
        t.Fatal("expected xdcute, but got", v)
    }else{
        log.Println("通过mock取到的name:",v)
    }
}

Eq(value) 表示与 value 等价的值。
Any() 可以用来表示任意的入参。
Not(value) 用来表示非 value 以外的值。
Nil() 表示 None 值

测试结果:

D:\goproj\test_study\db>go test -run TestGetName
2020/12/25 16:41:00 通过mock取到的name: xdcutecute
PASS
ok      test_study/db   0.174s

可以看到测试通过了,与我们预期的值相符。是不是突然Get到了GoMock的好处~

mock工具的作用是指定函数的行为(模拟函数的行为)。可以对入参进行校验,对出参进行设定,还可以指定函数的返回值。

10.最简单的打桩(stubs)

像第九节这种模拟接口调用方法,有明确的参数值与返回值,就是最简单的打桩

,或称桩代码,是指用来代替关联代码或者未实现代码的代码。如果函数B用B1来代替,那么,B称为原函数,B1称为桩函数。打桩就是编写或生成桩代码。

下一节会展现更多场景的打桩。

11.使用GoStub框架-变量、函数、过程打桩

记得使用该命令安装噢 go get github.com/prashantv/gostub

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

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

全局变量:

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

    //输出
    //learnku
    //xdcute

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("xdcute"))

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

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

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

    //输出
    //After stub: xdcute,万生世代

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

过程:

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

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

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

//输出
//xdcute

12.HttpMock-模拟http请求

在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。
最后将模拟好的请求,传参对应方法。

13.SqlMock-模拟数据库请求

引用命令 go get -t https://github.com/DATA-DOG/go-sqlmock
特点:
1.模拟任何实现了sql/driver接口的db驱动,无需关注db连接。

使用参考:
构建模拟sql

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

执行查询语句

mock.ExpectQuery(sqlSelectAll).WillReturnRows(sqlmock.NewRows(nil))

有更多调用方法请查看官方文档

14.GoMonkey-强大的打桩框架

特点:
1.直接在方法级别上进行mock
(在运行时通过汇编语句重写可执行文件,将待打桩函数或方法的实现跳转到桩实现)
在编译阶段直接替换掉真的函数代码部分
2.非线程安全,请勿用于并发测试

使用:
PatchInstanceMethod() 对于方法
在使用前,先要定义一个目标类的指针变量x
第一个参数是reflect.TypeOf(x)
第二个参数是字符串形式的函数名
返回值是一个PatchGuard对象指针,主要用于在测试结束时删除当前的补丁

var e *Etcd
guard := PatchInstanceMethod(reflect.TypeOf(e), "Get", func(_ *Etcd, _ string) []string {
    return []string{"task1", "task5", "task8"}
})
defer guard.Unpatch()

Patch() 对于过程
当一个函数没有返回值时,该函数我们一般称为过程。很多时候,我们将资源清理类函数定义为过程。

guard := Patch(DestroyResource, func(_ string) {
})
defer guard.Unpatch()

对于函数

第一个参数是目标函数的函数名
第二个参数是桩函数的函数名,习惯用法是匿名函数或闭包
返回值是一个PatchGuard对象指针,主要用于在测试结束时删除当前的补丁

func TestExec(t *testing.T) {
    Convey("test has digit", t, func() {
        Convey("for succ", func() {
            outputExpect := "xxx-vethName100-yyy"
            guard := Patch(osencap.Exec, func(_ string, _ ...string) (string, error) {
                return outputExpect, nil
            })
            defer guard.Unpatch()
            output, err := osencap.Exec(any, any)
            So(output, ShouldEqual, outputExpect)
            So(err, ShouldBeNil)
        })
    })
}

15.测试风格介绍

TDD(Test Drive Development)测试驱动开发

它的基本思想就是在开发功能代码之前,先编写测试代码。再通过开发功能来满足测试代码的通过,一旦出bug就需要修复重构。以此循环,来保证功能开发的完备性。
这样的好处是能够编写出足够健壮的代码,但前提是需要确保所有的需求在测试用例中都被照顾到,而且测试用例需要尽可能覆盖分支和行。
tdd

简单来说,不可运行/可运行/重构——这正是测试驱动开发的口号,也是 TDD 的核心。在这个闭环中,每一个阶段的输出都会成为下一阶段的输入。

  1. 不可运行——写一个功能最小完备的单元测试,并使得该单元测试编译失败。
  2. 可运行——快速编写刚刚好使测试通过的代码,不需要考虑太多,甚至可以使用一些不合理的方法。
  3. 重构——消除刚刚编码过程引入的重复设计,优化设计结构。

参考文章:测试驱动开发(TDD)总结——原理篇

BDD(Behavior Driven Development)行为驱动开发
它是在软件开发前,需要给出用例和预期,促使项目中人员沟通。
产品或者项目人员都参与进来,加强沟通,写出更符合产品需求预期的用例。只不过用例不再局限于一个函数或者类型,而是更高层面。
bdd
参考文章:Go项目中的BDD实践

16.总结

关于单元测试,经过一段时间的理论结合实践的学习,熟悉了Golang中单元测试的使用以及加深了单元测试对于项目的重要性。
在为不同的接口写测试时,能够快速熟悉项目,并且发现原有代码中可能存在的一些需要改进的地方。例如有 些方法,内部逻辑没有问题,但是缺少对于空值、非法值的处理。此时可以在该方法中添加注释,提醒开发人员可以进行改进,或者主动联系开发人员沟通,自己直接优化代码。
利用单元测试,可以很好的熟悉项目、发现可能存在的问题、发现可以优化的部分。bug发现的越晚,修改它所需要的成本就越高,所以应该尽可能早地查找和修改bug

重视单元测试后你将会发现单元测试带来的好处远远不止这些~

今后我也会继续学习如何更好的写测试,加强理论知识的实践,继续提升代码能力。

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 3年前 自动加精
讨论数量: 2
Dennis_Ritchie

:+1:

3年前 评论

不错。

新手,记笔记

递归测试
go test -v ./...
3年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!