如何写出可测试的 Go 代码

这个系列中已经介绍了很多 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
}

这个函数内部使用了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 语言单元测试系列基本更新完毕,后面会抽时间把 Go 1.18 新增的 Go-Fuzzing 的使用给大家分享一下,主要是现在我还不会:)

上一篇:在项目里怎么给 GORM 做单元测试

- END -

扫码关注公众号「网管叨bi叨」

给网管个星标,第一时间吸我的知识 👆

网管为大家整理了一本超实用的《Go 开发参考书》收集了70多条开发实践。去公众号回复【gocookbook】即刻领取!

觉得有用就点个在看  👇👇👇

好的,以下是一段简单的中文版MBTI性格测试代码: ```go package main import ( "bufio" "fmt" "os" "strings" ) func main() { // 设置问题列表 questions := []string{ "1. 我更喜欢在人群中呆着,而不是独自一人。", "2. 我更喜欢关注细节,而不是考虑大局。", "3. 我更喜欢以事实和逻辑为导向,而不是以情感和价值观为导向。", "4. 我更喜欢灵活应对变化,而不是按部就班。", "5. 我更喜欢先想好计划,再开始行动,而不是先行动再想计划。", "6. 我更喜欢在安静的环境中工作,而不是在热闹的环境中。", "7. 我更喜欢将注意力集中在当前需要做的事情上,而不是一心多用。", "8. 我更喜欢与人交往,而不是独来独往。", "9. 我更喜欢直接坦率地表达自己的意见,而不是委婉地表达。", "10.我更喜欢按照自己的想法和方式去做事,而不是按照别人的建议。", } // 设置每个问题对应的选项 options := []string{"A. 是的", "B. 不确定", "C. 不是的"} // 设置每个选项对应的分数 scores := []int{2, 1, 0} // 初始化分数 eScore, iScore := 0, 0 sScore, nScore := 0, 0 tScore, fScore := 0, 0 jScore, pScore := 0, 0 // 开始测试 for _, question := range questions { fmt.Println(question) for _, option := range options { fmt.Println(option) } fmt.Print("请选择您的答案:") reader := bufio.NewReader(os.Stdin) answer, _ := reader.ReadString('\n') answer = strings.TrimSuffix(answer, "\n") switch answer { case "A": eScore += scores[0] sScore += scores[0] tScore += scores[0] jScore += scores[0] case "B": iScore += scores[1] nScore += scores[1] fScore += scores[1] pScore += scores[1] case "C": eScore += scores[2] iScore += scores[2] sScore += scores[2] nScore += scores[2] tScore += scores[2] fScore += scores[2] jScore += scores[2] pScore += scores[2] default: fmt.Println("无效的选项,请重新选择") continue } } // 计算得分并输出结果 eiResult := getMBTIResult(eScore, iScore, "E", "I") snResult := getMBTIResult(sScore, nScore, "S", "N") tfResult := getMBTIResult(tScore, fScore, "T", "F") jpResult := getMBTIResult(jScore, pScore, "J", "P") fmt.Println("您的MBTI性格类型为:", eiResult+snResult+tfResult+jpResult) } // 根据得分计算性格类型 func getMBTIResult(score1, score2 int, type1, type2 string) string { if score1 > score2 { return type1 } else { return type2 } } ``` 这段代码实现了一个简单的中文版MBTI性格测试,用户需要根据自己的喜好选择每个问题的选项,然后程序会自动计算出用户的MBTI性格类型。在这个测试中,每个问题有三个选项,分别对应不同的分数,用户选择的选项会根据对应的分数累加到不同的得分变量中,最后根据得分计算出MBTI性格类型。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值