• Welcome to the world's largest Chinese hacker forum

    Welcome to the world's largest Chinese hacker forum, our forum registration is open! You can now register for technical communication with us, this is a free and open to the world of the BBS, we founded the purpose for the study of network security, please don't release business of black/grey, or on the BBS posts, to seek help hacker if violations, we will permanently frozen your IP and account, thank you for your cooperation. Hacker attack and defense cracking or network Security

    business please click here: Creation Security  From CNHACKTEAM

Recommended Posts

Go单测—编写可测试的代码

在本文中,我们不再介绍编写单元测试的工具,而是关注如何编写可测试的代码。源代码

一、编写可测试的代码

编写可测试的代码可能比编写单元测试本身更重要。简单地说,可测试代码意味着我们可以很容易地为它编写单元测试代码。编写单元测试的过程也是一个不断思考我们的代码设计和实现是否正确的过程。

二、剔除干扰因素

假设我们现在有一个根据时间判断报警信息发送速率的模块。白天工作时间允许发送大量报警信息,晚上降低发送速率,凌晨不允许发送报警信息。

//判断报警率决策功能

func judgeRate() int {

现在:=时间。现在()

切换小时:=现在。hour();{

案例时间=8小时20:

返回10

案例时间=20小时=23:

返回1

}

返回-1

}

这个函数在内部使用时间。现在()来获取系统的当前时间作为判断的依据,这似乎是合理的。

但是这个函数现在隐含了一个——小时的不确定因素。当我们在不同的时间调用这个函数时,可能会得到不同的结果。想象一下我们如何为这个函数编写单元测试。

如果我们不修改系统时间,那么我们就无法为这个函数编写单元测试,这个函数就成了“不可测试代码”(当然我们可以堆时间。现在用打桩工具,但这不是本文的重点)。

接下来应该如何转型?

我们通过向函数传递参数来传递要判断的时刻,函数实现如下。

包装可测试

导入“时间”

/*

@作者RandySun

@create 2022-05-01-20:32

*/

//

//判断率

//@Description:报警率决定功能

//@return int

//

func judgeRate() int {

现在:=时间。现在()

切换小时:=现在。hour();{

案例时间=8小时20:

返回10

案例时间=20小时=20:

返回1

}

返回-1

}

//

//judgeRateByTime

//@Description:报警率判定函数,参数为时间

//@param now

//@return int

//

func judgeRateByTime(现在是时间。时间)int {

切换小时:=现在。hour();{

案例时间=8小时20:

返回10

案例时间=20小时=23:

返回1

}

返回-1

}

这样不仅解决了函数和系统时间的紧耦合,还扩展了函数的功能。现在我们可以根据需要随时获取费率值。为修改后的judgeRateByTime编写单元测试也更加方便。

包装可测试

导入(

'测试'

时间

)

func Test _ judgeRateByTime(t * testing。T) {

测试:=[]结构{

名称字符串

参数时间。时间

想要int

}{

{

名称: '工作时间':

args:时间。日期(2022,05,03,11,22,33,0,时间。UTC),

want: 10,

},

{

名称: '夜间',

args:时间。日期(2022,05,03,22,22,33,0,时间。UTC),

want: 1,

},

{

姓名: '清晨':

args:时间。日期(2022,05,03,2,22,33,0,时间。UTC),

want: -1,

},

}

对于_,tt :=范围测试{

t.运行(tt.name,func(t *testing。T) {

如果得到:=judgeRateByT

ime(tt.args); got != tt.want { t.Errorf("judgeRateByTime() = %v, want %v", got, tt.want) } }) } }

执行:

testable> go test -v
=== RUN   Test_judgeRateByTime
=== RUN   Test_judgeRateByTime/工作时间
=== RUN   Test_judgeRateByTime/晚上
=== RUN   Test_judgeRateByTime/凌晨
--- PASS: Test_judgeRateByTime (0.00s)
    --- PASS: Test_judgeRateByTime/工作时间 (0.00s)
    --- PASS: Test_judgeRateByTime/晚上 (0.00s)
    --- PASS: Test_judgeRateByTime/凌晨 (0.00s)
PASS
ok      golang-unit-test-example/10testable     0.244s

image-20220503103810100

三、接口抽象进行解耦

同样是函数中隐式依赖的问题,假设我们实现了一个获取店铺客单价的需求,它完成的功能就像下面的示例函数。

// GetAveragePricePerStore 每家店的人均价
func GetAveragePricePerStore(storeName string) (int64, error) {
	res, err := http.Get("https://shop.com/api/orders?storeName=" + storeName)
	if err != nil {
		return 0, err
	}
	defer res.Body.Close()
	var orders []Order
	if err := json.NewDecoder(res.Body).Decode(&orders); err != nil {
		return 0, err
	}
	if len(orders) == 0 {
		return 0, nil
	}
	var (
		p int64
		n int64
	)
	for _, order := range orders {
		p += order.Price
		n += order.Num
	}
	return p / n, nil
}

在之前的章节中我们介绍了如何为上面的代码编写单元测试,但是我们如何避免每次单元测试时都发起真实的HTTP请求呢?亦或者后续我们改变了获取数据的方式(直接读取缓存或改为RPC调用)这个函数该怎么兼容呢?

我们将函数中获取数据的部分抽象为接口类型来优化我们的程序,使其支持模块化的数据源配置。

// OrderInfoGetter 订单信息提供者
type OrderInfoGetter interface {
	GetOrders(string) ([]Order, error)
}

然后定义一个API类型,它拥有一个通过HTTP请求获取订单数据的GetOrders方法,正好实现OrderInfoGetter接口。

// HttpApi HTTP API类型
type HttpApi struct{}
// GetOrders 通过HTTP请求获取订单数据的方法
func (a HttpApi) GetOrders(storeName string) ([]Order, error) {
	res, err := http.Get("https://shop.com/api/orders?storeName=" + storeName)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	var orders []Order
	if err := json.NewDecoder(res.Body).Decode(&orders); err != nil {
		return nil, err
	}
	return orders, nil
}

将原来的 GetAveragePricePerStore 函数修改为以下实现。

// GetAveragePricePerStore 每家店的人均价
func GetAveragePricePerStore(getter OrderInfoGetter, storeName string) (int64, error) {
	orders, err := getter.GetOrders(storeName)
	if err != nil {
		return 0, err
	}
	if len(orders) == 0 {
		return 0, nil
	}
	var (
		p int64
		n int64
	)
	for _, order := range orders {
		p += order.Price
		n += order.Num
	}
	return p / n, nil
}

经过这番改动之后,我们的代码就能很容易地写出单元测试代码。例如,对于不方便直接请求的HTTP API, 我们就可以进行 mock 测试。

// Mock 一个mock类型
type Mock struct{}
// GetOrders mock获取订单数据的方法
func (m Mock) GetOrders(string) ([]Order, error) {
	return []Order{
		{
			Price: 20300,
			Num:   2,
		},
		{
			Price: 642,
			Num:   5,
		},
	}, nil
}
func TestGetAveragePricePerStore(t *testing.T) {
	type args struct {
		getter    OrderInfoGetter
		storeName string
	}
	tests := []struct {
		name    string
		args    args
		want    int64
		wantErr bool
	}{
		{
			name: "mock test",
			args: args{
				getter:    Mock{},
				storeName: "mock",
			},
			want:    2991,
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := GetAveragePricePerStore(tt.args.getter, tt.args.storeName)
			if (err != nil) != tt.wantErr {
				t.Errorf("GetAveragePricePerStore() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("GetAveragePricePerStore() got = %v, want %v", got, tt.want)
			}
		})
	}
}

执行:

testable> go test -v -run TestGetAveragePricePerStore
=== RUN   TestGetAveragePricePerStore
=== RUN   TestGetAveragePricePerStore/mock_test
--- PASS: TestGetAveragePricePerStore (0.00s)
    --- PASS: TestGetAveragePricePerStore/mock_test (0.00s)
PASS
ok      golang-unit-test-example/10testable     0.246s

image-20220503133330807

四、依赖注入代替隐式依赖

我们可能经常会看到类似下面的代码,在应用程序中使用全局变量的方式引入日志库或数据库连接实例等。实现解耦,

package main
import (
	"github.com/sirupsen/logrus"
)
var log = logrus.New()
type App struct{}
func (a *App) Start() {
	log.Info("app start ...")
}
func (a *app) Start() {
	a.Logger.Info("app start ...")
	// ...
}
func main() {
	app := &App{}
	app.Start()
}

上面的代码中 App 中通过引用全局变量的方式将依赖项硬编码到代码中,这种情况下我们在编写单元测试时如何 mock log 变量呢?

此外这样的代码还存在一个更严重的问题——它与具体的日志库程序强耦合。当我们后续因为某些原因需要更换另一个日志库时,我们该如何修改代码呢?

我们应该将依赖项解耦出来,并且将依赖注入到我们的 App 实例中,而不是在其内部隐式调用全局变量。

type App struct {
	Logger
}
func (a *App) Start() {
	a.Logger.Info("app start ...")
	// ...
}
// NewApp 构造函数,将依赖项注入
func NewApp(lg Logger) *App {
	return &App{
		Logger: lg, // 使用传入的依赖项完成初始化
	}
}

上面的代码就很容易 mock log实例,完成单元测试。

依赖注入就是指在创建组件(Go 中的 struct)的时候接收它的依赖项,而不是它的初始化代码中引用外部或自行创建依赖项。

// Config 配置项结构体
type Config struct {
	// ...
}
// LoadConfFromFile 从配置文件中加载配置
func LoadConfFromFile(filename string) *Config {
	return &Config{}
}
// Server server 程序
type Server struct {
	Config *Config
}
// NewServer Server 构造函数
func NewServer() *Server {
	return &Server{
    // 隐式创建依赖项
		Config: LoadConfFromFile("./config.toml"),
	}
}

上面的代码片段中就通过在构造函数中隐式创建依赖项,这样的代码强耦合、不易扩展,也不容易编写单元测试。我们完全可以通过使用依赖注入的方式,将构造函数中的依赖作为参数传递给构造函数。

// NewServer Server 构造函数
func NewServer(conf *Config) *Server {
	return &Server{
		// 隐式创建依赖项
		Config: conf,
	}
}

不要隐式引用外部依赖(全局变量、隐式输入等),而是通过依赖注入的方式引入依赖。经过这样的修改之后,构造函数NewServer 的依赖项就很清晰,同时也方便我们编写 mock 测试代码。

使用依赖注入的方式能够让我们的代码看起来更清晰,但是过多的构造函数也会让主函数的代码迅速膨胀,好在Go 语言提供了一些依赖注入工具(例如 wire ,可以帮助我们更好的管理依赖注入的代码。

五、SOLID原则

最后我们补充一个程序设计的SOLID原则,我们在程序设计时践行以下几个原则会帮助我们写出可测试的代码。

首字母 指代 概念
S 单一职责原则 每个类都应该只有一个职责。
O 开闭原则 一个软件实体,如类、模块和函数应该对扩展开放,对修改关闭。
L 里式替换原则 认为“程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的”的概念。
I 接口隔离原则 许多特定于客户端的接口优于一个通用接口。
D 依赖反转原则 应该依赖抽象,而不是某个具体示例。

有时候在写代码之前多考虑一下代码的设计是否符合上述原则。

Link to comment
Share on other sites