• 104. Go单测系列4---编写可测试的代码


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

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

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

    一、剔除干扰因素

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

    // 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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    这个函数内部使用了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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这样我们不仅解决了函数与系统时间的紧耦合,而且还扩展了函数的功能,现在我们可以根据需要获取任意时刻的速率值。为改造后的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)
    			}
    		})
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    二、接口抽象进行解耦

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

    // GetAveragePricePerStore 每家店的人均价
    func GetAveragePricePerStore(storeName string) (int64, error) {
    	res, err := http.Get("https://lym.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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

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

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

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

    然后定义一个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://lym.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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    经过这番改动之后,我们的代码就能很容易地写出单元测试代码。例如,对于不方便直接请求的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)
    			}
    		})
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

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

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

    package main
    
    import (
    	"github.com/sirupsen/logrus"
    )
    
    var log = logrus.New()
    
    type App struct{}
    
    func (a *App) Start() {
    	log.Info("app start ...")
    }
    
    func main() {
    	app := &App{}
    	app.Start()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    上面的代码中 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, // 使用传入的依赖项完成初始化
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    上面的代码就很容易 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"),
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

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

    // NewServer Server 构造函数
    func NewServer(conf *Config) *Server {
    	return &Server{
    		// 隐式创建依赖项
    		Config: conf,
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

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

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

    四、SOLID原则

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

    在这里插入图片描述

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

  • 相关阅读:
    【web前端期末大作业】基于HTML+CSS+JavaScript实现代理商销售管理系统后台(8页)
    Ps:图像大小
    设计模式(七)桥接
    怎么写公司百度百科词条,写好的百科词条怎么上传编辑到百度百科
    GraphQL 入门
    vue配置不同环境部署后控制面板是否显示console
    kafka
    CodeTON Round 2 A - D
    机器学习(七)模型选择
    用友U8-OA getSessionList.jsp信息泄露 复现
  • 原文地址:https://blog.csdn.net/YouMing_Li/article/details/136634531