• 代码如何写才更容易测试?
  • 发布于 1个月前
  • 62 热度
    0 评论
如何编写出好测试的代码,这里说的测试可不是让QA团队测试的意思而是能让我们自己写单元测试进行初步验证的代码。典型的耦合性太高的代码肯定是不行的,比如特别常见的在Controller层就各种操作数据库的代码,写起单元测试能让你无语凝噎。想写出可测试的代码就得做好项目的分层规划,怎么做好这些需要专门出一个整洁架构的专栏才能讲清楚,今天我们先从最简单的代码层面看看想写出可测试的代码需要遵守的一些规范。

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

剔除干扰因素
假设我们现在有一个根据时间判断报警信息发送速率的模块,白天工作时间允许大量发送报警信息,而晚上则减小发送速率,凌晨不允许发送报警短信。
// 堆代码 duidaima.com
// 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)
   }
  })
 }
}
接口抽象进行解耦
同样是函数中隐式依赖的问题,假设我们实现了一个获取店铺客单价的需求,它完成的功能就像下面的示例函数。
// 堆代码 duidaima.com
// 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 依赖反转原则 应该依赖抽象,而不是某个具体示例。
有时候在写代码之前可以多考虑一下代码的设计是否符合上述原则。
用户评论