Go单测 mock数据库测试

时间:2021-9-16     作者:smarteng     分类: Go语言


这是Go语言单元测试从零到溜系列教程的第2篇,介绍了如何使用go-sqlmock和miniredis工具进行MySQL和Redis的mock测试。
在上一篇《Go单测从零到溜系列——1.网络测试》中,我们介绍了如何使用httptest和gock工具进行网络测试。
除了网络依赖之外,我们在开发中也会经常用到各种数据库,比如常见的MySQL和Redis等。本文就分别举例来演示如何在编写单元测试的时候对MySQL和Redis进行mock。

go-sqlmock

sqlmock 是一个实现 sql/driver 的mock库。它不需要建立真正的数据库连接就可以在测试中模拟任何 sql 驱动程序的行为。使用它可以很方便的在编写单元测试的时候mock sql语句的执行结果。

安装

go  get  github.com/DATA-DOG/go-sqlmock

使用示例

这里使用的是 go-sqlmock 官方文档中提供的基础示例代码。在下面的代码中,我们实现了一个 recordStats 函数用来记录用户浏览商品时产生的相关数据。具体实现的功能是在一个事务中进行以下两次SQL操作:

  • products 表中将当前商品的浏览次数+1
  • product_viewers 表中记录浏览当前商品的用户id
//  app.go
package  main

import  "database/sql"

//  recordStats  记录用户浏览产品信息
func  recordStats(db  *sql.DB,  userID,  productID  int64)  (err  error)  {
  //  开启事务
  //  操作views和product_viewers两张表
  tx,  err  :=  db.Begin()
  if  err  !=  nil  {
    return
  }

  defer  func()  {
    switch  err  {
    case  nil:
      err  =  tx.Commit()
    default:
      tx.Rollback()
    }
  }()

  //  更新products表
  if  _,  err  =  tx.Exec("UPDATE  products  SET  views  =  views  +  1");  err  !=  nil  {
    return
  }
  //  product_viewers表中插入一条数据
  if  _,  err  =  tx.Exec(
    "INSERT  INTO  product_viewers  (user_id,  product_id)  VALUES  (?,  ?)",
    userID,  productID);  err  !=  nil  {
    return
  }
  return
}

func  main()  {
  //  注意:测试的过程中并不需要真正的连接
  db,  err  :=  sql.Open("mysql",  "root@/blog")
  if  err  !=  nil  {
    panic(err)
  }
  defer  db.Close()
  //  userID为1的用户浏览了productID为5的产品
  if  err  =  recordStats(db,  1  /*some  user  id*/,  5  /*some  product  id*/);  err  !=  nil  {
    panic(err)
  }
}

现在我们需要为代码中的 recordStats 函数编写单元测试,但是又不想在测试过程中连接真实的数据库进行测试。这个时候我们就可以像下面示例代码中那样使用 sqlmock 工具去mock数据库操作。

package  main

import  (
  "fmt"
  "testing"

  "github.com/DATA-DOG/go-sqlmock"
)

//  TestShouldUpdateStats  sql执行成功的测试用例
func  TestShouldUpdateStats(t  *testing.T)  {
  //  mock一个*sql.DB对象,不需要连接真实的数据库
  db,  mock,  err  :=  sqlmock.New()
  if  err  !=  nil  {
    t.Fatalf("an  error  '%s'  was  not  expected  when  opening  a  stub  database  connection",  err)
  }
  defer  db.Close()

  //  mock执行指定SQL语句时的返回结果
  mock.ExpectBegin()
  mock.ExpectExec("UPDATE  products").WillReturnResult(sqlmock.NewResult(1,  1))
  mock.ExpectExec("INSERT  INTO  product_viewers").WithArgs(2,  3).WillReturnResult(sqlmock.NewResult(1,  1))
  mock.ExpectCommit()

  //  将mock的DB对象传入我们的函数中
  if  err  =  recordStats(db,  2,  3);  err  !=  nil  {
    t.Errorf("error  was  not  expected  while  updating  stats:  %s",  err)
  }

  //  确保期望的结果都满足
  if  err  :=  mock.ExpectationsWereMet();  err  !=  nil  {
    t.Errorf("there  were  unfulfilled  expectations:  %s",  err)
  }
}

//  TestShouldRollbackStatUpdatesOnFailure  sql执行失败回滚的测试用例
func  TestShouldRollbackStatUpdatesOnFailure(t  *testing.T)  {
  db,  mock,  err  :=  sqlmock.New()
  if  err  !=  nil  {
    t.Fatalf("an  error  '%s'  was  not  expected  when  opening  a  stub  database  connection",  err)
  }
  defer  db.Close()

  mock.ExpectBegin()
  mock.ExpectExec("UPDATE  products").WillReturnResult(sqlmock.NewResult(1,  1))
  mock.ExpectExec("INSERT  INTO  product_viewers").
    WithArgs(2,  3).
    WillReturnError(fmt.Errorf("some  error"))
  mock.ExpectRollback()

  //  now  we  execute  our  method
  if  err  =  recordStats(db,  2,  3);  err  ==  nil  {
    t.Errorf("was  expecting  an  error,  but  there  was  none")
  }

  //  we  make  sure  that  all  expectations  were  met
  if  err  :=  mock.ExpectationsWereMet();  err  !=  nil  {
    t.Errorf("there  were  unfulfilled  expectations:  %s",  err)
  }
}

上面的代码中,定义了一个执行成功的测试用例和一个执行失败回滚的测试用例,确保我们代码中的每个逻辑分支都能被测试到,提高单元测试覆盖率的同时也保证了代码的健壮性。
执行单元测试,看一下最终的测试结果。

❯  go  test  -v
===  RUN      TestShouldUpdateStats
---  PASS:  TestShouldUpdateStats  (0.00s)
===  RUN      TestShouldRollbackStatUpdatesOnFailure
---  PASS:  TestShouldRollbackStatUpdatesOnFailure  (0.00s)
PASS
ok            golang-unit-test-demo/sqlmock_demo            0.011s

可以看到两个测试用例的结果都符合预期,单元测试通过。
在很多使用ORM工具的场景下,也可以使用 go-sqlmock 库mock数据库操作进行测试。

miniredis

除了经常用到MySQL外,Redis在日常开发中也会经常用到。接下来的这一小节,我们将一起学习如何在单元测试中mock Redis的相关操作。
miniredis是一个纯go实现的用于单元测试的redis server。它是一个简单易用的、基于内存的redis替代品,它具有真正的TCP接口,你可以把它当成是redis版本的 net/http/httptest
当我们为一些包含Redis操作的代码编写单元测试时就可以使用它来mock Redis操作。

安装

go  get  github.com/alicebob/miniredis/v2

使用示例

这里以 github.com/go-redis/redis 库为例,编写了一个包含若干Redis操作的 DoSomethingWithRedis 函数。

//  redis_op.go
package  miniredis_demo

import  (
  "context"
  "github.com/go-redis/redis/v8"  //  注意导入版本
  "strings"
  "time"
)

const  (
  KeyValidWebsite  =  "app:valid:website:list"
)

func  DoSomethingWithRedis(rdb  *redis.Client,  key  string)  bool  {
  //  这里可以是对redis操作的一些逻辑
  ctx  :=  context.TODO()
  if  !rdb.SIsMember(ctx,  KeyValidWebsite,  key).Val()  {
    return  false
  }
  val,  err  :=  rdb.Get(ctx,  key).Result()
  if  err  !=  nil  {
    return  false
  }
  if  !strings.HasPrefix(val,  "https://")  {
    val  =  "https://"  +  val
  }
  //  设置  blog  key  五秒过期
  if  err  :=  rdb.Set(ctx,  "blog",  val,  5*time.Second).Err();  err  !=  nil  {
    return  false
  }
  return  true
}

下面的代码是我使用 miniredis 库为 DoSomethingWithRedis 函数编写的单元测试代码,其中 miniredis 不仅支持mock常用的Redis操作,还提供了很多实用的帮助函数,例如检查key的值是否与预期相等的 s.CheckGet() 和帮助检查key过期时间的 s.FastForward()

//  redis_op_test.go

package  miniredis_demo

import  (
  "github.com/alicebob/miniredis/v2"
  "github.com/go-redis/redis/v8"
  "testing"
  "time"
)

func  TestDoSomethingWithRedis(t  *testing.T)  {
  //  mock一个redis  server
  s,  err  :=  miniredis.Run()
  if  err  !=  nil  {
    panic(err)
  }
  defer  s.Close()

  //  准备数据
  s.Set("q1mi",  "liwenzhou.com")
  s.SAdd(KeyValidWebsite,  "q1mi")

  //  连接mock的redis  server
  rdb  :=  redis.NewClient(&redis.Options{
    Addr:  s.Addr(),  //  mock  redis  server的地址
  })

  //  调用函数
  ok  :=  DoSomethingWithRedis(rdb,  "q1mi")
  if  !ok  {
    t.Fatal()
  }

  //  可以手动检查redis中的值是否复合预期
  if  got,  err  :=  s.Get("blog");  err  !=  nil  ||  got  !=  "https://liwenzhou.com"  {
    t.Fatalf("'blog'  has  the  wrong  value")
  }
  //  也可以使用帮助工具检查
  s.CheckGet(t,  "blog",  "https://liwenzhou.com")

  //  过期检查
  s.FastForward(5  *  time.Second)  //  快进5秒
  if  s.Exists("blog")  {
    t.Fatal("'blog'  should  not  have  existed  anymore")
  }
}

执行执行测试,查看单元测试结果:

❯  go  test  -v
===  RUN      TestDoSomethingWithRedis
---  PASS:  TestDoSomethingWithRedis  (0.00s)
PASS
ok            golang-unit-test-demo/miniredis_demo        0.052s

miniredis 基本上支持绝大多数的Redis命令,大家可以通过查看文档了解更多用法。
当然除了使用 miniredis 搭建本地redis server这种方法外,还可以使用各种打桩工具对具体方法进行打桩。在编写单元测试时具体使用哪种mock方式还是要根据实际情况来决定。

总结

在日常工作开发中为代码编写单元测试时如何处理数据库的依赖是最常见的问题,本文介绍了如何使用 go-sqlmockminiredis 工具mock相关依赖。
在下一篇中,我们将更进一步,详细介绍如何在编写单元测试时mock接口。

标签: 测试