We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
转载自:https://www.liwenzhou.com/posts/Go/golang-unit-test-6/ 示例代码原作者已上传至Github,点击👉🏻https://github.com/Q1mi/golang-unit-test-demo 查看完整源代码。
这个系列中已经介绍了很多 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()
但是这个函数现在隐式包含了一个不确定因素——时间。在不同的时刻我们调用这个函数都可能会得到不一样的结果。想象一下,我们该如何为这个函数编写单元测试呢?
如果不修改系统时间,那么我们就无法为这个函数编写单元测试,这个函数成了“不可测试的代码”(当然可以使用打桩工具对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编写单元测试也更方便了。
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接口。
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
// 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 测试代码。
NewServer
使用依赖注入的方式能够让我们的代码看起来更清晰,但是过多的构造函数也会让主函数的代码迅速膨胀,好在Go 语言提供了一些依赖注入工具(例如 wire ,可以帮助我们更好的管理依赖注入的代码。
最后我们补充一个程序设计的SOLID原则,我们在程序设计时践行以下几个原则会帮助我们写出可测试的代码。
SOLID
有时候在写代码之前多考虑一下代码的设计是否符合上述原则。
至此,Go 语言单元测试系列基本更新完毕,后面会抽时间把 Go 1.18 新增的 Go-Fuzzing 的使用给大家分享一下,主要是现在我还不会:)
The text was updated successfully, but these errors were encountered:
No branches or pull requests
这个系列中已经介绍了很多 Go 单元测试的技能和工具,在这一篇中我们不再介绍编写单元测试的工具而是专注于如何编写可测试的代码。
编写可测试的代码
编写可测试的代码可能比编写单元测试本身更加重要,可测试的代码简单来说就是指我们可以很容易的为其编写单元测试代码。编写单元测试的过程也是一个不断思考的过程,思考我们的代码是否正确的被设计和实现。
接下来,我们将通过几个简单示例来介绍如何编写可测试的代码。
剔除干扰因素
假设我们现在有一个根据时间判断报警信息发送速率的模块,白天工作时间允许大量发送报警信息,而晚上则减小发送速率,凌晨不允许发送报警短信。
这个函数内部使用了
time.Now()
来获取系统的当前时间作为判断的依据,看起来很合理。但是这个函数现在隐式包含了一个不确定因素——时间。在不同的时刻我们调用这个函数都可能会得到不一样的结果。想象一下,我们该如何为这个函数编写单元测试呢?
如果不修改系统时间,那么我们就无法为这个函数编写单元测试,这个函数成了“不可测试的代码”(当然可以使用打桩工具对
time.Now
进行打桩,但那不是本文要强调的重点)。接下来我们该如何改造它?
我们通过为函数传参数的方式传入需要判断的时刻,具体实现如下。
这样我们不仅解决了函数与系统时间的紧耦合,而且还扩展了函数的功能,现在我们可以根据需要获取任意时刻的速率值。为改造后的
judgeRateByTime
编写单元测试也更方便了。接口抽象进行解耦
同样是函数中隐式依赖的问题,假设我们实现了一个获取店铺客单价的需求,它完成的功能就像下面的示例函数。
在之前的章节中我们介绍了如何为上面的代码编写单元测试,但是我们如何避免每次单元测试时都发起真实的HTTP请求呢?亦或者后续我们改变了获取数据的方式(直接读取缓存或改为RPC调用)这个函数该怎么兼容呢?
我们将函数中获取数据的部分抽象为接口类型来优化我们的程序,使其支持模块化的数据源配置。
然后定义一个API类型,它拥有一个通过HTTP请求获取订单数据的
GetOrders
方法,正好实现OrderInfoGetter
接口。将原来的
GetAveragePricePerStore
函数修改为以下实现。经过这番改动之后,我们的代码就能很容易地写出单元测试代码。例如,对于不方便直接请求的HTTP API, 我们就可以进行 mock 测试。
依赖注入代替隐式依赖
我们可能经常会看到类似下面的代码,在应用程序中使用全局变量的方式引入日志库或数据库连接实例等。
上面的代码中 App 中通过引用全局变量的方式将依赖项硬编码到代码中,这种情况下我们在编写单元测试时如何 mock log 变量呢?
此外这样的代码还存在一个更严重的问题——它与具体的日志库程序强耦合。当我们后续因为某些原因需要更换另一个日志库时,我们该如何修改代码呢?
我们应该将依赖项解耦出来,并且将依赖注入到我们的 App 实例中,而不是在其内部隐式调用全局变量。
上面的代码就很容易 mock log实例,完成单元测试。
依赖注入就是指在创建组件(Go 中的 struct)的时候接收它的依赖项,而不是它的初始化代码中引用外部或自行创建依赖项。
上面的代码片段中就通过在构造函数中隐式创建依赖项,这样的代码强耦合、不易扩展,也不容易编写单元测试。我们完全可以通过使用依赖注入的方式,将构造函数中的依赖作为参数传递给构造函数。
不要隐式引用外部依赖(全局变量、隐式输入等),而是通过依赖注入的方式引入依赖。经过这样的修改之后,构造函数
NewServer
的依赖项就很清晰,同时也方便我们编写 mock 测试代码。使用依赖注入的方式能够让我们的代码看起来更清晰,但是过多的构造函数也会让主函数的代码迅速膨胀,好在Go 语言提供了一些依赖注入工具(例如 wire ,可以帮助我们更好的管理依赖注入的代码。
SOLID原则
最后我们补充一个程序设计的
SOLID
原则,我们在程序设计时践行以下几个原则会帮助我们写出可测试的代码。有时候在写代码之前多考虑一下代码的设计是否符合上述原则。
总结
至此,Go 语言单元测试系列基本更新完毕,后面会抽时间把 Go 1.18 新增的 Go-Fuzzing 的使用给大家分享一下,主要是现在我还不会:)
The text was updated successfully, but these errors were encountered: