要想需求做的好,单测实践少不了。

Posted 泰 戈 尔

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了要想需求做的好,单测实践少不了。相关的知识,希望对你有一定的参考价值。

Illustration created for “A Journey With Go”, made from the original Go Gopher, created by Renee French.

文章目录

1 写在前面

打开电脑的备忘录,发现第一条已经安静的躺在那里很久了。
趁着今天休假有点时间,就来整理一下关于单测的相关知识点。

2 痛点分析

2.1 成本层面

日常工作中,提到UT,第一反应就是太浪费时间了吧。单测隶属于白盒测试,因此要对边界值等情况做到很好的覆盖,这就不可避免的会消耗人力成本。在原本排期就紧张的开发阶段,再加入单测的撰写,大部分人都是不乐意的。另外,单测的维护成本也会随着大版本的迭代水涨船高。

2.2 编写层面

单测的粒度从来也没有一个统一的标准,约定俗成并不能保证每个单测的粒度是否合适。单测粒度太小,其也就失去了测试的意义;单测粒度太大,内容包含了好几个测试,会让单测代码本身变得臃肿不堪,难以维护。

3 单测依赖

通常我们遇到的单测依赖类型,大部分为如下几类:

  • IO 依赖
    • 网络依赖
    • 数据库依赖
    • 文件依赖
  • 函数、方法依赖

4 单测实践

这里主要是以 go 来示例,其实单测的思想是一致的,就不过多赘述了。

4.1 TDT

首先看看最常见的 TDT(Table Driven Tests, 表格式驱动测试)。第一次听到这个词,还是洪岩(sunhongyan@baidu.com)给我说的,着实不赖。TDT 本身没啥特殊的,只不过是官方 testing 的变种写法。下面通过一个例子来看下具体的使用方法。

/**
 * scheme 增加透传参数
 */
func paddingExtraParams(scheme string, params map[string]string) string 
	if scheme == "" 
		return scheme
	
	paddingLevel := 1
	if !strings.Contains(scheme, "?") 
		scheme += "?"
	else
		if strings.Contains(scheme, "=") 
			paddingLevel = 2
		
	
	if strings.Contains(scheme, "&") 
		paddingLevel = 2
		scheme = strings.TrimRight(scheme, "&")
	
	for key, value := range params 
		switch paddingLevel 
		case 1:
			scheme += fmt.Sprintf("%s=%s&", key, value)
		case 2:
			scheme += fmt.Sprintf("&%s=%s", key, value)
		
	
	scheme = strings.TrimRight(scheme, "&")
	return scheme

对应的 TDT 写法就可以这么写。


func Test_paddingExtraParams(t *testing.T) 
	type args struct 
		cmd    string
		params map[string]string
	
	tests := []struct 
		name string
		args args
		want string
	
		
			name: "empty cmd",
			args: args
				cmd: "",
				params: map[string]string,
			,
			want: "",
		,
		
			name: "no question mark",
			args: args
				cmd: "origin-cmd",
				params: map[string]string
					"name": "tiger",
				,
			,
			want: "origin-cmd?name=tiger",
		,
		
			name: "has question mark 1",
			args: args
				cmd: "origin-cmd?",
				params: map[string]string
					"name": "tiger",
				,
			,
			want: "origin-cmd?name=tiger",
		,
		
			name: "has question mark 2",
			args: args
				cmd: "origin-cmd?param1=value1",
				params: map[string]string
					"name": "tiger",
				,
			,
			want: "origin-cmd?param1=value1&name=tiger",
		,
	
	for _, tt := range tests 
		t.Run(tt.name, func(t *testing.T) 
			if got := paddingExtraParams(tt.args.cmd, tt.args.params); got != tt.want 
				t.Errorf("paddingExtraParams() = %v, want %v", got, tt.want)
			
		)
	

在我看来,适合 TDT 的大致有这么几种场景,感兴趣的可以试一试。

  • 算法
  • 无外部依赖
  • 边界值较多

4.2 Mock

mock比较适合接口层的模拟,在测试包中创建一个结构体,去实现某个外部接口的依赖,以达到数据层模拟的效果。

先看下代码结构

在使用 mock 之前,需要先安装一下 gomock 库以及 mockgen 工具,可以通过如下命令进行安装。

go get github.com/golang/mock

比如,我们想对 car 这个 model 进行 mock,models/dao/car.go

package dao

type Car struct 
	Name string
	Price int


type CarRepository interface
	FindOne(id int)(Car, error)

然后使用 mockgen 工具生成 mock 代码

mockgen -source models/dao/car.go -destination test/models/dao/car_mock.go -package dao
# 如果上面的命令有问题的话,可以使用下面命令做下替换
mockgen -source car.go -destination car_mock.go -package dao
# 把 car_mock.go 放到 tests/models/dao/文件夹下即可,文件夹名称、路径均可自由发挥

就可以看到自动生成的代码car_mock.go

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

// Package mock_dao is a generated GoMock package.
package dao

import (
	gomock "github.com/golang/mock/gomock"
	dao "github.com/guoruibiao/unittest/models/dao"
	reflect "reflect"
)

// MockCarRepository is a mock of CarRepository interface
type MockCarRepository struct 
	ctrl     *gomock.Controller
	recorder *MockCarRepositoryMockRecorder


// MockCarRepositoryMockRecorder is the mock recorder for MockCarRepository
type MockCarRepositoryMockRecorder struct 
	mock *MockCarRepository


// NewMockCarRepository creates a new mock instance
func NewMockCarRepository(ctrl *gomock.Controller) *MockCarRepository 
	mock := &MockCarRepositoryctrl: ctrl
	mock.recorder = &MockCarRepositoryMockRecordermock
	return mock


// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockCarRepository) EXPECT() *MockCarRepositoryMockRecorder 
	return m.recorder


// FindOne mocks base method
func (m *MockCarRepository) FindOne(id int) (dao.Car, error) 
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "FindOne", id)
	ret0, _ := ret[0].(dao.Car)
	ret1, _ := ret[1].(error)
	return ret0, ret1


// FindOne indicates an expected call of FindOne
func (mr *MockCarRepositoryMockRecorder) FindOne(id interface) *gomock.Call 
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockCarRepository)(nil).FindOne), id)


car_mock.go本身即作为car.go内部接口的替代,所以接下来就可以在实际的单测代码中看到如何使用。tests/models/dao/car_test.go

package dao

import (
	"github.com/golang/mock/gomock"
	"github.com/guoruibiao/unittest/models/dao"
	"testing"
)

func TestOne(t *testing.T) 
	ctl := gomock.NewController(t)
	defer ctl.Finish()

	carRepo := NewMockCarRepository(ctl)
	carRepo.EXPECT().FindOne(1).Return(dao.CarName: "保时捷", Price: 1000000, nil)
	carRepo.EXPECT().FindOne(2).Return(dao.CarName: "法拉利", Price: 2500000, nil)
	carRepo.EXPECT().FindOne(3).Return(dao.CarName: "宾利",   Price: 9999999, nil)

	// 结果校验
	testcases := map[int]string
		1: "保时捷",
		2: "法拉利",
		3: "宾利",
	
	for id, want := range testcases 
		if car, err := carRepo.FindOne(id); car.Name != want 
			t.Error(err)
		
	

gomock 内容很多,感兴趣的去官网看看怎么用,我用的不是很多,没什么发言权,就不过多写了。

4.3 Stub

stub本意为,即在测试包中创建一个模拟方法,用于替换测试代码中调用层逻辑。

package dao

import (
	"encoding/json"
	"fmt"
	"github.com/golang/mock/gomock"
	"github.com/guoruibiao/unittest/models/dao"
	"github.com/prashantv/gostub"
	"testing"
	"time"
)

func TestOne(t *testing.T) 
	ctl := gomock.NewController(t)
	defer ctl.Finish()

	carRepo := NewMockCarRepository(ctl)
	carRepo.EXPECT().FindOne(1).Return(dao.CarName: "保时捷", Price: 1000000, nil)
	carRepo.EXPECT().FindOne(2).Return(dao.CarName: "法拉利", Price: 2500000, nil)
	carRepo.EXPECT().FindOne(3).Return(dao.CarName: "宾利",   Price: 9999999, nil)

	// 结果校验
	testcases := map[int]string
		1: "保时捷",
		2: "法拉利",
		3: "宾利",
	
	for id, want := range testcases 
		if car, err := carRepo.FindOne(id); car.Name != want 
			t.Error(err)
		
	


func TestVariable(t *testing.T) 
	var number int
	t.Log("before-stubed: ", number)
	stubs := gostub.Stub(&number, 1008611)
	defer stubs.Reset()

	t.Log("after-stubed: ", number)


func Hello(name string) string 
	return fmt.Sprintf("Hello %s.", name)


func TestFunc(t *testing.T) 
	var HelloFunc = Hello
	t.Log("Before stub:", HelloFunc("tiger"))
	stubs := gostub.StubFunc(&HelloFunc, "tiger")
	defer stubs.Reset()

	t.Log("After stub:", HelloFunc("tiger"))


var MarshalFunc = json.Marshal

func TestOutterFunc(t *testing.T) 
	data := "A"
	t.Log("Before stub:")
	t.Log(MarshalFunc(data))
	stubs := gostub.StubFunc(&MarshalFunc, func(v interface)([]byte, error)
		return []byte("fake-news"), nil
	)
	defer stubs.Reset()

	t.Log("After stub: ")
	t.Log(MarshalFunc(data))


func TestOutterTime(t *testing.T) 
	var TimeFunc = time.Now
	t.Log("Before stub: ", TimeFunc())
	stubs := gostub.StubFunc(&TimeFunc, func()time.Time
		d, _ := time.ParseDuration("+1h")
		return time.Now().Add(d)
	)
	defer stubs.Reset()

	t.Log("After stub: ", TimeFunc())

前几个方法没问题,后面对外部代码打桩时,发现会有错误。

=== RUN   TestOutterFunc
    TestOutterFunc: car_test.go:61: Before stub:
    TestOutterFunc: car_test.go:62: [34 65 34] <nil>
--- FAIL: TestOutterFunc (0.00s)
panic: func type has 2 return values, but only 1 stub values provided [recovered]
	panic: func type has 2 return values, but only 1 stub values provided

姑且当做一个待解决的问题吧。

4.4 Mock 与 Stub 的对比

网上抄了一份别人整理的mock 与 stub 的对比,如下:
1)mock和stub都是采用替换的方式来实现,被测试的函数中的依赖关系,不过mock采用的是接口替换的方式,stub采用的是函数替代的方式。

2)mock的实现对功能代码没有侵入性,stub的侵入性比较强,在实现功能函数的时候,就需要为了测试设置一些回调函数,也就是这里所谓的桩。

3)对于控制被替代的方法来讲,mock如果想支持不同的输出,就需要提前实现不同的分支代码,甚至需要定义不同的mock结构体来实现,这样的mock代码会变成一个支持所有逻辑分支的一个最大集合,mock代码复杂性会变高;stub却能很好的控制桩函数的不同分支,因为stub替换的是函数,那么只要需要再用到这种输出的时候,定义一个函数即可,而这个函数甚至都可以是匿名函数。

4.5 monkey

monkey 是另一种打桩测试方法,个人感觉比 stub 好用,最起码看起来容易理解点。用之前需要进行安装。

go get  bou.ke/monkey
package main

import (
	"bou.ke/monkey"
	"fmt"
)

var foo1 = func (s string) string 
	return fmt.Sprintf("Hello %s.", s)


var foo2 = func (s string) string 
	return fmt.Sprintf("HELLO %s", s)

func main() 
	monkey.Patch(foo1, foo2)
	fmt.Println(foo1("Tiger"))  


// 运行结果
HELLO Tiger

然后再来测试一个外部方法的桩测试。

package main

import (
	"bou.ke/monkey"
	"encoding/json"
	"fmt"
)

var MarshalFunc = json.Marshal

func MarshalFuncOfMine(v interface)([]byte, error) 
	fmt.Println("---------------------")
	fmt.Printf("%#v\\n", v)
	fmt.Println("---------------------")
	return json.Marshal(v)



func main() 
	monkey.Patch(MarshalFunc, MarshalFuncOfMine)
	fmt.Println(MarshalFunc("Tiger"))


5 总结整理

最后来一起回顾一下,本文以痛点开始,介绍了开发同学在编写单测代码时可能的心路历程,然后通过分析单测内部的依赖类型,介绍了对应的较为适合的方案。
软件开发没有银弹,任何想要偷懒的行为本身就是错误的。软件也不是测试出来的,单纯依赖单测,而不主动设计代码结构,最后都会变得难以维护,成为后来者眼中的💩山

建议阅读:go单元测试学习指南

以上是关于要想需求做的好,单测实践少不了。的主要内容,如果未能解决你的问题,请参考以下文章

要想推荐系统做的好,图技术少不了

软件要想做的好,测试必定少不了

单元测试怎么就成了银弹?

单测增量代码覆盖率统计方案

小程序最佳实践之『单测』篇 干货满满!

卓越工程实践之—前端高质量单测