一、前言
单元测试一直是一个研发过程中老生常谈的话题,能够把单元测试做的比较好的公司也寥寥可数。最近同事开玩笑说最不喜欢的两件事情”接手的代码没有单测和别人让我写单测“,也能看得出大家对单测是又爱又恨。但真实情况是单测确实能够提高质量,一般公司架构团队或TL会要求业务研发有单测指标,但很容易因为 ”成本“ 问题最终以失败收尾,那怎么能够降低单测成本又能享受到单测带来和好处就是本文的”目的“了。
想要实现一个低成本的单测基本要从以下问题入手:
- 代码可测性
- 低成本mock
- 逻辑断言工具
资料汇总:
- 引用:https://mp.weixin.qq.com/s/5ebKsHQm2BjKULv6K0mzPA
- monkey 原理解读
二、【新手入门】单元测试解决什么问题?
单元测试(unit test)是最小、最简单的软件测试形式、这些测试用来评估某一个独立的软件单元,比如一个类,或者一个函数的正确性。这些测试不考虑包含该软件单元的整体系统的正确定。单元测试同时也是一种规范,用来保证某个函数或者模块完全符合系统对其的行为要求。单元测试经常被用来引入测试驱动开发的概念。
在*_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数:
| 测试单数 | 函数名前缀为Test | 测试程序的一些逻辑行为是否正确 | 
| 基准函数 | 函数名前缀为Benchmark | 测试函数的性能 | 
| 示例函数 | 函数名前缀为Example | 提供示例 | 
大家可以看到下面这张图,从最上面依次我们可以理解为“黑盒端到端测试”、“单服务接口测试”以及“方法级别的单元测试”,他们三者的会有两个维度的不同那就是成本和频率,黑盒UI测试一次迭代基本上完整的回归也就1~2次,每次测试按天计数,服务测试可以理解为借口自动化测试脚本,每次测试按环境发布次数计数,频率最高运行成本最低的就是单测,每次提交代码都可以运行一次单测检测。
PS: 单测还有一大成本就是本文提到的写单测的成本,如果这个成本和研发接口的成本差不多这就是一个糟糕的单测,如果单测只有一个接口研发的20%的成本那是非常值得做的。

三、你编码的时候考虑单测可测性了吗?
首先我们可以看看一些主流项目是如何写单测的:
- https://github.com/kubernetes/kubernetes/blob/master/pkg/proxy/service_test.go
- https://github.com/gogs/gogs/blob/main/internal/db/actions_test.go
- https://github.com/gohugoio/hugo/blob/master/source/fileInfo_test.go
	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
 
mock三种主流方案对比:
| 评估项 | gomock | monkey | sqlmock | 
|---|---|---|---|
| 代码侵入性 | 强依赖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” 命令回自动打开页面并执行单测
 
点击目录查看具体代码覆盖率情况:
 
其他各种包:
转载:https://blog.csdn.net/u011142688/article/details/125819979
 
					