在这里插入图片描述

1.什么是单元测试

单元测试(Unit Testing),是指对软件中的最小可测试单元进行检查和验证。

对于单元测试中单元的含义,一般要根据实际情况去判定其具体含义,如 C 语言中单元指一个函数,Java 里单元指一个类,图形化软件可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小被测功能模块。

在 Go 中,单元测试一般指对函数或方法的测试。

2.单元测试的作用

单元测试可以检查我们的代码能否按照预期执行,来提升代码质量。

通过单元测试,我们可以设置多个测试用例,执行要测试的函数,判断是否符合预期。尽可能达保证函数功能没有问题,或者出现我们预知的错误。一次书写测试用例,随着代码一起永久保留,来验证函数功能,这就是单元测试的好处。

3.Go 如何写单元测试

Go 本身对自动化测试非常友好,并且有许多优秀的第三方测试框架,非常好上手。

首先看一下 Go 官方的 testing 包。

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

测试函数的签名必须接收一个指向 testing.T 类型的指针,并且不能返回任何值,函数名必须以 Test 开头,建议后跟要测试的函数名。

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

下面利用 Go 官方 testing 包给出一个示例。

代码目录结构:

gotest
	- go.mod
	- go.sum
	- main.go
	- hello
		- hello.go
		- hello_test.go

main.go:

package main

import (
	"main/hello"
)

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

hello.go:

package hello

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

我们新建一个单测文件 hello_test.go,为函数 Hello() 添加单元测试。

hello_test.go:

package hello

import "testing"

func TestHello(t *testing.T) {
	got := Hello()
	want := "Hello world"
	if got != want {
		t.Errorf("got %q want %q", got, want)
	}
}

进入 hello 目录执行命令go test,或指定目录 go test ./hello,所有以_test.go结尾的源码文件内以 Test 开头的函数会自动被执行。

输出:

PASS
ok      main/hello      0.173s

我们也可以加上-v选项,输出单测执行的详细过程:

go test -v
=== RUN   TestHello
--- PASS: TestHello (0.00s)
PASS
ok      main/hello      0.176s

该结果,表示单测通过了,返回的值与我们预期的值是相同的。

现在尝试把预期结果修改一下:

want := "Hello fuck"

测试结果:

D:\code\gotest\hello>go test -v
=== RUN   TestHello
    hello_test.go:9: got "Hello world" want "Hello fuck"
--- FAIL: TestHello (0.00s)
FAIL
exit status 1
FAIL    main/hello      0.127s

此时提示测试不通过,得到的值与预期的值不相同。

至此,利用 Go 官方包 testing 便完成了一个简单的单元测试,我们可以进行正确或错误的测试。

4.go test 命令

4.1 简介

go test 是 Go 用来执行测试函数(test function)、基准函数(benchmark function)和示例函数(example function)的命令。

行 go test 命令,它会在 *_test.go 文件中寻找 test、benchmark 和 example 函数来执行。测试函数名必须以 TestXXX 开头,基准函数名必须以 BenchmarkXXX 开头,示例函数必须以 ExampleXXX 开头。

// test 测试函数
func TestXXX(t *testing.T) { ... }

// benchmark 基准函数
func BenchmarkXXX(b *testing.B) { ... }

// examples 示例函数,其相关命名方式可以查看第一篇文章
func ExamplePrintln() {
    Println("The output of\nthis example.")
    // Output: The output of
    // this example.
}

关于更多测试函数的信息请查看go help testfunc。

go test 有很多选项,主要分为三类:一类是控制构建,一类是控制测试行为,一类是用于状态分析。

这里只列出常用的用于控制测试行为的选项:

-bench regexp
	只执行匹配对应正则表达式的 benchmark 函数,如执行所有性能测试 "-bench ." 或 "-bench=."
-benchtime t
	对每个 benchmark 函数运行指定时间。如 -benchtime 1h30,默认值为 1s。特殊语法 Nx 表示运行基准测试 N 次(如 -benchtime 100x)
-run regexp
	只运行匹配对应正则表达式的 test 和 example 函数,例如 "-run Array" 那么就执行函数名包含 Array 的单测函数
-cover
	开启测试覆盖率
-v
	显示测试的详细命令

详见官方文档 Testing flags。

4.2 示例

假设在文件 add.go 有一个被测试函数

package hello

func Add(a, b int) int {
	return a + b
}
  • 测试函数(test function)

在测试文件 add_test.go 添加一个单元测试函数 TestAdd:

package hello

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 测试函数。

go test -v -run TestAdd main/hello
=== RUN   TestAdd
    add_test.go:16: the result is ok
--- PASS: TestAdd (0.00s)
PASS
ok      main/hello      0.170s
  • 基准函数(benchmark function)

添加一个性能测试函数 BenchmarkAdd:

package hello

func BenchmarkAdd(b *testing.B) {
	for n := 0; n < b.N; n++ {
		Add(1, 2)
	}
}

运行指定基准函数:

go test -bench BenchmarkAdd main/hello
goos: windows
goarch: amd64
pkg: main/contain
cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz
BenchmarkAdd-8          1000000000               0.2333 ns/op
PASS
ok      main/contain    0.586s
  • 示例函数(example function)
package hello

func ExampleAdd() {
	fmt.Println(Add(1, 2))
	// Output: 3
}

运行指定示例函数:

go test -v -run ExampleAdd main/contain
=== RUN   ExampleAdd
--- PASS: ExampleAdd (0.00s)
PASS
ok      main/contain    (cached)

注意: 示例函数类似于测试函数,但不是使用*testing.T来报告成功或失败,而是将输出打印到os.Stdout。如果示例函数中的最后一条注释以Output:开头,则将输出与注释进行精确比较(参见上面的示例)。如果最后一条注释以Unordered output:开头,则将输出与注释进行比较,但忽略行的顺序。编译了一个没有此类注释的示例函数,会被编译但不会被执行。如果在Output:之后没有文本,示例函数仍会被编译并执行,并且预期不会产生任何输出。

5.快速生成单测代码

实际上,不同函数的单测代码虽然逻辑不同,但结构是一样的,长得非常相似,因此重复的代码可以使用工具来生成,不用手动繁琐地重复书写。

常用的 IDE,比如 GoLand 或 VS Code,都自带了生成单元测试代码的工具。以 GoLand 为例,可以快速为函数、文件或包生成测试代码。在源码文件中右键函数名 > Generate... > Test for function便可以快速生成对应函数的单测代码模板,然后我们在生成的模板代码中添加具体的测试用例即可。

使用该方法,为上面的 Hello() 函数生成的测试代码如下:

func TestHello(t *testing.T) {
	tests := []struct {
		name string
		want string
	}{
		// TODO: Add test cases.
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := Hello(); got != tt.want {
				t.Errorf("Hello() = %v, want %v", got, tt.want)
			}
		})
	}
}

我们在注释处添加测试用例即可,以表驱动的方式(Table-Driven Approach)完成测试用例的书写与执行,非常快捷方便。

6.查看单测覆盖率

写好测试后,可以利用 Go 自带的工具 test coverage 查看一下单元测试覆盖率。

测试覆盖率是一个术语,用于统计通过运行程序包的测试多少代码得到执行。 如果执行测试函数导致 80%的语句得到了运行,则测试覆盖率为 80%。

我们来试一下。

D:\code\gotest>go test -v -cover ./hello
=== RUN   TestHello
--- PASS: TestHello (0.00s)
=== RUN   TestAdd
    hello_test.go:16: the result is ok
--- PASS: TestAdd (0.00s)
PASS
coverage: 100.0% of statements
ok      main/hello      0.154s  coverage: 100.0% of statements

可以看到,目录 hello 下的所有单测都通过了,且报告覆盖率为 100%.

7.使用单测框架写单测

学会使用 Go 官方 testing 包写单元测试是远远不够的,因为在实际项目开发中,面对复杂的逻辑判断,繁多的测试用例,网络 IO 调用等,都加大了单测编写与管理的难度,此时我们需要用更好的测试框架来完成单测的书写。

7.1 单测框架

7.1.1 testify

testify 是一个非常流行的单测框架,它提供了一系列的辅助工具,用于增强标准库 testing 包的功能,使得编写单元测试更加方便和强大。

testify 提供了三个能力:

  • 断言:assert 和 require
  • 单测组织:套件(suites)
  • 模拟(mocking)

其中 mock 的功能,一般我们不使用,因为有更好用的 mock 库。

suite 提供了几个接口,可以用来做初始化和释放的动作

方法说明
SetupSuite在整个suite开始时执行一次
SetupTest在每个测试函数开始之前执行
BeforeTest在每个测试函数开始之前执行,类似SetupTest,带suite和测试函数名
AfterTest在每个测试函数结束之后执行,类似TearDownTest,带suite和测试函数名
TearDownTest在每个测试函数结束之后执行
TearDownSuite在整个suite结束时执行一次

下面使用 testify + gomonkey 给出使用示例。

先改造一下 Hello() 和 Add() 函数。

// Hello 返回 Hello world 和写入 DB 的值
func Hello(a, b int) string {
	return fmt.Sprintf("Hello world and %v", Add(a, b))
}

// Add 将 a b 和写入 DB 并返回
func Add(a, b int) int {
	sum := a + b
	// 省略的 DB 操作
	return sum
}

我们为 Hello() 函数添加单元测试并 mock 住 Add() 函数。

package hello

import (
	"testing"

	"github.com/agiledragon/gomonkey/v2"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
)

// TestSuiteHello 测试套件
type TestSuiteHello struct {
	suite.Suite
	patches []*gomonkey.Patches
}

// SetupTest 测试套件初始化
func (suite *TestSuiteHello) SetupTest() {
	// mock Add 函数
	p := gomonkey.ApplyFunc(Add, func(a, b int) int {
		// 不执行DB写入,只返回加和结果
		return a + b
	})
	suite.patches = append(suite.patches, p)
}

// TearDownSuite 测试套件析构
func (suite *TestSuiteHello) TearDownSuite() {
	for _, p := range suite.patches {
		p.Reset()
	}
}

// TestHelloSuite 启动测试套件
func TestHelloSuite(t *testing.T) {
	suite.Run(t, new(TestSuiteHello))
}

// TestHello hello 函数单测
func (suite *TestSuiteHello) TestHello() {
	type args struct {
		a int
		b int
	}
	tests := []struct {
		name string
		args args
		want string
	}{
		{"testcase1", args{1, 2}, "Hello world and 3"},
		{"testcase2", args{3, 4}, "Hello world and 7"},
		{"testcase3", args{5, 6}, "Hello world and 11"},
	}
	for _, t := range tests {
		res := Hello(t.args.a, t.args.b)
		assert.Equal(suite.T(), t.want, res, t.name)
	}
}

自定义的测试套件 TestSuiteHello,可以添加多个以 Test 开头的单测,将被一一执行。

执行单元测试:

D:\code\gotest>go test -v ./hello
=== RUN   TestHelloSuite
=== RUN   TestHelloSuite/TestHello
--- PASS: TestHelloSuite (0.00s)
    --- PASS: TestHelloSuite/TestHello (0.00s)
PASS
ok      main/hello      0.149s

测试通过。

7.1.2 goconvey

7.1.2.1 简介

如果觉得上面使用表驱动的方式来管理测试用例太过简单,不够丰富,那么可以使用 GoConvey。

GoConvey 是一个典型的行为驱动开发(BDD,Behavior-Driven Development)风格的 Go 语言单元测试框架。它的设计深受 Ruby 的 RSpec 和 JavaScript 的 Jasmine 等框架的影响,旨在让测试代码读起来更像自然语言,从而提升可读性和协作性。

GoConvey 的单测组织形式更富逻辑性和结构化,更有可读性和可维护性。此外还具有丰富的断言函数和 Web 界面,是极其常用的测试框架。

goconvey 的断言以 So 函数包裹,常用断言有 ShouldEqual,ShouldBeNil,ShouldResemble(就是递归比较,类似 reflect.DeepEqual)。自定义断言也比较方便,实现 assertion 函数就可以。

7.1.2.2 用法

goconvey 还可以无限嵌套,将相关的用例集合在一起,类似于:

import(
	"testing"

	. "github.com/smartystreets/goconvey/convey"
)

func TestFoo(t *testing.T) {
	Convey("测试用例 1", t, func() {
		Convey("测试用例 1.1", func() {		//第 2 层及其以上不需要传递 testing.T 变量
			// ...
		})
		Convey("测试用例 1.2", func() {
			// ...
		})
	}
}

使用 goconvey 改写上面 TestHello()。

package hello

import (
	"testing"

	"github.com/smartystreets/goconvey/convey"
)

func TestHello(t *testing.T) {
	convey.Convey("test case 1", t, func() {
		s := Hello(1, 2)
		convey.So(s, convey.ShouldEqual, "Hello world and 3")
	})
	convey.Convey("test case 2", t, func() {
		s := Hello(3, 4)
		convey.So(s, convey.ShouldEqual, "Hello world and 7")
	})
	convey.Convey("test case 3", t, func() {
		s := Hello(5, 6)
		convey.So(s, convey.ShouldEqual, "Hello world and 11")
	})
}

提一嘴,goconvey 的作者又搞了一个新的框架 gunit,相关文章有 A History of Testing in Go at SmartyStreets 及其译文。gunit 风格更像 testify,不过 star 数不到 1K,感兴趣的同学可以了解一下。

7.1.2.3 注意事项

需要注意,在 GoConvey 测试框架中,So 函数是用来执行具体的断言的。Convey 函数则用于定义测试的上下文或场景。So 函数通常位于 Convey 块内部,这是因为 Convey 提供了必要的上下文和隔离环境,使得每个测试条件或断言可以在定义好的环境中运行。

实际上,So 函数依赖于 Convey 函数提供的上下文。在 Convey 函数外部调用 So 函数通常是不合适的,会遇到运行时错误。

--- FAIL: TestConsumeHandler_GetConfirmDetails (0.00s)
panic: Convey operation made without context on goroutine stack.
Hint: Perhaps you meant to use `Convey(..., func(c C){...})` ? [recovered]
	panic: Convey operation made without context on goroutine stack.
Hint: Perhaps you meant to use `Convey(..., func(c C){...})` ?

7.2 Stub/Mock 框架

7.2.1 gomonkey

gomonkey 快速上手

7.2.2 gomock

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

gomock 用于为接口创建模拟对象,并在测试中使用这些模拟对象来模拟实际对象的行为。这对于在隔离环境中测试代码特别有用,特别是当你的代码依赖于外部资源或复杂组件(如数据库、网络服务等)时,你可以使用 gomock 来模拟这些依赖,从而专注于测试代码本身的行为。

gomock 的核心功能包括:

  • 模拟接口:生成满足接口定义的模拟对象。
  • 记录和验证交互:检查代码是否以预期的方式调用了模拟对象的方法,包括调用的次数、传递的参数等。
  • 返回自定义响应:设置模拟对象的方法在被调用时返回自定义的结果。
  • 匹配参数:提供了多种参数匹配器,可以灵活地校验传递给模拟方法的参数。

gomock 基于接口,一开始不太好理解它的用法,先来看看一段业务代码。

代码结构:

- main.go
- db
	- db.go
//
// db.go
//

package db

import (
	"log"
)

// OrderDBI 定义了一个订单接口,有一个获取名称的方法
type OrderDBI interface {
	GetName(orderid int) string
}

// OrderInfo 定义结构体
type OrderInfo struct {
	orderid int
}

// GetName 实现接口的方法 GetName
func (order OrderInfo) GetName(orderid int) string {
	log.Println("原本应该连接数据库去取名称")
	return "foo"
}

//
// main.go
//

package main

import (
	"fmt"
	
	"main/db"
)

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

运行这段代码可以得到取到的用户名:foo。

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

此时我们可以使用 gomock 将其 mock 住,提前定义好返回内容。

首先,我们需要预先将 mockgen 安装好,可以通过以下命令安装:

go install github.com/golang/mock/mockgen

安装好后,我们使用 mockgen 来 mock 前面的 Add() 函数。

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

-source 需要 mock 的源文件
-destination 生成的 mock 文件路径
-package 所属包

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

命令执行成功后会生成指定的 mock 文件,点进去查阅一下,有一个EXPECT()需要重点留意一下。

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

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

import (
	reflect "reflect"

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

// MockOrderDBI is a mock of OrderDBI interface.
type MockOrderDBI struct {
	ctrl     *gomock.Controller
	recorder *MockOrderDBIMockRecorder
}

// MockOrderDBIMockRecorder is the mock recorder for MockOrderDBI.
type MockOrderDBIMockRecorder struct {
	mock *MockOrderDBI
}

// NewMockOrderDBI creates a new mock instance.
func NewMockOrderDBI(ctrl *gomock.Controller) *MockOrderDBI {
	mock := &MockOrderDBI{ctrl: ctrl}
	mock.recorder = &MockOrderDBIMockRecorder{mock}
	return mock
}

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

// GetName mocks base method.
func (m *MockOrderDBI) GetName(orderid int) string {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "GetName", orderid)
	ret0, _ := ret[0].(string)
	return ret0
}

// GetName indicates an expected call of GetName.
func (mr *MockOrderDBIMockRecorder) GetName(orderid interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockOrderDBI)(nil).GetName), orderid)
}

MockOrderDBIMockRecorder 和 MockOrderDBI 都实现了 interface 的方法。前者用来记录预期的行为,后者匹配并执行预期的行为。

下面是一个 mock 的执行流程。
在这里插入图片描述
gomock 的核心结构就是 Call,expectedCalls 是 call 的集合。

Call 对象有一系列的方法来表示预期的行为。

调用方法说明
After(preReq Call) *Call指定执行顺序
AnyTimes() *Call允许调用次数为 0 次或更多次
Do(f interface{}) *Call指定匹配时执行的操作
Return(rets …interface{}) *Call模拟返回值
DoAndReturn(f interface{}) *Call指定匹配是执行的操作,并且模拟返回值
MaxTimes(n int) *Call匹配到的方法最多可以被调用次数n 次
MinTimes(n int) *Call匹配到的方法最少要被调用次数n 次
SetArg(n int, value interface{}) *Call设置第n个参数的值为value
String() String返回其字符串形式
Times(n int) *Call匹配到的方法必须被调用次数 n 次

另外一个就是 Call 的入参匹配规则。

匹配方法说明
Any() Matcher始终匹配
AssignableToTypeOf(x interface{}) Matcher如果入参可赋值给目标,则匹配,使用的是reflect.Type的AssignableTo
Eq(x interface{}) Matcher相等则匹配
Nil() Matcher为 nil 则匹配
Not(x interface{}) Matcher匹配条件取反

接下来新建一个 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("foo")

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

执行上面的单测输出:

go test -v ./db
=== RUN   TestGetName
2022/02/05 14:24:58 通过mock取到的name: foo
--- PASS: TestGetName (0.00s)
PASS
ok      main/db 0.386s

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

8.再谈 mock

我们对不需要或无法执行单测时运行的函数将其 mock 住,模拟返回一个假值。谈到 mock,我们有必要说一下与 mock 相关的概念。。

8.1 test doubles

在《xUnit Test Patterns》一书中,作者首次提出 test doubles(测试替身)的概念。我们常挂在嘴边的 mock 只是其中一种,而且是最容易与 stub(打桩)混淆的一种。在前文中对 gomonkey 的介绍,你可以注意到了,我没有使用 mock,全部是 stub。是的,gomonkey 不是mock工具,只是一个高级打桩的工具,适配了我们大部分的使用场景。

测试替身,共有五种:可以参考这篇翻译《xUnit Test Patterns》学习笔记6 - Test Double 。

  • Dummy Object
    用于传递给调用者但是永远不会被真实使用的对象,通常它们只是用来填满参数列表

  • Test Stub
    Stubs通常用于在测试中提供封装好的响应,譬如有时候编程设定的并不会对所有的调用都进行响应。Stubs也会记录下调用的记录,譬如一个email gateway就是一个很好的例子,它可以用来记录所有发送的信息或者它发送的信息的数目。简而言之,Stubs一般是对一个真实对象的封装

  • Test Spy
    Test Spy像一个间谍,安插在了SUT内部,专门负责将SUT内部的间接输出(indirect outputs)传到外部。它的特点是将内部的间接输出返回给测试案例,由测试案例进行验证,Test Spy只负责获取内部情报,并把情报发出去,不负责验证情报的正确性

  • Mock Object
    针对设定好的调用方法与需要响应的参数封装出合适的对象

  • Fake Object
    Fake 对象常常与类的实现一起起作用,但是只是为了让其他程序能够正常运行,譬如内存数据库就是一个很好的例子。

8.2 mock 与 stub

mock 和 stub 应该是最容易混淆的,而且习惯上我们统一用 mock 去形容模拟返回的能力,习惯成自然,也就把 mock 常挂在嘴边了。

就我的理解,stub 可以理解为 mock 的子集,mock 更强大一些:
在这里插入图片描述

  • mock 可以验证实现过程,验证某个函数是否被执行,被执行几次。
  • mock可以依条件生效,比如传入特定参数,才会使mock效果生效。
  • mock可以指定返回结果。
  • 当 mock 指定任何参数都返回固定的结果时,它等于stub。

只不过,Go 的 mock 工具 gomock 只基于接口生效,不适合大部分项目,而 gomonkey 的 stub 覆盖了大部分的使用场景。

关于 mock 和 stub 的资料还可参考 Mocks Aren’t Stubs 及其译文。

关于 mock 框架,这里给出一个简单的对比说明。

框架类型特点资料
gomockstub1.由Golang官方开发维护的测试框架,实现了较为完整的基于 interface 的 mock 功能;
2.提供相应工具(mockgen)生成单测代码提高单测书写效率。
golang/mock
gomokeystub1. 支持对于函数、方法、接口、局部变量、全局变量、调用序列等插桩;
2. 不支持异包私有函数 mock。
Monkey Patching in Go
gomonkey底层原理剖析.pdf

8.3 不要滥用 mock

mock 虽好,切勿贪杯。

两个门派。

约从 2004-2005 年间,江湖上形成两大门派:经典测试驱动开发派 和 mockist(mock极端派)。

先说 mockist。他主张将被测函数 所有 调用的外面函数,全部 mock。也即,只关注被测函数自己的一行行代码,只要调用其他函数,全都 mock 掉,用假数据来测试。

再说经典测试驱动开发派,他们主张不要滥用 mock,能不 mock 就不 mock,被测单元也不一定是具体的一个函数,可能是多个函数,串起来。必要的时候再 mock。

两个门派相争多年,理论各有利弊,至今仍然共存。存在即合理。比如 mockist,使用了过多的 mock,无法覆盖函数接口,这部分又是很容易出错的;经典派,串的太多,又被质疑是集成测试。

对于我们实际应用,不必强制遵从某一派,结合即可,需要的时候 mock,尽量少 mock,不用纠结。

什么时候适合 mock?

如果一个对象具有以下特征,比较适合使用 mock 对象:

  • 该对象提供非确定的结果(比如当前的时间或者当前的温度)
  • 对象的某些状态难以创建或者重现(比如网络错误或者文件读写错误)
  • 对象方法上的执行太慢(比如在测试开始之前初始化数据库)
  • 该对象还不存在或者其行为可能发生变化(比如测试驱动开发中驱动创建新的类)
  • 该对象必须包含一些专门为测试准备的数据或者方法(后者不适用于静态类型的语言,流行的 mock 框架不能为对象添加新的方法,stub 是可以的)。

因此,不要滥用 mock(stub),当被测方法中调用其他方法函数,第一反应应该走进去串起来,而不是从根部就 mock掉了。

关于 mock 的使用问题,推荐阅读肖鹏的《mock七宗罪》。

9.小结

熟练掌握 Golang 单元测试的书写是一位合格 gopher 的必备技能。

本文从 0 到 1 讲解了 Go 如何编写单元测试,带你快速上手 Golang 单元测试。

使用单测框架编写单测,如 testify + gomonkey 或 goconvey + gomock 均可,可根据实际场景选择使用。关于其他的单测框架,感兴趣的你可自行了解,比如 mock 框架还有 monkey 和 gostub。


参考文献

GoLang快速上手单元测试(思想、框架、实践)
gomonkey 1.0 正式发布!
Package testing 中文文档
《xUnit Test Patterns》学习笔记6 - Test Double
Mocks Aren’t Stubs
Mock并非Stub(翻译)
Golang go 命令

更多推荐