Golang单元测试与框架

2,775 阅读13分钟

什么是单元测试 -- what?

单元测试是针对程序模块来进行正确性检验的测试工作,程序单元是应用的最小可测试部件。

进行单元测试是为了让开发者确信自己的代码在按预期运行,为确保代码可以测试且测试易于维护。

为什么要进行单元测 -- why?

1、提高工作效率:

在我们修改已有代码时候,会不得不考虑增量代码是否会对原有逻辑带来冲击,以及修复bug之后是否引入的新的bug。而完善的单测在能够提供我们代码交付质量的同时,减少bug发现和修复的成本,进而提高工作效率。

Untitled Diagram.drawio (2).png

2、提升代码质量:

可测试通常与软件的良好设计相关,难以测试的代码一般设计上都有问题。所以有效的单测会驱动开发者写出更高质量代码。同时单测带来最直接的收益就是能够减少bug率,虽然单测不能捕获所有bug,但是的确能够暴露出大多数bug。

3、节省成本:

单测能够确保程序的底层逻辑单元的正确性,让问题在RD自测阶段就暴露出来。bug越早发现,修复成本往往更低,带来的影响也会更小。

4、重构(促进变化并简化集成):

单元测试允许在将来重构代码或升级系统库时,确保该模块仍然能正常工作。单元测试能监测到可能违反设计合同的变化,有助于维护和更改代码。单元测试还可以减少新开发功能中的缺陷,减少现有功能更改时出现的错误。

怎么进行单元测试 -- how?

1、进行单元测试最基本的工具就是测试框架

testingGoConveytestify
简单好用能够使用 go test 来运行测试go test 无缝集成,直接使用该命令运行
断言不够友好,需要大量if else支持断言,写法更简便支持断言,写法更简便
支持通过浏览器查看测试结果支持 mock & suite功能

go内置的testing框架已经足够应付简单的单测需求,方便好用,但不支持断言,在大型项目上表现还是不够完善。

goConvey和testify支持大量的断言,结合其他的mock框架,在单元测试方面表现良好。

下面介绍一下各测试框架的使用。

testing框架使用:

  • 用_test.go结尾来表示测试文件
  • 函数以Test开头并只有一个参数*testing.T来表示一个测试函数
  • fatalf会直接退出(FailNow + Log),errorf会继续(Fail + Log)
Log()打印日志
Logf()格式化打印日志
Error()打印错误日志
Errorf()格式化打印错误日志
Fatal()打印致命日志
Fatalf()格式化打印致命日志|
Fail()标记失败,但继续执行当前测试函数
FailNow()失败,立即终止当前测试函数执行
Skip()跳过当前函数,通常用于未完成的测试用例

example:

func Fib(n int) int {
   if n < 2 {
      return n
   }
   return Fib(n-1) + Fib(n-2)
}

// testing 框架
func Test_Fib(t *testing.T) {
   var (
      in       = 7
      expected = 13
   )
   actual := Fib(in)
   if actual != expected {
      t.Errorf("Fib(%d) = %d; expected %d", in, actual, expected)
   }
}

如果要覆盖到不同分支,就需要不同类型输入,那么这就很适合表格驱动测试,testing同样也支持表格驱动测试

// table_driven test
func TestFib(t *testing.T) {
   var fibTests = []struct {
      in       int // input
      expected int // expected result
   }{
      {1, 1},
      {2, 1},
      {3, 2},
      {4, 3},
      {5, 5},
      {6, 8},
      {7, 13},
   }
   for _, tt := range fibTests {
      actual := Fib(tt.in)
      if actual != tt.expected {
         t.Errorf("Fib(%d) = %d; expected %d", tt.in, actual, tt.expected)
      }
   }
}

同时testing框架还支持性能测试等操作。

goland 支持一键自动生成测试模版,在需要测试的函数处右键,点击generate即可对函数生成测试模版,只需要填写测试用例数据即可,较为方便

testing框架的详细使用方法

GoConvey框架使用:

在前面testing框架中,我们判断预期结果都是用if…else 之类的判断语句,如果面对更庞大的测试,有更多的测试用例,可能需要思考更多逻辑判断,加大代码长度与复杂度,不便于编写与管理。所以我们需要用到更好的测试框架来增强测试编写。

GoConvey 是一款针对 Golang 的测试框架,它框架兼容Golang原生的testing框架,可以直接用go test -v来运行测试,同时更好的管理和运行测试用例,而且有很丰富的断言函数,能够写出更完善的测试用例。还有 web 界面,是极其常用的测试框架。

github地址:github.com/smartystree…

func Test_Fib_convey(t *testing.T) {
   Convey("Test_Fib_convey should return true when actual=7  && expected = 13", t, func() {
      actual := Fib(7)
      expected := 13
      So(actual, ShouldEqual, expected)
   })
   Convey("Test_Fib_convey should return true when actual=6  && expected = 8", t, func() {
      actual := Fib(6)
      expected := 8
      So(actual, ShouldEqual, expected)
   })
   Convey("Test_Fib_convey should return true when actual=5  && expected = 5", t, func() {
      actual := Fib(5)
      expected := 0
      So(actual, ShouldEqual, expected)
   })
}

image.png 每个测试用例必须使用Convey函数包裹起来,一个函数可以有多个测试用例多个convey,convey可以嵌套:

  • 第一个参数为string类型的测试描述
  • 第二个参数为测试函数的入参(类型为*testing.T)
  • 第三个参数为不接收任何参数也不返回任何值的函数(习惯使用闭包)
  • Convey函数的第三个参数闭包的实现中通过So函数完成断言判断,它的第一个参数为实际值,第二个参数为断言函数变量,第三个参数为期望值或没有
  • Convey语句可以无限嵌套,以体现测试用例之间的关系。需要注意的是,只有最外层的Convey需要传入*testing.T类型的变量t。
// convey 框架
func Test_Fib_convey(t *testing.T) {

   Convey("Test_Fib_convey", t, func() {
      Convey("Test_Fib_convey should return true when actual=7  && expected = 13", func() {
         actual := Fib(7)
         expected := 13
         So(actual, ShouldEqual, expected)
      })
      Convey("Test_Fib_convey should return true when actual=6  && expected = 8", func() {
         actual := Fib(6)
         expected := 8
         So(actual, ShouldEqual, expected)
      })
      Convey("Test_Fib_convey should return true when actual=5  && expected = 5", func() {
         actual := Fib(5)
         expected := 0
         So(actual, ShouldEqual, expected)
      })
   })

}

image.png

同时,GoConvey支持在 Web 界面进行自动化编译测试。

需要go1.16以上版本才能使用 ?
go get github.com/smartystreets/goconvey
go install github.com/smartystreets/goconvey
然后在测试文件所在目录下 命令行执行 goconvey

image.png

对于某些先暂时跳过的convey和so 可使用skipconvey和skipSo 替换原来的convey和so函数,测试就会跳过该案例。

Convey的断言也支持定制,可以自定义断言

type Assertion func(actual interface{}, expected ...interface{}) string

参考

Testify框架使用:

Testify 也是一个断言库,对代码的侵入比较弱,且它的功能相对于 GoConvey 而言比较简单,主要是在提供断言功能之外,提供了 mock 的功能。自带的mock功能不够强大,最好还是使用其他mock框架

github地址:github.com/stretchr/testify

testify的assert子库提供了大量断言以供使用。require供了和assert同样的接口,但是遇到错误时,require直接终止测试,而assert返回false

suite子库提供了测试套件功能,可以在整个套件开始结束时执行动作,也可以在每个测试开始结束时执行动作。

// testify 框架
func Test_Fib_testify(t *testing.T) {
   actual := Fib(7)
   expected := 13
   assert.Equal(t, actual, expected, "test failed msg")
   require.Equal(t, actual, expected, "test failed msg")
}

testify提供了测试套件的性能(TestSuite),可以用来做一些测试前的数据准备工作和结束后的收尾工作,这里就不展开了

2、测试过程中有可能会遇到各种依赖和外部接口调用,为了屏蔽外部环境的影响,让测试变得可靠,就要使测试结果变得可重复,这时候就需要使用测试替身(test double)了

测试替身是桩(Stub), 伪造对象(fake),测试间谍(spy)模拟对象(mock)的总称。

而使用测试替身的根本目的是使用替身替换/模拟一个模块的真实协作者,将测试代码和周围隔离开。

image.png

  • 桩(Stub): 测试桩是单元测试中常用的技术,所谓的“打桩”是指用最简单的代码来替代真实实现。换句话说,如果A依赖了B的一个很复杂的方法的返回值,而我们要测试A,这个返回值是多少我们并不太关心,就可以写出一个B1(桩函数),只是用了B的桩而不是B,这样就可以完成测试。

  • 伪造对象(Fake):伪造对象是真实协作者的简单版本,它看起来像鸭子,叫起来像鸭子,却不是鸭子本身。

    打个比方,如果我们的数据存储在一个大型的数据库中,数据库给我们提供了API,但是这个数据库本身需要部署环境,需要有网络。而我们可以使用伪造对象在内存中模拟这样的一个存储实体,实现对应的API用来测试。(Ps.Fake是不使用模拟框架的,而是真实API的一个轻量级实现)

  • 测试间谍(Spy):测试间谍是一种加强的伪造对象,它不仅长得像鸭子,叫声像鸭子,它还能告诉你正常鸭子不会告诉你的鸭子的秘密。当我们的测试类没有返回值或者想测试这个类的一些私有的状态的时候,就需要用到测试间谍。

  • 模拟对象(Mock):模拟对象是特殊的间谍对象。他不仅是像间谍对象那样暴露自己的隐私信息以供查询,还会模拟被模拟对象的行为与待测试对象进行交互,他比Fake多的是“可以配置的针对不同参数的响应”,即 Mock对象可以做到传入不同的参数的时候,给予不同的响应,且如何响应式可以配置的。打个比方:一个网络接口,当我们传入1的时候返回A,传入2的时候返回B,要实现这样的一个模拟对象,就叫Mock。

    mock和stub很容易混淆,但stub是和真实的对象进行协作,只是对于打桩的函数返回预设的结果,更关注的是状态,即返回结果。mock是与模拟出来的mock对象进行交互,完全与真实协作者隔离,既关注测试流程中的行为,也关注最后的状态。stub是对函数/变量;mock是对接口。

对于这四者的介绍,可以参考(blog.csdn.net/nxy_wuhao/a…

下面我们介绍一下golang中常用的stub/mock框架

GoStubGomnkeyGomock
轻量级打桩框架运行时重写可执行文件,类似热补丁 官方提供的mock框架,功能强大
支持为全局变量,函数打桩性能强大,使用方便mockgen 工具可自动生成mock代码;支持mock所有接口类型
需要改造原函数,使用不方便;性能不强支持对变量,函数,方法打桩,支持打桩序列可以配置调用次数,调用顺序,根据入参动态返回结果等
不是并发安全的;使用可能根据版本不同需要有些额外配置工作只支持接口级别mock,不能mock普通函数

1、GoStub

github地址:github.com/prashantv/gostub

gostub的使用场景如下:

  • 为一个全局变量打桩
  • 为一个函数打桩
  • 为一个过程打桩:当一个函数没有返回值时,该函数我们一般称为过程。
func Test_stub(t *testing.T) {
   // 为全局变量打桩
   stubs := gostub.Stub(&num, 150)
   defer stubs.Reset()
   fmt.Println(num)
   // 对函数/过程打桩
   var Exec = func(cmd string, args ...string) {
      fmt.Println("function original")

   }
   stubsFunc := gostub.Stub(&Exec, func(cmd string, args ...string) {
      fmt.Println("function stub")
   })
   defer stubsFunc.Reset()
   Exec("test")
   // 也可以直接用gostub.StubFunc() 对函数打桩
}

gostub是一款轻量级打桩框架,虽然已经可以优雅的解决很多场景的函数打桩问题,但对于一些复杂的情况,却只能干瞪眼。

同时,GoStub框架需要改造函数,不符合我们的日常习惯

2、GoMonkey

由于方法(成员函数)无法通过 GoStub 框架打桩,当产品代码的 OO 设计比较多时,打桩点可能离被测函数比较远,导致UT用例写起来比较难受。同时过程或函数通过 GoStub 框架打桩时,对产品代码有侵入性。Gomonkey框架解决了这部分问题

Gomokey通过在运行时通过汇编语句重写可执行文件,将待打桩函数或方法的实现跳转到桩实现,原理和热补丁类似。通过 Monkey,我们可以解决函数或方法的打桩问题,但 Monkey 不是线程安全的,不要将 Monkey 用于并发的测试中。

Ps.桩函数的类型必须严格匹配才能生效

使用场景:

  • 为函数/过程打桩
  • 为成员方法打桩
  • 为全局变量打桩
  • 为函数变量打桩
  • 为函数打一个特定的桩序列
  • 为成员方法打一个特定的桩序列
  • 为函数变量打一个特定的桩序列

为函数和成员方法打桩,每次mock都返回相同结果:

为一个函数打桩:func ApplyFunc(target, double interface{}) *Patches

为一个成员方法打桩:func ApplyMethod(target reflect.Type, methodName string, double interface{}) *Patches

为成员方法和函数打一个桩序列,每次mock返回指定的不同值:

func ApplyMethodSeq(target reflect.Type, methodName string, outputs []OutputCell) *Patches

func ApplyFuncSeq(target interface{}, outputs []OutputCell) *Patches

也可以用来mock函数变量和全局变量

函数变量:func ApplyFuncVar(target, double interface{}) *Patches

全局变量:func ApplyGlobalVar(target, double interface{}) *Patches

outputs是希望返回的结果序列(测试时会按顺序返回),数量需要和对应的测试用例对应

type OutputCell struct {
     Values Params  //希望mock返回的数据
     Times   int //返回的次数
 } 
func TestGetAbsolutePath(t *testing.T) {
	// 方法序列打桩
	retArr := []OutputCell{
		{Values: Params{"./testpath1"}},
		{Values: Params{"./testpath2"}},
		{Values: Params{"./testpath3"}, Times: 2},
	}
	ApplyFuncSeq (config.GetAbsolutePath, retArr)

	log.Printf("config path: %s", config.GetAbsolutePath())
	log.Printf("config path: %s", config.GetAbsolutePath())
	log.Printf("config path: %s", config.GetAbsolutePath())
	log.Printf("config path: %s", config.GetAbsolutePath())
}
//测试给方法打桩1,返回正确
func TestMethodRight(t *testing.T) {
	var temp *model.MyUser
	patch := ApplyMethod(reflect.TypeOf(temp), "GetUserName", func(_ *model.MyUser) string {
		return "hello,world!"
	})
	defer patch.Reset()
	Convey("GetUserName将返回:hello,world!", t, func() {
		var user *model.MyUser
		user = new(model.MyUser)
		So(user.GetUserName(), ShouldEqual, "hello,world!")
	})
}

使用GoMonkey可能遇到的问题:

  • 打桩失败:

applyFunc和applyMethod 是在运行时把a函数的机器码替换成跳转到b函数地址,使用了内联优化会在编译时将函数直接展开,无法替换。

通过命令行参数 -gcflags=-l 关闭内联优化

  • peimission denied问题:

gomonkey 替换函数机器码过程中需要调用syscall,在macOS Catalina 10.15.x以上版本会报权限错误,解决办法:github.com/eisenxp/mac…

3、GoMock

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接口,使用mockgen产生一个mock对象

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

下面是自动生成的代码

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

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

import (
   reflect "reflect"

   gomock "github.com/golang/mock/gomock"
)

// MockDB is a mock of DB interface.
type MockDB struct {
   ctrl     *gomock.Controller
   recorder *MockDBMockRecorder
}

// MockDBMockRecorder is the mock recorder for MockDB.
type MockDBMockRecorder struct {
   mock *MockDB
}

// NewMockDB creates a new mock instance.
func NewMockDB(ctrl *gomock.Controller) *MockDB {
   mock := &MockDB{ctrl: ctrl}
   mock.recorder = &MockDBMockRecorder{mock}
   return mock
}

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

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

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

在测试的使用mock对象

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")) //设置期望返回结果,可以设置可调用次数times/AnyTimes

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

goMock支持对特定输入打桩和对任意输入打桩(gomock.any()),可根据具体情况使用;

实际项目中,可以用gomock来mock dao层和rpc层代码,隔离外部依赖。

4、其他

针对一些其他外部依赖还有很多其他mock框架,可以根据实际情况使用:

需要模拟sql操作,可以使用go-sqlmock,它不需要建立真正的数据库连接就可以在测试中模拟任何 sql 驱动程序的行为。可以创建一个虚拟的sql连接,自定义数据库的返回数据

需要模拟redis操作,可以使用miniredis,miniredis是一个纯go实现的用于单元测试的redis server。它是一个简单易用的、基于内存的redis替代品,它具有真正的TCP接口。当我们为一些包含 Redis 操作的代码编写单元测试时可以使用它来 mock Redis 操作。

http/web测试,可以使用httptest和gock框架。