如何做好单元测试?Golang Mock”三剑客“ gomock、monkey、sqlmock

一、前言

单元测试一直是一个研发过程中老生常谈的话题,能够把单元测试做的比较好的公司也寥寥可数。最近同事开玩笑说最不喜欢的两件事情”接手的代码没有单测和别人让我写单测“,也能看得出大家对单测是又爱又恨。但真实情况是单测确实能够提高质量,一般公司架构团队或TL会要求业务研发有单测指标,但很容易因为 ”成本“ 问题最终以失败收尾,那怎么能够降低单测成本又能享受到单测带来和好处就是本文的”目的“了。

想要实现一个低成本的单测基本要从以下问题入手:

  • 代码可测性
  • 低成本mock
  • 逻辑断言工具

资料汇总:

二、【新手入门】单元测试解决什么问题?

单元测试(unit test)是最小、最简单的软件测试形式、这些测试用来评估某一个独立的软件单元,比如一个类,或者一个函数的正确性。这些测试不考虑包含该软件单元的整体系统的正确定。单元测试同时也是一种规范,用来保证某个函数或者模块完全符合系统对其的行为要求。单元测试经常被用来引入测试驱动开发的概念。

在*_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数:

测试单数函数名前缀为Test测试程序的一些逻辑行为是否正确
基准函数函数名前缀为Benchmark测试函数的性能
示例函数函数名前缀为Example提供示例

大家可以看到下面这张图,从最上面依次我们可以理解为“黑盒端到端测试”、“单服务接口测试”以及“方法级别的单元测试”,他们三者的会有两个维度的不同那就是成本和频率,黑盒UI测试一次迭代基本上完整的回归也就1~2次,每次测试按天计数,服务测试可以理解为借口自动化测试脚本,每次测试按环境发布次数计数,频率最高运行成本最低的就是单测,每次提交代码都可以运行一次单测检测。

PS: 单测还有一大成本就是本文提到的写单测的成本,如果这个成本和研发接口的成本差不多这就是一个糟糕的单测,如果单测只有一个接口研发的20%的成本那是非常值得做的。

在这里插入图片描述

三、你编码的时候考虑单测可测性了吗?

首先我们可以看看一些主流项目是如何写单测的:

	tests := []struct {
		name    string
		message string
		want    []string
	}{
		{
			name:    "no match",
			message: "Hello world!",
			want:    nil,
		},
		{
			name:    "contains issue numbers",
			message: "#123 is fixed, and #456 is WIP",
			want:    []string{"#123", " #456"},
		},
		{
			name:    "contains full issue references",
			message: "#123 is fixed, and user/repo#456 is WIP",
			want:    []string{"#123", " user/repo#456"},
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			got := issueReferencePattern.FindAllString(test.message, -1)
			assert.Equal(t, test.want, got)
		})
	}

看完之后大家是不是都发现了同一个特点,阶段明准备数据->并发执行->断言结果,大家在看看自己开发的业务代码是否可以按照这种方式进行 “高比例覆盖” 呢?

如果可以证明你的可测性做的很不错,如果不行大家就要思考思考以下几个问题了:

  • func 的职责是否清晰,是否一个func只做一件事,是否足够简单
  • 是否有偷懒的入参出参,比较典型的就是一个超大的status向下传递,已经大大超出了这个方法原本需要的入参范围了
  • 一个方法代码量是否在一屏以内,一个200行的代码想要单测是否非常困难的,每一个逻辑嵌套都会带来单测成倍的工作量

四、选择合适mock工具事半功倍

对于这类开源项目或开源组件一般不会有mock的烦恼,因为它们依赖的中间件非常有限,但是对于我们业务开发就不一样了,每一个中间件都是强依赖,比较典型的就是数据库、cache、MQ了,单测时我们又没有真正意义上的中间件环境,那在读取数据返回结果时要怎么办呢?

那就要请我们三大武林高手:gomock、monkey、sqlmock出山了:

  • gomock:强依赖interface进行打桩
  • monkey:方法替换改写
    • https://github.com/bouk/monkey (作者不在更新)
    • https://github.com/agiledragon/gomonkey (已经支持arm和全部go版本)
    • 注意:monkey不支持内联函数,在测试的时候需要通过命令行参数 -gcflags=-l 关闭Go语言的内联优化。
    • monkey不是线程安全的,所以不要把它用到并发的单元测试中。
      • 解决方案:https://github.com/go-kiss/monkey
  • sqlmock:通过中间件底层链接进行mock
    • github.com/DATA-DOG/go-sqlmock
    • github.com/go-redis/redismock
    • github.com/alicebob/miniredis/v2

mock三种主流方案对比:

评估项gomockmonkeysqlmock
代码侵入性强依赖interface,影响编码规范无侵入需要支持动态替换中间件实例
成本一般
灵活性每次增加方法都需要修改mock实现按需mock工具受限、场景受限

在看例子之前一句话概括这三种不同mock工具适用的场景:

  • gomock:适合于 业务代码分层互相依赖已经使用 interface 情况,或对第三方依赖是 interface 情况下使用
  • monkey:万金油无论是对方法、变量都可以mock,甚至官方函数都行,但不支持并行测试,改写方法是全局生效的
  • sqlmock:不太适合func的单测会增加单测范围以及反调用直觉,比较适合于一一个服务的全流程单测

gomock

func Test_GetCountriesList_ToGoMock(t *testing.T) {
    Convey("Countries_ToGoMock", t, func() {
        ctlCity := gomock.NewController(t)
        defer ctlCity.Finish()
        ctlCountries := gomock.NewController(t)
        defer ctlCountries.Finish()
        cityToolMock := mock_Model.NewMockCityTool(ctlCity)
        countriesMock := mock_Model.NewMockCountriesTool(ctlCountries)
        c := []model.Countries{
            {
                Id:            "CN",
                Native:        "中国",
                CallingCode:   86,
                OfficialId:    "CHN",
                Region:        "Asia",
                CountriesIcon: "https://pic.cdn.sunmi.com/CountriesICON/chn.svg",
                Zh:            "中国",
                En:            "China",
            },
        }
        gomock.InOrder(
            countriesMock.EXPECT().GetMapListByType().Return(c, nil),
        )
        mapTool := NewMap(cityToolMock, countriesMock)
        rs, _ := mapTool.GetCountriesList()
        So(rs[0].Zh, ShouldEqual, "中国")
    })
}

monkey

func Test_GetCountriesList_ToMonker(t *testing.T) {
 
    Convey("err", t, func() {
        p := monkey.PatchInstanceMethod(reflect.TypeOf(&model.Countries{}), "GetMapListByType", func(_ *model.Countries) ([]model.Countries, error) {
            c := []model.Countries{
                {
                    Zh: "中国",
                },
            }
            return c, nil
        })
        defer p.Unpatch()
        rs, _ := MapHandelr.GetCountriesList()
        So(rs[0].Zh, ShouldEqual, "中国")
    })
 
}

sqlmock

func Test_GetCountriesList_SqlMock(t *testing.T) {
    Convey("error", t, func() {
        //把匹配器设置成相等匹配器,不设置默认使用正则匹配
        db, mock, err := sqlmock.New()
        if err != nil {
            panic(err)
        }
        rows := sqlmock.NewRows([]string{"zh"}).
            AddRow("中国")
        mock.ExpectQuery("^SELECT \\* FROM `countries`").WillReturnRows(rows)
 
        _DB, err := gorm.Open("mysql", db)
        model.MockDB = _DB
 
        rs, _ := MapHandelr.GetCountriesList()
        fmt.Println(rs)
        So(rs[0].Zh, ShouldEqual, "中国")
    })
}

五、单测工具推荐

断言是单测的灵魂,市面上大多数工具都主要提供的是更好的断言能力。

主流断言工具 github.com/stretchr/testify

testify 绝大多数github开源软件都在使用testify

go get github.com/stretchr/testify/assert
 
func TestSomething(t *testing.T) {
  assert := assert.New(t)
  // assert equality
  assert.Equal(123, 123, "they should be equal")
  // assert inequality
  assert.NotEqual(123, 456, "they should not be equal")
  // assert for nil (good for errors)
  assert.Nil(object)
  // assert for not nil (good when you expect something)
  if assert.NotNil(object) {
    // now we know that object isn't nil, we are safe to make
    // further assertions without causing any errors
    assert.Equal("Something", object.Value)
  }
}

【强烈推荐】流程化单测工具:github.com/smartystreets/goconvey

文档:https://github.com/smartystreets/goconvey/wiki

了解单测的小伙伴一定听说过 ”表格驱动测试“,先定义一堆输入,然后循环测试方法,这里介绍到的goconvey可以称作 ”逻辑驱动测试“,编写单测可以和业务逻辑结合使用goconvey编写一颗逻辑树来覆盖不同的代码分支逻辑。

并且goconvey也有丰富的So断言也支持自定义断言:https://github.com/smartystreets/goconvey/wiki/Assertions

package package_name

import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
)

func TestSpec(t *testing.T) {

	// Only pass t into top-level Convey calls
	Convey("Given some integer with a starting value", t, func() {
		x := 1

		Convey("When the integer is incremented", func() {
			x++

			Convey("The value should be greater by one", func() {
				So(x, ShouldEqual, 2)
			})
		})
	})
}

goconvey自带命令行和可视化工具,项目下执行 “goconvey” 命令回自动打开页面并执行单测
在这里插入图片描述

点击目录查看具体代码覆盖率情况:
在这里插入图片描述

其他各种包:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

文振熙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值