本文是Go单测从零到溜系列的最后一篇,在这一篇中我们不再介绍编写单元测试的工具而是专注于如何编写可测试的代码。

Go单测从零到溜系列6—编写可测试的代码

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

接下来,我们将通过几个简单示例来介绍如何编写可测试的代码。

剔除干扰因素

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

// judgeRate 报警速率决策函数
func judgeRate() int {
	now := time.Now()
	switch hour := now.Hour(); {
	case hour >= 8 && hour < 20:
		return 10
	case hour >= 20 && hour <= 23:
		return 1
	}
	return -1
}

这个函数内部使用了time.Now()来获取系统的当前时间作为判断的依据,看起来很合理。

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

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

接下来我们该如何改造它?

我们通过为函数传参数的方式传入需要判断的时刻,具体实现如下。

// judgeRateByTime 报警速率决策函数
func judgeRateByTime(now time.Time) int {
	switch hour := now.Hour(); {
	case hour >= 8 && hour < 20:
		return 10
	case hour >= 20 && hour <= 23:
		return 1
	}
	return -1
}

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

func Test_judgeRateByTime(t *testing.T) {
	tests := []struct {
		name string
		arg  time.Time
		want int
	}{
		{
			name: "工作时间",
			arg:  time.Date(2022, 2, 18, 11, 22, 33, 0, time.UTC),
			want: 10,
		},
		{
			name: "晚上",
			arg:  time.Date(2022, 2, 18, 22, 22, 33, 0, time.UTC),
			want: 1,
		},
		{
			name: "凌晨",
			arg:  time.Date(2022, 2, 18, 2, 22, 33, 0, time.UTC),
			want: -1,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := judgeRateByTime(tt.arg); got != tt.want {
				t.Errorf("judgeRateByTime() = %v, want %v", got, tt.want)
			}
		})
	}
}

接口抽象进行解耦

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

// GetAveragePricePerStore 每家店的人均价
func GetAveragePricePerStore(storeName string) (int64, error) {
	res, err := http.Get("https://liwenzhou.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://liwenzhou.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:    12062,
			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)
			}
		})
	}
}

依赖注入代替隐式依赖

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

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依赖反转原则应该依赖抽象,而不是某个具体示例。

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

至此,Go 语言单元测试从零到溜系列到此更新完。


扫码关注微信公众号