Golang单元测试快速上手(三) 高级技巧

注:本文由linstring原创,博客地址:https://blog.csdn.net/lin_strong

转载请注明出处

第一部分链接:
https://blog.csdn.net/lin_strong/article/details/109012560


文章目录

  • 如何插入测试点/解开依赖
    • For 普通函数
      • 场景
      • 插入测试点
      • 如何GoMock非接口
      • 尝试下Testify's Mock?
    • For 外部依赖
    • For 对象内部依赖
    • For 工厂模式
  • 其他测试技巧
    • 作为Process来测试
    • 测试时序
    • Http test
    • I/O test
  • 其他参考

如何插入测试点/解开依赖

For 普通函数

很多函数都是独立的形式,比如上面的TurnOnSequentially,而不是作为接口的某一方法。如果要测试的对象直接依赖于独立的函数,Mock起来就较为困难了。有些语言可以直接替换掉依赖的函数(比如C语言中使用链接时替代/预处理器替代,解释型语言中直接替掉,Java中也可以直接替),而在Go中则较为困难。但我们可以通过函数指针的方式来解开依赖。

场景

假设我们在测的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package setstubdemo

import (
    "fmt"
    "go_test_demo/service"
    "time"
)

func AFunction() int{<!-- -->
    resp, err := service.ExampleService(&service.ExampleRequest{<!-- -->When: time.Now()})
    if err == nil && resp != nil{<!-- -->
        return resp.Rst
    }else {<!-- -->
        fmt.Printf("Error: resp %#+v, err %v\n", resp, err)
        return 0
    }
}

依赖于一个网络服务(其他同理):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package service

import "time"

type ExampleRequest struct {<!-- -->
   When time.Time
}

type ExampleResponse struct {<!-- -->
   Rst int
}

func ExampleService(req *ExampleRequest) (*ExampleResponse, error){<!-- -->
   time.Sleep(time.Minute * 10)
   return &ExampleResponse{<!-- -->Rst: 10}, nil
}

由于测试环境中无法连接到,或者耗时太长,返回不可控等原因,我们不能真正调用它。而我们也不想改变被测函数的签名,被依赖的函数也不能随便动,因为还有好几个其他地方依赖着它。

插入测试点

这时,可以这么改被依赖的函数以方便注入依赖:

1
2
3
4
5
6
7
8
9
10
11
type ExampleServiceFun func(req *ExampleRequest) (*ExampleResponse, error)

var ExampleServiceStub ExampleServiceFun

func ExampleService(req *ExampleRequest) (*ExampleResponse, error){<!-- -->
   if ExampleServiceStub != nil {<!-- -->
      return ExampleServiceStub(req)
   }
   time.Sleep(time.Minute * 10)
   return &ExampleResponse{<!-- -->Rst: 10}, nil
}

这样我们即不会改变函数原来行为,也还可以继续享受IDE方便的提示功能(如果把ExampleService设为函数指针的话就失去了代码提示的便利,虽然也能打桩),还能很轻松的移除注入的依赖(通过将stub设为nil)。

如何GoMock非接口

现在又产生了一个问题,GoMock不支持Mock Func类型。这也好解决,只要加个有这签名的方法的接口:

1
2
3
type ExampleServiceInterface interface {<!-- -->
   Do(req *ExampleRequest) (*ExampleResponse, error)
}

然后生成:

1
mockgen -source=service.go -destination=mock/ExampleServiceFunc.go

这样,测试用例就长成这样(并不完善,纯粹为示例设置打桩点):

1
2
3
4
5
6
7
8
9
10
11
12
func TestAFunctionWithGoMock(t *testing.T) {<!-- -->
   ctrl := gomock.NewController(t)
   defer ctrl.Finish()
   mockFunc := mock_service.NewMockExampleServiceInterface(ctrl)
   service.ExampleServiceStub = mockFunc.Do
   defer func() {<!-- -->service.ExampleServiceStub = nil}()
   mockFunc.EXPECT().Do(gomock.Any()).Return(&service.ExampleResponse{<!-- -->Rst: 10}, nil)

   got := AFunction()

   assert.Equal(t, 10, got)
}

尝试下Testify’s Mock?

但是多写一个接口定义还是略显繁琐,这种情况我个人更喜欢直接使用testify的mock,因为其mock生成工具mockery支持mock函数,而且用起来更顺手:

1
2
3
4
$ mockery --name=ExampleServiceFun
11 Oct 20 17:53 CST INF Starting mockery dry-run=false version=2.2.1
11 Oct 20 17:53 CST INF Walking dry-run=false version=2.2.1
11 Oct 20 17:53 CST INF Generating mock dry-run=false interface=ExampleServiceFun qualified-name=go_test_demo/service version=2.2.1

这样就直接自动为其生成了mock对象于mocks/ExampleServiceFun.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Code generated by mockery v2.2.1. DO NOT EDIT.

package mocks

import (
    service "go_test_demo/service"

    mock "github.com/stretchr/testify/mock"
)

// ExampleServiceFun is an autogenerated mock type for the ExampleServiceFun type
type ExampleServiceFun struct {<!-- -->
    mock.Mock
}

// Execute provides a mock function with given fields: req
func (_m *ExampleServiceFun) Execute(req *service.ExampleRequest) (*service.ExampleResponse, error) {<!-- -->
    ret := _m.Called(req)

    var r0 *service.ExampleResponse
    if rf, ok := ret.Get(0).(func(*service.ExampleRequest) *service.ExampleResponse); ok {<!-- -->
        r0 = rf(req)
    } else {<!-- -->
        if ret.Get(0) != nil {<!-- -->
            r0 = ret.Get(0).(*service.ExampleResponse)
        }
    }

    var r1 error
    if rf, ok := ret.Get(1).(func(*service.ExampleRequest) error); ok {<!-- -->
        r1 = rf(req)
    } else {<!-- -->
        r1 = ret.Error(1)
    }

    return r0, r1
}

testify的mock和gomock的概念类似,功能上略微弱些。用其写此测试用例的话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package setstubdemo

import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "go_test_demo/service"
    "go_test_demo/service/mocks"
    "testing"
)

func TestAFunctionWithTestifyMock(t *testing.T) {<!-- -->
    mockFunc := new(mocks.ExampleServiceFun)
    defer mockFunc.AssertExpectations(t)
    service.ExampleServiceStub = mockFunc.Execute
    defer func() {<!-- --> service.ExampleServiceStub = nil }()
    mockFunc.On("Execute", mock.Anything).Return(&service.ExampleResponse{<!-- -->Rst: 10}, nil)

    got := AFunction()

    assert.Equal(t, 10, got)
}

可以看到,testify的mock对象是独立verify的,不支持设置相互间顺序,但其实很多情况下够用了。

For 外部依赖

对于想解开对外部函数依赖的情况,换句话说,无法直接修改被依赖函数的代码的时候。

建议加一个中间层,改成依赖中间层,而不是直接依赖外部函数,这样就可以随意注入依赖了。

For 对象内部依赖

其实方法论已经有了,剩下就是活学活用的问题。

为了示例,我们先假装实现一个LedBoard(还记得它实现了Controller接口么)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package controller

type LedBoard struct {<!-- -->
   name string
}

func NewLedBoard(name string) Controller {<!-- -->
   return &LedBoard{<!-- -->name: name}
}

func (l *LedBoard) PowerUp() error {<!-- -->
   panic("implement me")
}

func (l *LedBoard) Open(num int) error {<!-- -->
   panic("implement me")
}

func (l *LedBoard) Close(num int) error {<!-- -->
   panic("implement me")
}

func (l *LedBoard) Count() int {<!-- -->
   panic("implement me")
}

func (l *LedBoard) PowerDown() error {<!-- -->
   panic("implement me")
}

然后我们再写一个主控类,它有一个方法是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package maincontroller

import "go_test_demo/controller"

type MainController struct {<!-- -->
}

func NewMainController() *MainController {<!-- -->
   return &MainController{<!-- -->}
}

func (m *MainController) Blink() {<!-- -->
   ctrler := controller.NewLedBoard("dummy")
   ctrler.PowerUp()
   // Do something
   ctrler.PowerDown()
}

并且你确定不应该修改Blink的签名直接传入接口。那为了能够测试它,可以依样画瓢设个桩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package maincontroller

import "go_test_demo/controller"

type MainController struct {<!-- -->
   blinkCtrlStub controller.Controller
}

func NewMainController() *MainController {<!-- -->
   return &MainController{<!-- -->}
}

func (m *MainController) Blink() {<!-- -->
   var ctrler controller.Controller
   if m.blinkCtrlStub == nil {<!-- -->
      ctrler = controller.NewLedBoard("dummy")
   }else {<!-- -->
      ctrler = m.blinkCtrlStub
   }
   ctrler.PowerUp()
   // Do something
   ctrler.PowerDown()
}

而且这个桩很安全,因为外部访问不到。而由于Test文件和被测文件在同一个目录下,是可以直接访问内部字段的,所以对应的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package maincontroller

import (
    "github.com/golang/mock/gomock"
    "github.com/stretchr/testify/assert"
    "go_test_demo/controller"
    mock_controller "go_test_demo/controller/mock"
    "testing"
)

func TestMainController_Blink(t *testing.T) {<!-- -->
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    mockCtrler := mock_controller.NewMockController(ctrl)
    mc := NewMainController()
    mc.blinkCtrlStub = mockCtrler
    gomock.InOrder(
        mockCtrler.EXPECT().PowerUp(),
        // others
        mockCtrler.EXPECT().PowerDown(),
    )

    mc.Blink()
}

For 工厂模式

那再玩花点,如果方法内部使用的是工厂模式,甚至可以玩玩用mock生产mock。

比如controller的工厂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package controller

type Type int

const (
   LEDBoard Type = 0
)

type FactoryFunc func(t Type) Controller

func (f FactoryFunc) New(t Type) Controller {<!-- -->
   return f(t)
}

type Factory interface {<!-- -->
   New(t Type) Controller
}

func DefaultFactory(t Type) Controller {<!-- -->
   switch t {<!-- -->
   case LEDBoard:
      return NewLedBoard("Ha")
   default:
      return nil
   }
}

咱给它搞个mock对象:

1
mockgen -source=factory.go -destination=mock/Factory.go

然后比如maincontroller那有个RequestNew方法。为了安全,不允许直接传入Controller来注册,只能说明需要增加什么类型的控制器,然后返回给你注册好并启动好的Controller。这时内部就需要用到工厂来生产对象了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (m *MainController)RequestNew(t controller.Type) controller.Controller {<!-- -->
   var fac controller.Factory = controller.FactoryFunc(controller.DefaultFactory)
   ctrler := fac.New(t)
   if ctrler == nil {<!-- -->
      return nil
   }
   err := ctrler.PowerUp()
   if err != nil {<!-- -->
      fmt.Printf("Error: [RequestNew] PowerUp fail, err %v\n", err)
      return nil
   }
   ctrlservicetd.TurnOnSequentially(ctrler)
   return ctrler
}

为了测试它,不能让RequestNew方法直接使用默认的工厂,而应该插个桩:

1
2
3
4
5
6
7
8
9
10
11
12
type MainController struct {<!-- -->
   blinkCtrlStub controller.Controller
   fac controller.Factory
}

func NewMainController() *MainController {<!-- -->
   return &MainController{<!-- -->fac: controller.FactoryFunc(controller.DefaultFactory)}
}
func (m *MainController)RequestNew(t controller.Type) controller.Controller {<!-- -->
   ctrler := m.fac.New(t)
   ……
}

这下我们的测试能够从头控制到尾了??:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func TestMainController_RequestNew(t *testing.T) {<!-- -->
   ctrl := gomock.NewController(t)
   defer ctrl.Finish()
   mockFac := mock_controller.NewMockFactory(ctrl)
   mockCtrler := mock_controller.NewMockController(ctrl)
   mockFac.EXPECT().New(controller.Type(4)).Return(mockCtrler)
   mockCtrler.EXPECT().Count().Return(3).AnyTimes()
   gomock.InOrder(
      mockCtrler.EXPECT().PowerUp(),
      mockCtrler.EXPECT().Open(0).Return(nil),
      mockCtrler.EXPECT().Open(1).Return(nil),
      mockCtrler.EXPECT().Open(2).Return(nil),
   )
   mc := NewMainController()
   mc.fac = mockFac

   got := mc.RequestNew(controller.Type(4))

   assert.Equal(t, got, mockCtrler)
}

其他测试技巧

作为Process来测试

有时,会想要测试一个process而不是一个function的行为

1
2
3
4
func Crasher() {<!-- -->
   fmt.Println("Going down in flames!")
   os.Exit(1)
}

为了测试这个代码,可以把测试生成的二进制文件本身作为一个subprocess来调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
func TestCrasher(t *testing.T) {<!-- -->
   if os.Getenv("BE_CRASHER") == "1" {<!-- -->
      Crasher()
      return
   }
   cmd := exec.Command(os.Args[0], "-test.run=TestCrasher")
   cmd.Env = append(os.Environ(), "BE_CRASHER=1")
   err := cmd.Run()
   if e, ok := err.(*exec.ExitError); ok && !e.Success() {<!-- -->
      return
   }
   t.Fatalf("process ran with err %v, want exit status 1", err)
}

From:https://talks.golang.org/2014/testing.slide#21

测试时序

和时序有关的模块较难测试,经常选择不测试。。。

为了控制时间,代码不能直接依赖于time库,而要依赖于抽象。
https://github.com/juju/ratelimit 有使sleep可测试的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
……
// NewBucketWithClock is identical to NewBucket but injects a testable clock
// interface.
func NewBucketWithClock(fillInterval time.Duration, capacity int64, clock Clock) *Bucket {<!-- -->
   return NewBucketWithQuantumAndClock(fillInterval, capacity, 1, clock)
}
……
// Wait takes count tokens from the bucket, waiting until they are
// available.
func (tb *Bucket) Wait(count int64) {<!-- -->
   if d := tb.Take(count); d > 0 {<!-- -->
      tb.clock.Sleep(d)
   }
}

// WaitMaxDuration is like Wait except that it will
// only take tokens from the bucket if it needs to wait
// for no greater than maxWait. It reports whether
// any tokens have been removed from the bucket
// If no tokens have been removed, it returns immediately.
func (tb *Bucket) WaitMaxDuration(count int64, maxWait time.Duration) bool {<!-- -->
   d, ok := tb.TakeMaxDuration(count, maxWait)
   if d > 0 {<!-- -->
      tb.clock.Sleep(d)
   }
   return ok
}
……
// Clock represents the passage of time in a way that
// can be faked out for tests.
type Clock interface {<!-- -->
   // Now returns the current time.
   Now() time.Time
   // Sleep sleeps for at least the given duration.
   Sleep(d time.Duration)
}

// realClock implements Clock in terms of standard time functions.
type realClock struct{<!-- -->}

// Now implements Clock.Now by calling time.Now.
func (realClock) Now() time.Time {<!-- -->
   return time.Now()
}

// Now implements Clock.Sleep by calling time.Sleep.
func (realClock) Sleep(d time.Duration) {<!-- -->
   time.Sleep(d)
}

Http test

httptest包提供了用于http测试的工具:
https://godoc.org/net/http/httptest

I/O test

如果要mock io.Reader和io.Writer的话:
https://godoc.org/testing/iotest

其他参考

  1. Advanced Testing in Go
  2. 了解下测试驱动开发(Test-driven development, TDD)?
    我的《测试驱动的嵌入式C开发》读书笔记