首先我们谈一谈什么是测试,它是验证某种事物是否按预期工作的流程,虽然测试这一概念普遍用于软件行业,但他并不局限于此。
比如你买了一台二手电脑,在交易之前,一定会去试着链接屏幕看显示屏是否正常工作,HDMI,VGA端口,或者USB,AUDIO等接口是否正常输入输出,这个过程其实就是测试。
在程序的世界中,通常需要被测试的内容复杂,测试流程繁琐,往往需要我们编写更多的代码来完成自动化的测试。测试代码需要手动完成,但它可以更快的执行,并且可以让更多的开发人员来共享他们。
下面我们用一个简单的例子来了解一下GO的单元测试
我们有一个Sum方法,它的作用是遍历数组求和。
package main
import "fmt"
func Sum(set []int) int {
var result int
for num := range set {
result += num
}
return result
}
也许细心的你已经发现了其中的问题。我们在mian函数中对他进行简单的测试。
func main() {
set := []int{17, 23, 100, 76, 55}
sum := Sum(set)
fmt.Printf("sum is : %d", sum)
}
结果
sum is : 10
这个答案好像不太符合我们的预期。没错,经过测试我们发现了在循环中忽略了元素的序号。我们对代码稍作修改:
func Sum(set []int) int {
var result int
for _, num := range set {
result += num
}
return result
}
再次测试:
sum is : 271
正确
虽然上面所示的方法可能适用于小型项目,但要在main中来测试所有我们想要检测的内容会变得非常麻烦。幸运的是,在testing包,Go为我们提供一些很好的功能,我们可以在不需要太多学习的情况下使用它们。
接下来在同一个包中,创建一个名为 sum_test.go 的文件,并将下面的代码添加到其中。
package main
import "testing"
func TestSum(t *testing.T) {
set := []int{17, 23, 100, 76, 55}
expected := 271
actual := Sum(set)
if actual != expected {
t.Errorf("Expect %d, but got %d!", expected, actual)
}
}
现在我们要运行我们的测试,所以在终端中切换到main包所在目录,并使用下面的命令运行测试。
得到结果:
➜ awesomeProject go test -v
=== RUN TestSum
--- PASS: TestSum (0.00s)
PASS
ok awesomeProject 0.004s
恭喜!你刚刚使用 Go 内置的 testing 编写了第一个测试。现在,让我们深入了解实际发生的事情。
首先,是我们的文件名。Go 要求所有的测试都在以 _test.go 结尾的文件中。这使得我们在检查另一个 package 包的源代码时,确定哪些文件是测试和哪些文件实现功能非常容易。
在看了文件名之后,我们可以直接跳转到代码中,将测试包导入。它为我们提供了一些类型 (如testing.T) ,这些类型提供常见功能,比如在测试失败时设置错误消息。
接下来,是函数 TestSum()。所有的测试都应该以 func TestXxx(*testing.T) 的格式来编写。其中 Xxx 可以是任何字符或数字,而第一个字符需要是大写字符或数字。(译注:一般,Xxx 就是被测试的函数名)
最后,如上所述,我们使用了 TestSum 函数中的参数 *tesing.T。如果我们没有得到预期的结果,我们使用它来设置一个错误,当我们运行测试时,该错误将显示在终端上。我们回到刚才的例子中,请将测试代码中的 expected 更新为 10,然后使用 go test -v 运行测试。您应该会看到显示如下所示错误信息的输出:
➜ awesomeProject go test -v
=== RUN TestSum
--- FAIL: TestSum (0.00s)
main_test.go:11: Expect 10, but got 271!
FAIL
exit status 1
FAIL awesomeProject 0.004s
我们的例子只是一个简单的方法单。单元测试是针对程序的最小单元来进行正确性检验的测试工作,一个单元可能是单个程序、类、对象、方法等。 ——维基百科
一个机器,由各种细小的零件组成,如果其中某件零件坏了,机器运行故障。必须保证每个零件都按设计图要求的规格,机器才能正常运行。
一个可单元测试的工程,会把业务、功能分割成规模更小、有独立的逻辑部件,称为单元。单元测试的目标,就是保证各个单元的逻辑正确性。单元测试保障工程各个“零件”按“规格”(需求)执行,从而保证整个“机器”(项目)运行正确,最大限度减少bug。
由于每个单元有独立的逻辑,做单元测试时需要隔离外部依赖,确保这些依赖不影响验证逻辑。因为要把各种依赖分离,单元测试会促进工程进行组件拆分,整理工程依赖关系,更大程度减少代码耦合。这样写出来的代码,更好维护,更好扩展,从而提高代码质量。
如果程序有bug,我们运行一次全部单元测试,找到不通过的测试,可以很快地定位对应的执行代码。修复代码后,运行对应的单元测试;如还不通过,继续修改,运行测试…直到测试通过。有时,写那个功能模块的员工已离职,项目运行出错(逻辑错误,非pannic),你根本就不知道调试哪个类。如果离职的员工之前写了单元测试,运行一下立马就找到问题点了。单元测试大大减少调试时间,从而达到节约时间成本的效果。
重构,每个开发者都会经历,重构后把代码改坏了的情况并不少见。以往,写完一个框架,运行APP,没什么问题,完事。由于最初的框架并不是你写的,可谓牵一发动全身,你改1个方法导致整个框架运行失败…
如果你有单元测试,情况大不相同。写完一个功能,把单元测试写了,确保这个功能逻辑正确;写第二个,单元测试…,道理一样,每个功能做到第一点“保证逻辑正确性”,100个功能块拼在一起肯定不出问题。你大可以放心一边重构,一边运行项目;而不是整体重构完,提心吊胆地run。
Test-Driven Development, 测试驱动开发, 是敏捷开发的一项核心实践和技术,也是一种设计方法论。TDD原理是开发功能代码之前,先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过。由于TDD对开发人员要求非常高,跟传统开发思维不一样,因此实施起来相当困难。
测试驱动开发有好处也有坏处。因为每个测试用例都是根据需求来的,或者说把一个大需求分解成若干小需求编写测试用例,所以测试用例写出来后,开发者写的执行代码,必须满足测试用例。如果测试不通过,则修改执行代码,直到测试用例通过。
好处,通过测试的执行代码,肯定满足需求,而且有助于接口编程,降低代码耦合,也极大降低bug出现几率(如果是极限编程,几乎是不可能有bug)。
坏处,1.投入开发资源(时间和精力);2.由于测试用例在未进行代码设计前写;很有可能限制开发者对代码整体设计;3.可能引起开发人员不满情绪,我觉得这点很严重,毕竟不是人人都喜欢单元测试,尽管单元测试会带给我们相当多的好处。
二是与具体实现代码同步进行。先写少量功能代码,紧接着写单元测试(重复这两个过程,直到完成功能代码开发)。其实这种方案跟第一种已经很接近,基本上功能代码开发完,单元测试也差不多完成了。
三是编写完功能代码再写单元测试。我的实践经验告诉我,事后编写的单元测试“粒度”都比较粗。对同样的功能代码,采取前两种方案的结果可能是用10个“小”的单测来覆盖,每个单测比较简单易懂,可读性可维护性都比较好(重构时单测的改动不大);而第三种方案写的单测,往往是用1个“大”的单测来覆盖,这个单测逻辑就比较复杂,因为它要测的东西很多,可读性可维护性就比较差。
建议:我个人是比较推荐单元测试与具体实现代码同步进行这个方案的。只有对需求有一定的理解后才能知道什么是代码的正确性,才能写出有效的单元测试来验证正确性,而能写出一些功能代码则说明对需求有一定理解了。
测试覆盖率是统计通过运行程序包的测试多少代码得到执行。 如果执行测试套件导致80%的语句得到了运行,则测试覆盖率为80%。
在一些企业,用单元测试覆盖率常备拿来当做单元测试是否合格的指标,比如代码覆盖率必须达到90%以上。于是一些人员费尽心思提高单元测试的代码覆盖率,然而这种做法有利也有弊,我们将在以后的文章中讨论这点。
让我们再次回到开头的例子中
main.go
package main
func Sum(set []int) int {
var result int
for _, num := range set {
result += num
}
return result
}
main_test.go
package main
import "testing"
func TestSum(t *testing.T) {
set := []int{17, 23, 100, 76, 55}
expected := 271
actual := Sum(set)
if actual != expected {
t.Errorf("Expect %d, but got %d!", expected, actual)
}
}
我们在该文件的目录下运行
go test -cover
得到main包的单元测试覆盖率
➜ awesomeProject go test -cover
PASS
coverage: 100.0% of statements
ok awesomeProject 0.004s
➜ awesomeProject
或者生成html格式的覆盖率报告
➜ awesomeProject go test -v -coverprofile cover.out main_test.go mian.go
=== RUN TestSum
--- PASS: TestSum (0.00s)
PASS
coverage: 100.0% of statements
ok command-line-arguments 0.004s coverage: 100.0% of statements
➜ awesomeProject go tool cover -html=cover.out -o cover.html
会在当前目录下生成一个cover.out和cover.html文件。用浏览器打开生成的html,绿色表示运行到的代码,红色表示没运行到的代码。
(例子中的代码全部运行到了)
假设我们需要测试一个 TCP 端口的响应是否符合预期,我们编写了这样的一个函数:
package example
import (
"fmt"
"io/ioutil"
"net"
"strings"
)
// GatherTCP : connect and check response
func GatherTCP(host string, port int, excepted string) (bool,error) {
addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d",
host, port))
if err != nil {
return false, err
}
conn, err := net.DialTCP("tcp", nil, addr) // !
if err != nil {
return false, err
}
defer conn.Close()
data, err := ioutil.ReadAll(conn)
if err != nil {
return false, err
}
return strings.EqualFold(string(data), excepted), nil
}
函数功能很简单, 但是这段代码不是一段对测试友好的代码。 我们看下叹号(!)注释的地方,这里创建了一个 TCP 连接( net.TCPConn ),但是因
为 net.DialTCP 函数不能被 mock,因此这个函数必须要和外部进行交互。 所以这个函数的单元测试函数不得不适配这种要求,除了编写单元测试的工作外,需要额
外处理以下情况:
作为一个有追求的新社会青年,我们应该在设计的时候就考虑好如何编写测试用例,我们 用一个模块内的全局变量保存 net.DialTCP 的函数指针,利用这个变量完成单元测试的 mock 处理:
package example
import (
"fmt"
"io/ioutil"
"net"
"strings"
)
// DialTCP : net.DialTCP wrapper
var DialTCP = func(network string, laddr, raddr *net.TCPAddr)(net.Conn, error) {
return net.DialTCP(network, laddr, raddr) // #
}
// GatherTCP : connect and check response
func GatherTCP(host string, port int, excepted string) (bool,error) {
addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d",host, port))
if err != nil {
return false, err
}
conn, er := DialTCP("tcp", nil, addr) // #
if err != nil {
return false, err
}
defer conn.Close()
data, err := ioutil.ReadAll(conn)
if err != nil {
return false, err
}
return strings.EqualFold(string(data), excepted), nil
}
井号(#)注释的地方就是修改点,需要注意:
mock(模拟)对象能够模拟实际依赖对象的功能,同时又不需要非常复杂的准备工作,你需要做的,仅仅就是定义对象接口,然后实现它,再交给测试对象去使用。
使用 gomock 来生成实现 net.Conn 的测试对象:
$ mockgen -package example_test -destination conn_mock_test.go net Conn
命令执行完后会在当前目录生成一个专用于测试的 MockConn 对象,无需修改 conn_mock_test.go文件。
使用示例:
var (
mockCtrl *gomock.Controller
mockConn *MockConn
)
mockCtrl = gomock.NewController(t) // 创建控制器
mockConn = NewMockConn(mockCtrl) // 创建 mock 对象 mockConn.EXPECT().Close().Return(nil) // 声明对应方法期望的响应 mockConn.Close() // 执行并返回上面期望的响应
现在我们可以通过替换掉全局变量 DialTCP 来注入 mock 对象了:
mockCtrl := gomock.NewController(t)
mockConn := NewMockConn(mockCtrl)
dialTCP := example.DialTCP
example.DialTCP = func(network string, laddr, raddr*net.TCPAddr) (net.Conn, error) {
return mockConn, nil
}
ok, err := example.GatherTCP("", 0, "") // call function
example.DialTCP = dialTCP // reset
或者我们可以使用 gostub 来辅助注入:
mockCtrl := gomock.NewController(t)
mockConn := NewMockConn(mockCtrl)
stubs := gostub.Stub(&example.DialTCP, func(network string,
laddr, raddr *net.TCPAddr) (net.Conn, error) {
return mockConn, nil
})
defer stubs.Reset()
ok, err := example.GatherTCP("", 0, "") // call function
通过传递 example.DialTCP
变量的地址,gostub 帮我们封装了替换重置过程。
结合刚才准备的工具,我们可以开始编写单元测试了,主要分为以下几个阶段:
package example_test
import (
"example"
"io"
"net"
"testing"
"github.com/golang/mock/gomock"
)
func TestGatherTCPWithResponse(t *testing.T) {
// setup
mockCtrl := gomock.NewController(t)
mockConn := NewMockConn(mockCtrl)
dialTCP := example.DialTCP
example.DialTCP = func(network string, laddr, raddr
*net.TCPAddr) (net.Conn, error) {
return mockConn, nil
}
response := "awesome"
excepted := "awesome"
pass := true
mockConn.EXPECT().Close().Return(nil)
mockConn.EXPECT().Read(gomock.Any()).AnyTimes().DoAndReturn(fun
c(data []byte) (int, error) {
n := copy(data[:], response)
return n, io.EOF
})
// call
ok, err := example.GatherTCP("", 0, excepted)
// check
if ok != pass {
t.Fatalf("result not equal")
}
if err != nil {
t.Errorf("test error: %v", err)
}
// teardown
mockCtrl.Finish()
example.DialTCP = dialTCP
}
golang 提供了一种定义匿名结构数组的方式:
tests := []struct {
response, excepted string
pass bool
}{
{"", "", true},
{"awesome", "awesome", true}, {"这是中文", "这是中文", true},
{"fail", "nooo", false},
{"这是中文", "这也是中文", false}, }
上面数组定义了5个元素,每个元素都是一个有 response , excepted 和 pass 的结构, 因此我们可以利用这种方式来定义我们的测试用例:
func TestGatherTCP(t *testing.T) {
// setup
mockCtrl := gomock.NewController(t)
mockConn := NewMockConn(mockCtrl)
dialTCP := example.DialTCP
example.DialTCP = func(network string, laddr, raddr
*net.TCPAddr) (net.Conn, error) {
return mockConn, nil
}
// teardown
defer func() {
mockCtrl.Finish()
example.DialTCP = dialTCP
}()
tests := []struct {
response, excepted string
pass bool
}{
{"", "", true},
{"awesome", "awesome", true}, {"这是中文", "这是中文", true},
{"fail", "nooo", false},
{"这是中文", "这也是中文", false}, }
for _, test := range tests {
mockConn.EXPECT().Close().Return(nil)
mockConn.EXPECT().Read(gomock.Any()).AnyTimes().DoAndReturn(
func(data []byte) (int, error) {
n := copy(data[:], test.response)
return n, io.EOF
},
)
ok, err := example.GatherTCP("", 0, test.excepted)
if ok != test.pass {
t.Fatalf("result not equal")
}
if err != nil {
t.Errorf("test error: %v", err)
}
}
}
testify提供了断言和 mock 等一些比标准库更好用的测试工具,配合其提供的 suite 包,
可以编写类似 Python unittest.TestCase 风格的测试用例。
testify suite 要求声明一个内嵌了 suite.Suite 的结构,该结构可以声明 SetupTest 和 TearDownTest 两个方法以在每个测试用例运行时装载和卸载测试环境,如:
type exampleTestSuite struct {
suite.Suite
dialTCP func(network string, laddr, raddr *net.TCPAddr)
(net.Conn, error)
}
func (s *exampleTestSuite) SetupTest() {)
s.dialTCP = example.DialTCP
example.DialTCP = MockDialTCP // replace DialTCP
}
func (s *exampleTestSuite) TearDownTest() {
example.DialTCP = s.dialTCP // reset DialTCP
}
同时,suite 提供了断言相关的方法,使用方式类似 assert 包下类似的方法:
func (s *ExampleTestSuite) TestExample() { assert.NotNil(s.T(), nil, "must fail")
// 等同于
s.NotNil(nil, "must fail")
}
为了兼容 go test 命令,使用 suite 包的时候,需要配置测试入口:
func TestExampleTestSuite(t *testing.T) {
suite.Run(t, &exampleTestSuite{})
}
改动点主要有:
package example_test
import (
"example"
"io"
"net"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/suite"
)
type exampleTestSuite struct {
suite.Suite
mockCtrl *gomock.Controller
mockConn *MockConn
dialTCP func(network string, laddr, raddr *net.TCPAddr)
(net.Conn, error)
}
func (s *exampleTestSuite) SetupTest() {
s.mockCtrl = gomock.NewController(s.T())
s.mockConn = NewMockConn(s.mockCtrl)
s.dialTCP = example.DialTCP
example.DialTCP = func(network string, laddr, raddr
*net.TCPAddr) (net.Conn, error) {
return s.mockConn, nil
} }
func (s *exampleTestSuite) TearDownTest() {
s.mockCtrl.Finish()
s.mockConn = nil
s.mockCtrl = nil
example.DialTCP = s.dialTCP
}
func (s *exampleTestSuite) TestConnected() {
tests := []struct {
response, excepted string
pass bool }{
{"", "", true},
{"awesome", "awesome", true}, {"这是中文", "这是中文", true},
{"fail", "nooo", false},
{"这是中文", "这也是中文", false}, }
for _, test := range tests {
s.mockConn.EXPECT().Close().Return(nil)
s.mockConn.EXPECT().Read(gomock.Any()).AnyTimes().DoAndReturn(f
unc(data []byte) (int, error) {
n := copy(data[:], test.response)
return n, io.EOF
})
ok, err := example.GatherTCP("", 0, test.excepted)
s.Equal(test.pass, ok)
s.Nil(err)
} }
func (s *exampleTestSuite) TestConnectError() {
tests := []struct {
readErr error
}{
{&net.OpError{}},
}
for _, test := range tests {
s.mockConn.EXPECT().Close().Return(nil)
s.mockConn.EXPECT().Read(gomock.Any()).AnyTimes().DoAndReturn(f
unc(data []byte) (int, error) {
return 0, test.readErr
})
ok, err := example.GatherTCP("", 0, "")
s.Equal(false, ok)
s.NotNil(err)
}
}
// TestExampleTestSuite :
func TestExampleTestSuite(t *testing.T) {
suite.Run(t, &exampleTestSuite{})
}
ginkgo 是一个 BDD 风格的测试框架,能够很好地提升单元测试的表达能力。
$ ginkgo bootstrap
这个命令生成 example_suite_test.go文件,用于引导 ginkgo 的启动,无需修改。 添加一个单元测试:
$ ginkgo generate example
$ cat example_test.go
package example_test
import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
. "example" )
var _ = Describe("Exampleas", func() {
})
这个命令生成了example_test.go文件,这是主要编写单元测试的地方。
ginkgo 提供了方便的机制来加载测试环境和卸载测试环境,比如我们需要替换 example.DialTCP ,可以这样操作:
var _ = Describe("Example", func() {
var (
dialTCP = example.DialTCP
mockCtrl *gomock.Controller
mockConn *MockConn
connError error = nil
)
BeforeEach(func() { // 加载测试环境
mockCtrl = gomock.NewController(GinkgoT()) mockConn = NewMockConn(mockCtrl)
example.DialTCP = func(network string, laddr, raddr
*net.TCPAddr) (net.Conn, error) {
return mockConn, connError
}
})
AfterEach(func() { // 卸载测试环境 connError = nil
example.DialTCP = dialTCP
mockCtrl.Finish()
})
})
这样会在每个单元测试之前替换掉 example.DialTCP 并生成一个 MockConn 进行测试。
为了简化单元测试的编写,提倡使用 ginkgo 提供的针对表驱动形式的单元测试 DSL:
package example_test
import ( "io"
"net"
. "github.com/onsi/ginkgo"
. "github.com/onsi/ginkgo/extensions/table"
. "github.com/onsi/gomega"
"example"
"github.com/golang/mock/gomock"
)
var _ = Describe("Example", func() {
var (
dialTCP = example.DialTCP
mockCtrl *gomock.Controller
mockConn *MockConn
)
BeforeEach(func() {
mockCtrl = gomock.NewController(GinkgoT())
mockConn = NewMockConn(mockCtrl)
example.DialTCP = func(network string, laddr, raddr *net.TCPAddr) (net.Conn, error) {
return mockConn, nil
}
})
AfterEach(func() {
example.DialTCP = dialTCP
mockCtrl.Finish()
})
DescribeTable(
"connected", func(response string, excepted string,
pass bool) {
// setup mock
mockConn.EXPECT().Close().Return(nil)
mockConn.EXPECT().Read(gomock.Any()).AnyTimes().DoAndReturn(func(data []byte) (int, error) {
n := copy(data[:], response)
return n, io.EOF
})
ok, err := example.GatherTCP("", 0, excepted)
Expect(err).Should(BeNil())
Expect(ok).Should(Equal(pass))
},
Entry("emtry ok", "", "", true),
Entry("match ok", "awesome", "awesome", true), Entry("chinese ok", "这是中文", "这是中文", true),
Entry("match fail", "fail", "nooo", false),
Entry("chinese fail", "这是中文", "这也是中文", false), )
DescribeTable(
"read error", func(readErr error) {
// setup mock
mockConn.EXPECT().Close().Return(nil)
mockConn.EXPECT().Read(gomock.Any()).AnyTimes().DoAndReturn(func(data []byte) (int, error) {
return 0, readErr
})
ok, err := example.GatherTCP("", 0, "")
Expect(err == nil).Should(BeFalse())
Expect(ok).Should(BeFalse())
},
Entry("connect error", &net.OpError{}),
)
})
每个 Entry 声明了一个测试用例和对应的响应,不需要重复性地进行函数调用和结果检 查,可以极大简化单元测试编写难度。