Golang单元测试快速上手(二) 断言与测试替身

本文详细介绍Go语言中的测试方法,包括原生测试工具的使用、第三方测试库Testify与GoMock的功能对比,以及如何利用Mock对象进行隔离测试。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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

转载请注明出处

第一部分链接:
https://blog.youkuaiyun.com/lin_strong/article/details/109012560


Verify

原生

*testing.T提供了方法来管理测试状态和辅助打印日志等。

Fail方法用于标记测试用例失败,其他类似方法都是其封装;

func (c *T) Fail()

标记为失败的测试不会立即结束,这样就可以继续完成其他的Check,如果需要立即结束任务的话:

func (c *T) FailNow()

FailNow会标记测试失败并立即退出测试。
FailNow通过调用runtime结束当前goroutine的方式实现退出测试,因此必须在测试所在goroutine调用。并且因为结束的是goroutine,不会停止其他测试。
LogLogf方法则用于打印信息到测试的记录日志中去,分别对应fmt.Printlnfmt.Printf,其他类似方法都是其封装;错误日志只有在要求打印,或者测试失败时会真正输出

func (c *T) Log(args ...interface{})
func (c *T) Logf(format string, args ...interface{})

Error = Log + Fail
Errorf = Logf + Fail
Fatal = Log + FailNow
Fatalf = Logf + FailNow

Go语言原生不提供其他测试框架常见的assert,因为官方认为这可能导致用户懒于思考良好的错误处理和汇报:
https://golang.org/doc/faq#assertions

第三方包testify中提供了assert和require:
https://github.com/stretchr/testify

assert

assert package可以:

  • 打印友好,易读的失败描述
  • 有助于写可读性良好的代码
  • 可选地为每个断言做注释一条消息

assert提供了很多方便的Helper函数,底层使用的是Fail系,所以失败时不会导致退出测试
常用的如

  • assert.Equal
  • assert.NotEqual
  • assert.Zero
  • assert.NotZero
  • assert.Nil
  • assert.NotNil

还有如:

  • assert.Implements
  • assert.Contains
  • assert.InDelta

之类的很多很方便的函数。一些示例:

package testifyassert

import (
   "errors"
   "github.com/stretchr/testify/assert"
   "testing"
)

func TestAssertEqual(t *testing.T)  {
   expect := 3
   actual := 2
   assert.Equal(t, expect, actual)
   actual = 3
   assert.Equal(t, expect, actual)
   assert.Equal(t, int32(1), int64(1))
}

func TestAssertInDelta(t *testing.T) {
   expect := 0.5
   actual := 0.47
   assert.InDelta(t, expect, actual, 0.02)
   assert.InDelta(t, expect, actual, 0.04)
}

func ReturnErr() error{
   return errors.New("test")
}

func TestAssertNil(t *testing.T) {
   assert.NotNil(t, ReturnErr())
   assert.Nil(t, ReturnErr())
}

func TestAssertContain(t *testing.T){
   assert.Contains(t, []rune{'A', 'B'}, 'C')
   assert.NotContains(t, []rune{'A', 'B'}, 'C')
}

输出如下:

$ go test -v
=== RUN   TestAssertEqual
--- FAIL: TestAssertEqual (0.00s)
    demo_test.go:12: 
                Error Trace:    demo_test.go:12
                Error:          Not equal: 
                                expected: 3
                                actual  : 2
                Test:           TestAssertEqual
    demo_test.go:15: 
                Error Trace:    demo_test.go:15
                Error:          Not equal: 
                                expected: int32(1)
                                actual  : int64(1)
                Test:           TestAssertEqual
=== RUN   TestAssertInDelta
--- FAIL: TestAssertInDelta (0.00s)
    demo_test.go:21: 
                Error Trace:    demo_test.go:21
                Error:          Max difference between 0.5 and 0.47 allowed is 0.02, but difference was 0.030000000000000027
                Test:           TestAssertInDelta
=== RUN   TestAssertNil
--- FAIL: TestAssertNil (0.00s)
    demo_test.go:31: 
                Error Trace:    demo_test.go:31
                Error:          Expected nil, but got: &errors.errorString{s:"test"}
                Test:           TestAssertNil
=== RUN   TestAssertContain
--- FAIL: TestAssertContain (0.00s)
    demo_test.go:35: 
                Error Trace:    demo_test.go:35
                Error:          []int32{65, 66} does not contain 67
                Test:           TestAssertContain
FAIL
exit status 1
FAIL    go_test_demo/testifyassert     0.830s

注意:equal方法中,int32(1)和int64(1)是不相等的,因为他们类型不一样。要注意转换类型使两个被比较对象类型一致。

require

requireassert 的方法完全一致,调用上只需要直接替换名字即可。但是底层使用的是Fatal系,会直接终止测试。

import "github.com/stretchr/testify/require"

func TestRequire(t *testing.T) {
   require.Equal(t, 0, 1)
   assert.Equal(t, 0, 1)
}

由于require断言失败,后面的断言直接跳过没有执行。

$ go test -run=Requi
--- FAIL: TestRequire (0.00s)
    demo_test.go:41: 
                Error Trace:    demo_test.go:41
                Error:          Not equal: 
                                expected: 0
                                actual  : 1
                Test:           TestRequire
FAIL
exit status 1
FAIL    go_test_demo/testifyassert     1.063s

测试替身

自包含的模块很好测试,但我们经常要测试一些中间的代码,它们依赖于很多其他的模块。而这些其他的模块可能难以预测、有很大延时、不好控制返回值,比如像rpc调用、数据库、特定硬件这样的。我们需要方法掌控它们,这时就可以选择使用测试替身替换掉真实的模块。

测试替身概念

测试替身(test double)在测试中扮演一些函数、数据、模块或库。CUT(Code Under Test)不知道使用的是替身;它按与协作者同样的方式与替身交互。

测试替身并不是完全模拟它替代的那个东西,这使得替身能简单很多。

测试替身可以给CUT提供间接的输入(返回值)或以捕捉的方式,并且能够检查CUT发送给测试替身的间接输出(参数)。

测试替身最简单的形式就是替代真实产品代码的一个测试桩(stub)。

测试替身的类型

Gerard Meszaros的书xUnit Testing Patterns中定义了多种测试替身。

  1. 测试傀儡(Test dummy):只是为了链接器不报警而创建的测试替身。dummy是一个永远不会被调用的桩。它是用来满足编译器、链接器或运行时依赖的。
  2. 测试桩(Test stub):按照当前测试用例的指示返回一些值。
  3. 测试间谍(Test spy):捕获从CUT传来的参数,这样测试用例就可以验证正常的参数被传给了CUT。spy也可以像测试桩一样喂给CUT返回值。
  4. Mock对象:验证被调用的函数,调用顺序以及从CUT传给DOC的参数。同样被编程为返回特定的值给CUT。mock对象常用于处理那种需要多次调用并且每次的调用和响应可能不同的情况。
  5. Fake对象:提供被替换组件的部分实现。fake的实现通常相对于被替换的那个会简单的多。
  6. Exploding fake:如果被调用会导致测试失败。

当讨论测试替身需要的不同行为和能力时,这些名词很有用。但是通常在实践中我们并不需要特意区分它们。你会发现人们常常随意地使用fake、mock和stub。

使用测试替身的时机

不是总无脑用测试替身。能用真的代码时就不要用替身。需要自己判断要不要欺骗CUT。

使用测试替身的一些常见原因:

  1. 硬件解耦:这样就不需要硬件就能测试了,还能提供难以实现的多种输入。
  2. 注入难以产生的输入:通过调整测试替身的返回值,以测试难以测试到的执行路径。
  3. 加速一个慢速collaborator:如数据库、网络服务等。
  4. 依赖于一些易变的东西:如时钟。
  5. 依赖于开发中的东西:这时使用替身使得测试得以继续,同时有助于发现CUT需要,但当前尚未实现的服务需求。
  6. 依赖于一些难以配置的东西:如果DOC难以配置为想要的状态,如数据库。

一个最简单的测试替身

在一些特别简单的情况下,可以直接手写测试替身,如对这样一个特别简单的函数:

package simplestub

func CallTheFuncAndReturnValue(cb func() int) int{
   return cb()
}

其测试可以是这样的:

package simplestub

import (
   "github.com/stretchr/testify/assert"
   "testing"
)

var called = false

func callback() int {
   called = true
   return 10
}

func TestCallTheFunc(t *testing.T) {
   got := CallTheFuncAndReturnValue(callback)
   assert.Equal(t, 10, got)
   assert.True(t, called)
}

Mocking Frameworks

GoMock和Testify是Go的两大Mocking框架。都可以为Go生成Mock对象以辅助测试,他们两的详细比较如下:
GoMock vs. Testify: Mocking frameworks for Go

深入介绍二者对本篇来说可能过长了。或许自己去深入下?

GoMock

https://github.com/golang/mock
https://godoc.org/github.com/golang/mock/gomock#example-Call-Do-CaptureArguments

Testify

https://github.com/stretchr/testify

Mock Code for Test

为了让代码可测试,代码应该尽可能依赖于抽象,而不是直接依赖于实际的模块。

Andrew Gerrand:Go避免mock和fake,而喜欢编写具有广泛接口的代码。
https://talks.golang.org/2014/testing.slide#22

假设在写一个灯的开启策略,不考虑时间延时问题,只是按照一定顺序打开灯。
直觉上大概这样写:

var ctrler = new(ledboard.LedBoard)

func init() {
   ctrler.PowerUp()
}

func TurnOnSequentially(){
   cnt := ctrler.Count()
   for i := 0; i < cnt; i++ {
      ctrler.Open(i)
   }
}

但这样直接依赖于LedBoard这个结构体,难以替换和测试,要是以后不是Led灯了呢?而且为什么一定要是灯?

依赖于接口

更好的做法可能是TurnOnSequentially只依赖于接口,定义接口并传入,其只负责控制接口:

package controller

type Controller interface {
   PowerUp() error
   Open(num int) error
   Close(num int) error
   Count() int
   PowerDown() error
}
package ctrlservice

import "go_test_demo/controller"

func TurnOnSequentially(ctrler controller.Controller){
   cnt := ctrler.Count()
   for i := 0; i < cnt; i++ {
      ctrler.Open(i)
   }
}

这样,我们不关心控制器的具体实现,只依赖于这个接口,同时,也方便了我们监控其行为。

创建实现接口的mock对象

下面我们以官方的Mock库—GoMock为例看看怎么为其写测试。

先进入要mock的接口的同目录下,然后使用mockgen工具:

$ mockgen -source=controller.go -destination=mock/Controller.go

即可在destination选项指定的文件得到生成的mock对象,否则默认输出到标准输出:

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

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

import (
   gomock "github.com/golang/mock/gomock"
   reflect "reflect"
)

// MockController is a mock of Controller interface
type MockController struct {
   ctrl     *gomock.Controller
   recorder *MockControllerMockRecorder
}

// MockControllerMockRecorder is the mock recorder for MockController
type MockControllerMockRecorder struct {
   mock *MockController
}

// NewMockController creates a new mock instance
func NewMockController(ctrl *gomock.Controller) *MockController {
   mock := &MockController{ctrl: ctrl}
   mock.recorder = &MockControllerMockRecorder{mock}
   return mock
}
………………………………

这个mock对象实现了Controller接口。

Setup exception

接下来,为TurnOnSequentially写测试。

接下来简单介绍下在基于gomock的测试中要怎么为mock对象设置期望。

GoMock的所有Mock对象捆绑于一个测试控制器一同设置和验证,需要为每个(子)测试创建一个新TestController,在测试结束时调用Finish来触发Verfiy

ctrl := gomock.NewController(t)
defer ctrl.Finish()

然后New每个mock对象出来,并绑定此测试控制器

mockCtrler := mock_controller.NewMockController(ctrl)

有了mock对象后,就可以通过EXCEPT来设置你期望mock对象会被调用某个函数,应该传递的是什么参数,应该return什么值等。比如我期望至少count方法会被调用一次,当调用时应该返回3:

mockCtrler.EXPECT().Count().Return(3).MinTimes(1)

甚至可以通过gomock.InOrder指定期望的调用顺序。

Test case

这样,我们结合gomock后写出的测试就成了这个样子:

package ctrlservice

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

func TestTurnOnSequentially(t *testing.T) {
    // setup
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()
	mockCtrler := mock_controller.NewMockController(ctrl)

	mockCtrler.EXPECT().Count().Return(3).AnyTimes()
	gomock.InOrder(
		mockCtrler.EXPECT().Open(0).Return(nil),
		mockCtrler.EXPECT().Open(1).Return(nil),
		mockCtrler.EXPECT().Open(2).Return(nil),
	)

    // exercise
	TurnOnSequentially(mockCtrler)
	
	// and verify (with defer)
}

这样,就可以验证

  1. TurnOnSequentially起码会调用接口的Count方法一次(考虑到多次调用以检查count也并不算错),并且我们会给其返回一个3。
  2. TurnOnSequentially会调用Open方法三次,并且是以传参0、1、2的顺序调用的,每次给其返回nil,表示没有出错

当然,也可以进行更精细的exception控制,以保证先会调用一次Count后才调用Open,并允许后面调用任意次Count(如果真觉得有必要控制的这么精细的话):

……
cl := mockCtrler.EXPECT().Count().Return(3)
gomock.InOrder(
   cl,
   mockCtrler.EXPECT().Open(0).Return(nil),
   mockCtrler.EXPECT().Open(1).Return(nil),
   mockCtrler.EXPECT().Open(2).Return(nil),
)
mockCtrler.EXPECT().Count().Return(3).AnyTimes().After(cl)
……

Table-driven with mock

但其实这一个测试还远远不够测试这个函数:

  1. 其实是很容易绕过这么简单的单个测试的,比如这么写:
func TurnOnSequentially(ctrler controller.Controller){
   _ = ctrler.Count()
   for i := 0; i < 3; i++ {
      ctrler.Open(i)
   }
}
  1. 还有很多边界条件要处理,比如nil处理,Open Err时的处理等

想就知道适合写成表驱动的样子。GoLand自动生成的代码是这样的:

func TestTurnOnSequentially(t *testing.T) {
   type args struct {
      ctrler controller.Controller
   }
   tests := []struct {
      name string
      args args
   }{
      // TODO: Add test cases.
   }
   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
      })
   }
}

在带上mock后其实这并不是很合适的表,需要进行一定的改造,实践中比如可以给一个setup函数,并返回一个需要defer的函数。供参考:

package ctrlservicetd

import (
	"errors"
	"github.com/golang/mock/gomock"
	"go_test_demo/controller"
	mock_controller "go_test_demo/controller/mock"
	"testing"
)

func TestTurnOnSequentially(t *testing.T) {
   tests := []struct {
      name  string
      setUp func(t *testing.T) (ctrler controller.Controller, deferFn func())
   }{
      {"Nil", func(t *testing.T) (ctrler controller.Controller, deferFn func()) {
         return nil, nil
      }},
      {"AnyErrorToStop", func(t *testing.T) (ctrler controller.Controller, deferFn func()) {
         ctrl := gomock.NewController(t)
         mockCtrler := mock_controller.NewMockController(ctrl)
         mockCtrler.EXPECT().Count().Return(3).AnyTimes()
         gomock.InOrder(
            mockCtrler.EXPECT().Open(0).Return(nil),
            mockCtrler.EXPECT().Open(1).Return(errors.New("test")),
         )
         return mockCtrler, func() { ctrl.Finish() }
      }},
      {"Count0DoNothing", func(t *testing.T) (ctrler controller.Controller, deferFn func()) {
         ctrl := gomock.NewController(t)
         mockCtrler := mock_controller.NewMockController(ctrl)
         mockCtrler.EXPECT().Count().Return(0)
         return mockCtrler, func() { ctrl.Finish() }
      }},
      {"CountNegativeDoNothing", func(t *testing.T) (ctrler controller.Controller, deferFn func()) {
         ctrl := gomock.NewController(t)
         mockCtrler := mock_controller.NewMockController(ctrl)
         mockCtrler.EXPECT().Count().Return(-1)
         return mockCtrler, func() { ctrl.Finish() }
      }},
      {"Success", func(t *testing.T) (ctrler controller.Controller, deferFn func()) {
         ctrl := gomock.NewController(t)
         mockCtrler := mock_controller.NewMockController(ctrl)
         mockCtrler.EXPECT().Count().Return(3).AnyTimes()
         gomock.InOrder(
            mockCtrler.EXPECT().Open(0).Return(nil),
            mockCtrler.EXPECT().Open(1).Return(nil),
            mockCtrler.EXPECT().Open(2).Return(nil),
         )
         return mockCtrler, func() { ctrl.Finish() }
      }},
      {"Success", func(t *testing.T) (ctrler controller.Controller, deferFn func()) {
         ctrl := gomock.NewController(t)
         mockCtrler := mock_controller.NewMockController(ctrl)
         mockCtrler.EXPECT().Count().Return(5).AnyTimes()
         gomock.InOrder(
            mockCtrler.EXPECT().Open(0).Return(nil),
            mockCtrler.EXPECT().Open(1).Return(nil),
            mockCtrler.EXPECT().Open(2).Return(nil),
            mockCtrler.EXPECT().Open(3).Return(nil),
            mockCtrler.EXPECT().Open(4).Return(nil),
         )
         return mockCtrler, func() { ctrl.Finish() }
      }},
   }
   for _, tt := range tests {
      t.Run(tt.name, func(t *testing.T) {
         ctrler, deferFn := tt.setUp(t)
         if deferFn != nil {
            defer deferFn()
         }

         TurnOnSequentially(ctrler)
      })
   }
}

一个能通过测试的TurnOnSequentially

package ctrlservicetd

import (
	"fmt"
	"go_test_demo/controller"
)

func TurnOnSequentially(ctrler controller.Controller) {
	if ctrler == nil {
		return
	}
	cnt := ctrler.Count()
	if cnt < 0 {
		fmt.Printf("Warn: [TurnOnSequentially] Negative count: %v\n", cnt)
	}
	for i := 0; i < cnt; i++ {
		err := ctrler.Open(i)
		if err != nil {
			fmt.Printf("Error: [TurnOnSequentially] Fail on %v/%v, err: %v\n", i, cnt, err)
			return
		}
	}
}

测试执行结果:

$ go test -v
=== RUN   TestTurnOnSequentially
=== RUN   TestTurnOnSequentially/Nil
=== RUN   TestTurnOnSequentially/AnyErrorToStop
Error: [TurnOnSequentially] Fail on 1/3, err: test
=== RUN   TestTurnOnSequentially/Count0DoNothing
=== RUN   TestTurnOnSequentially/CountNegativeDoNothing
Warn: [TurnOnSequentially] Negative count: -1
=== RUN   TestTurnOnSequentially/Success
=== RUN   TestTurnOnSequentially/Success#01
--- PASS: TestTurnOnSequentially (0.00s)
    --- PASS: TestTurnOnSequentially/Nil (0.00s)
    --- PASS: TestTurnOnSequentially/AnyErrorToStop (0.00s)
    --- PASS: TestTurnOnSequentially/Count0DoNothing (0.00s)
    --- PASS: TestTurnOnSequentially/CountNegativeDoNothing (0.00s)
    --- PASS: TestTurnOnSequentially/Success (0.00s)
    --- PASS: TestTurnOnSequentially/Success#01 (0.00s)
PASS
ok      go_test_demo/ctrlservicetd      0.839s

以及100%的覆盖率:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值