GO单元测试

GO单元测试

什么是单元测试

首先我们谈一谈什么是测试,它是验证某种事物是否按预期工作的流程,虽然测试这一概念普遍用于软件行业,但他并不局限于此。

比如你买了一台二手电脑,在交易之前,一定会去试着链接屏幕看显示屏是否正常工作,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。

提高代码质量

由于每个单元有独立的逻辑,做单元测试时需要隔离外部依赖,确保这些依赖不影响验证逻辑。因为要把各种依赖分离,单元测试会促进工程进行组件拆分,整理工程依赖关系,更大程度减少代码耦合。这样写出来的代码,更好维护,更好扩展,从而提高代码质量。
如果程序有bug,我们运行一次全部单元测试,找到不通过的测试,可以很快地定位对应的执行代码。修复代码后,运行对应的单元测试;如还不通过,继续修改,运行测试…直到测试通过。有时,写那个功能模块的员工已离职,项目运行出错(逻辑错误,非pannic),你根本就不知道调试哪个类。如果离职的员工之前写了单元测试,运行一下立马就找到问题点了。单元测试大大减少调试时间,从而达到节约时间成本的效果。

放心重构

重构,每个开发者都会经历,重构后把代码改坏了的情况并不少见。以往,写完一个框架,运行APP,没什么问题,完事。由于最初的框架并不是你写的,可谓牵一发动全身,你改1个方法导致整个框架运行失败…
如果你有单元测试,情况大不相同。写完一个功能,把单元测试写了,确保这个功能逻辑正确;写第二个,单元测试…,道理一样,每个功能做到第一点“保证逻辑正确性”,100个功能块拼在一起肯定不出问题。你大可以放心一边重构,一边运行项目;而不是整体重构完,提心吊胆地run。

什么时候写单元测试

  1. 一是在具体实现代码之前,这是测试驱动开发(TDD)所提倡的。

Test-Driven Development, 测试驱动开发, 是敏捷开发的一项核心实践和技术,也是一种设计方法论。TDD原理是开发功能代码之前,先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过。由于TDD对开发人员要求非常高,跟传统开发思维不一样,因此实施起来相当困难。
测试驱动开发有好处也有坏处。因为每个测试用例都是根据需求来的,或者说把一个大需求分解成若干小需求编写测试用例,所以测试用例写出来后,开发者写的执行代码,必须满足测试用例。如果测试不通过,则修改执行代码,直到测试用例通过。
好处,通过测试的执行代码,肯定满足需求,而且有助于接口编程,降低代码耦合,也极大降低bug出现几率(如果是极限编程,几乎是不可能有bug)。
坏处,1.投入开发资源(时间和精力);2.由于测试用例在未进行代码设计前写;很有可能限制开发者对代码整体设计;3.可能引起开发人员不满情绪,我觉得这点很严重,毕竟不是人人都喜欢单元测试,尽管单元测试会带给我们相当多的好处。

  1. 二是与具体实现代码同步进行。先写少量功能代码,紧接着写单元测试(重复这两个过程,直到完成功能代码开发)。其实这种方案跟第一种已经很接近,基本上功能代码开发完,单元测试也差不多完成了。

  2. 三是编写完功能代码再写单元测试。我的实践经验告诉我,事后编写的单元测试“粒度”都比较粗。对同样的功能代码,采取前两种方案的结果可能是用10个“小”的单测来覆盖,每个单测比较简单易懂,可读性可维护性都比较好(重构时单测的改动不大);而第三种方案写的单测,往往是用1个“大”的单测来覆盖,这个单测逻辑就比较复杂,因为它要测的东西很多,可读性可维护性就比较差。

建议:我个人是比较推荐单元测试与具体实现代码同步进行这个方案的。只有对需求有一定的理解后才能知道什么是代码的正确性,才能写出有效的单元测试来验证正确性,而能写出一些功能代码则说明对需求有一定理解了。

单元测试的标准

单元测试覆盖率

测试覆盖率是统计通过运行程序包的测试多少代码得到执行。 如果执行测试套件导致80%的语句得到了运行,则测试覆盖率为80%。
在一些企业,用单元测试覆盖率常备拿来当做单元测试是否合格的指标,比如代码覆盖率必须达到90%以上。于是一些人员费尽心思提高单元测试的代码覆盖率,然而这种做法有利也有弊,我们将在以后的文章中讨论这点。

go test中如何得到单元测试覆盖率

让我们再次回到开头的例子中

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,绿色表示运行到的代码,红色表示没运行到的代码。
(例子中的代码全部运行到了)
GO单元测试_第1张图片

优雅的 Golang 单元测试

编写测试友好的代码

假设我们需要测试一个 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,因此这个函数必须要和外部进行交互。 所以这个函数的单元测试函数不得不适配这种要求,除了编写单元测试的工作外,需要额
外处理以下情况:

  • 需要开启一个独立的 TCP 服务做 mock;
  • 处理监听端口冲突的情况;
  • 合理终结和回收 TCP 服务;

作为一个有追求的新社会青年,我们应该在设计的时候就考虑好如何编写测试用例,我们 用一个模块内的全局变量保存 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
  }

井号(#)注释的地方就是修改点,需要注意:

  • 将 net.DialTCP 返回的 *net.TCPConn 对象转换成 net.Conn 接口;
  • 全局变量 DialTCP 需要公开以便被修改;

使用 gomock 来 mock 外部依赖

mock(模拟)对象能够模拟实际依赖对象的功能,同时又不需要非常复杂的准备工作,你需要做的,仅仅就是定义对象接口,然后实现它,再交给测试对象去使用。
GO单元测试_第2张图片

使用 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() // 执行并返回上面期望的响应

注入 mock 对象

现在我们可以通过替换掉全局变量 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 帮我们封装了替换重置过程。

使用 gomock 来编写简单的单元测试

结合刚才准备的工具,我们可以开始编写单元测试了,主要分为以下几个阶段:

  1. setup:装载测试环境(准备测试脚手架,注入 mock 连接,构造测试数据) 2. call:使用测试用例调用测试方法
  2. check:检查方法行为是否符合预期
  3. teardown:卸载测试环境
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 来编写单元测试

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

使用 testify 改写表驱动的单元测试

改动点主要有:

  1. 把之前每个函数都需要主动处理的 setup 和 teardown 过程放到独立的函数由框架 处理;
  2. 把测试逻辑改写成 suite 的方法形式;
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 来编写单元测试

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 改写表驱动形式的单元测试

为了简化单元测试的编写,提倡使用 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 声明了一个测试用例和对应的响应,不需要重复性地进行函数调用和结果检 查,可以极大简化单元测试编写难度。

你可能感兴趣的:(go)