> 文章列表 > Go 单元测试高效实践

Go 单元测试高效实践

Go 单元测试高效实践

敏捷开发中有一个广为人知的开发方法就是 XP(极限编程),XP 提倡测试先行,为了将以后出现 bug 的几率降到最低,这一点与近些年流行的 TDD(测试驱动开发)有异曲同工之处。

在最开始做编程时,我总是忽略单元测试在代码中的作用,觉得编写单元测试的功夫都赶上甚至超越业务程序了。到后来,业务量越来越复杂,慢慢地,浮现一个问题,就是系统对于测试人员是一个黑盒,简单的测试无法保证系统所设计的东西都可以测试到⬇️
举两个最简单的例子:
系统设计的数据打点,是无法从功能业务上测试出来的,而对于测试人员,可能由于版本差异,用例未覆盖。
如果一个表中有两个字段,新用户过来更新一个字段之后,测另一个字段的功能时就不再以一个新用户的身份操作了。
在这样的情况下,如果开发人员没有对系统做完全的检查,就很可能出现问题。
就以上情况看,需要从开发人员的维度,对功能做一个“预期”测试,一个功能走过,应该输入什么,输出什么,哪些数据变动了,变动是否符合预期等等。

最近,公司业务基本都转入了 Go 做开发,在 Go 的整个业务处理上也日渐完善,而 Go 的单元测试用起来也十分顺手,所以做个小的总结。

一. Mock DB

在单元测试中,很重要的一项就是数据库的 Mock,数据库要在每次单元测试时作为一个干净的初始状态,并且每次运行速度不能太慢。

1. Mysql 的 Mock

这里使用到的是 github.com/dolthub/go-mysql-server 借鉴了这位大哥的方法 如何针对 MySQL 进行 Fake 测试

type Config struct {DSN             string // write data source name.MaxOpenConn     int    // open poolMaxIdleConn     int    // idle poolConnMaxLifeTime int
}var DB *gorm.DB// InitDbConfig 初始化Db
func InitDbConfig(c *conf.Data) {log.Info("Initializing Mysql")var err errordsn := c.Database.DsnmaxIdleConns := c.Database.MaxIdleConnmaxOpenConns := c.Database.MaxOpenConnconnMaxLifetime := c.Database.ConnMaxLifeTimeif DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{QueryFields: true,NamingStrategy: schema.NamingStrategy{//TablePrefix:   "",   // 表名前缀SingularTable: true, // 使用单数表名},}); err != nil {panic(fmt.Errorf("初始化数据库失败: %s \\n", err))}sqlDB, err := DB.DB()if sqlDB != nil {sqlDB.SetMaxIdleConns(int(maxIdleConns))                               // 空闲连接数sqlDB.SetMaxOpenConns(int(maxOpenConns))                               // 最大连接数sqlDB.SetConnMaxLifetime(time.Second * time.Duration(connMaxLifetime)) // 单位:秒}log.Info("Mysql: initialization completed")
}
  • fake-mysql 的初始化和注入

    在 fake_mysql 目录下

var (dbName    = "mydb"tableName = "mytable"address   = "localhost"port      = 3380
)func InitFakeDb() {go func() {Start()}()db.InitDbConfig(&conf.Data{Database: &conf.Data_Database{Dsn:             "no_user:@tcp(localhost:3380)/mydb?timeout=2s&readTimeout=5s&writeTimeout=5s&parseTime=true&loc=Local&charset=utf8,utf8mb4",ShowLog:         true,MaxIdleConn:     10,MaxOpenConn:     60,ConnMaxLifeTime: 4000,},})migrateTable()
}func Start() {engine := sqle.NewDefault(memory.NewMemoryDBProvider(createTestDatabase(),information_schema.NewInformationSchemaDatabase(),))config := server.Config{Protocol: "tcp",Address:  fmt.Sprintf("%s:%d", address, port),}s, err := server.NewDefaultServer(config, engine)if err != nil {panic(err)}if err = s.Start(); err != nil {panic(err)}}func createTestDatabase() *memory.Database {db := memory.NewDatabase(dbName)db.EnablePrimaryKeyIndexes()return db
}func migrateTable() {
// 生成一个user表到fake mysql中err := db.DB.AutoMigrate(&model.User{})if err != nil {panic(err)}
}

在单元测试开始,调用 InitFakeDb()即可

func setup() {fake_mysql.InitFakeDb()
}

2. Redis 的 Mock

这里用到的是 miniredis , 与之配套的Redis Client 是 go-redis/redis/v8,在这里调用 InitTestRedis() 注入即可

// RedisClient redis 客户端  
var RedisClient *redis.Client  // ErrRedisNotFound not exist in redisconst ErrRedisNotFound = redis.Nil  // Config redis config
type Config struct {  Addr         string  Password     string  DB           int  MinIdleConn  int  DialTimeout  time.Duration  ReadTimeout  time.Duration  WriteTimeout time.Duration  PoolSize     int  PoolTimeout  time.Duration  // tracing switch  EnableTrace bool  
}  // Init 实例化一个redis client  
func Init(c *conf.Data) *redis.Client {  RedisClient = redis.NewClient(&redis.Options{  Addr:         c.Redis.Addr,  Password:     c.Redis.Password,  DB:           int(c.Redis.DB),  MinIdleConns: int(c.Redis.MinIdleConn),  DialTimeout:  c.Redis.DialTimeout.AsDuration(),  ReadTimeout:  c.Redis.ReadTimeout.AsDuration(),  WriteTimeout: c.Redis.WriteTimeout.AsDuration(),  PoolSize:     int(c.Redis.PoolSize),  PoolTimeout:  c.Redis.PoolTimeout.AsDuration(),  })  _, err := RedisClient.Ping(context.Background()).Result()  if err != nil {  panic(err)  }  // hook tracing (using open telemetry)  if c.Redis.IsTrace {  RedisClient.AddHook(redisotel.NewTracingHook())  }  return RedisClient  
}  // InitTestRedis 实例化一个可以用于单元测试的redis  
func InitTestRedis() {  mr, err := miniredis.Run()  if err != nil {  panic(err)  }  // 打开下面命令可以测试链接关闭的情况  // defer mr.Close()  RedisClient = redis.NewClient(&redis.Options{  Addr: mr.Addr(),  })  fmt.Println("mini redis addr:", mr.Addr())  
}

二. 单元测试

经过对比,我选择了 goconvey 这个单元测试框架
它比原生的go testing 好用很多。goconvey还提供了很多好用的功能:

  • 多层级嵌套单测
  • 丰富的断言
  • 清晰的单测结果
  • 支持原生go test

使用

go get github.com/smartystreets/goconvey
func TestLoverUsecase_DailyVisit(t *testing.T) {  Convey("Test TestLoverUsecase_DailyVisit", t, func() {  // clean  uc := NewLoverUsecase(log.DefaultLogger, &UsecaseManager{})  Convey("ok", func() {  // execute  res1, err1 := uc.DailyVisit("user1", 3)  So(err1, ShouldBeNil)  So(res1, ShouldNotBeNil)  // 第 n (>=2)次拜访,不应该有奖励,也不应该报错  res2, err2 := uc.DailyVisit("user1", 3)  So(err2, ShouldBeNil)  So(res2, ShouldBeNil)  })  })  
}
可以看到,函数签名和 go 原生的 test 是一致的
测试中嵌套了两层 Convey,外层new了内层Convey所需的参数 
内层调用了函数,对返回值进行了断言

这里的断言也可以像这样对返回值进行比较 So(x, ShouldEqual, 2)
或者判断长度等等 So(len(resMap),ShouldEqual, 2)

Convey的嵌套也可以灵活多层,可以像一棵多叉树一样扩展,足够满足业务模拟。


三. TestMain

为所有的 case 加上一个 TestMain 作为统一入口

import (  
"os"  
"testing"  . "github.com/smartystreets/goconvey/convey"  
)  func TestMain(m *testing.M) {  setup()  code := m.Run()  teardown()  os.Exit(code)
}
// 初始化fake db
func setup() {  fake_mysql.InitFakeDb()  redis.InitTestRedis()
}