原文中有一句话: 除了网络依赖之外,我们在开发中也会经常用到各种数据库,比如常见的MySQL和Redis等。本文就分别举例来演示如何在编写单元测试的时候对MySQL和Redis进行mock。
为什么我想要学单元测试?是因为实际开发的过程中,需要了一个很麻烦的问题,让我的效率降低了。此问题是:在后端开发过程中,需要经常dubug调试代码,在调试的过程中,总是很发现自己的逻辑问题,哪怕是很小很小的逻辑问题,都需要把项目重新运行,这个过程每次都要耗费30-60s,作为一名程序员,不能容忍。
为了解决解决这个问题,我尝试了几种办法,具体如下
- 使用热加载
之前看到前端写的代码,如果有问题的话,可以直接取IDE里面修改,然后crtl+s保存,然后前端页面就会直接更新好,用时1s,所以我就想着整一个后端的热加载,但是经过测试和请教学长,才发现,热加载只是把程序自动关闭并且重新运行,重新运行的时间,可是一点都不会减少。
所以,使用热加载解决上述问题失败。
- 注释掉一部分不相关的代码
开发go项目的时候,会用到gorm的自动迁移(自动建表),项目的启动时间主要就是用在了这个上面,所以如果我们的数据库对应的结构体没有变动的时候,我们可以把gorm自动迁移的这一部分给注释掉,这样就会节省百分之八十左右的启动时间。
所以,上述问题也算是被很好的解决了。
- 编写单元测试
很早就说要学单元测试,但是一直没有坚持学下去,其中最重要的原因就是:不懂单元测试究竟是干什么的,能够解决什么实际的问题,没有明确的目标和要解决的bug。
昨天在学长偶然的帮助下,学长帮我写了一个单元测试函数,用来测试一个很简单的函数,检查其是否有bug。此函数只是项目中众多函数的其中之一,我只是想测试一下这个函数,不需要运行其他的百分之99的代码,我也不想把代码拷贝出去,然后新建一个文件夹进行测试。
对于简单的函数,单元测试函数很容易编写,但是如果我想要测试一个接口,这个接口里面用到了gorm数据库全局连接,用到了redis连接等等,那就需要打桩,构建上下文环境,不然到时候测试的时候,global_DB 的值是nil,就无法进行正常的测试了。
所以,编写单元测试也可以很好的解决上述问题。
我觉得看了这篇文章,就可以使用第三种方式解决上述问题了,至于这篇文章是否是用来解决这个问题的,我还不敢确定。
具体知识内容
go-sqlmock
原文:sqlmock 是一个实现 sql/driver 的mock库。它不需要建立真正的数据库连接就可以在测试中模拟任何 sql 驱动程序的行为。使用它可以很方便的在编写单元测试的时候mock sql语句的执行结果。
我的理解:没有mysql环境,但是可以模拟出来一个mysql环境,让程序继续运行下去;既然没有连接数据库,那肯定是没有办法操控真实的数据,我们这样做的目的只是为了解决是否程序有逻辑问题。以往想要判断代码逻辑是否问题,必须要连接数据库,但是现在,没有数据库也能进行逻辑判断。
看一下具体代码实现,了解一下为什么没有数据库,却能mock出数据库。
// 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)
}
}
// app_test.go
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")) //不返回正确的结果,而是返回我们想要的error,我们需要什么,就mock什么
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)
}
}
第一段代码中有一个main函数,这个main函数可以注释掉,main函数是在有数据库的环境下使用的,我们使用的是单元测试,不再依赖于main函数就可以运行了。
第一段代码中有两个操作,一个是更新,一个是插入。我们在第二段单元测试的代码中mock出这两条操作。
第二段代码,原文代码没有注释,我在代码上面加上了注释,一看就懂
func TestShouldUpdateStats(t *testing.T) {
//我们mock一个数据库链接,mock出来的数据库链接是空的,什么都干不了
//我们后面的操作,就是让mock出来的数据库链接能够发挥出来作用,当然,无论怎样发挥,但是冒牌货,但是只要能让我们的代码逻辑走通就行
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, 0))
//如果测试函数中的数据库链接使用Exec方法执行了 `UPDATE products xxx`,那返回的的两个参数
//中的第一个参数(Result)的方法LastInsertId的返回值int64(lastInsertID )为1 ,RowsAffected的返回值为1
//这样,即使没有连接真正的数据库,我们也能把程序正常跑下去,从而发现代码逻辑漏洞,进而修复
mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
//将mock的DB对象返回我们的函数中
err = recordStats(db, 2, 3)
if 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)
}
}
miniredis
也是同理,代码如下,不再过多介绍了。
// 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
}
// redis_op_test.gopackage miniredis_demo
import(
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"testing"
"time")funcTestDoSomethingWithRedis(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")
}}
总结
学了这篇文章,已经可以解决部门我最初提出的问题了,唯一的区别是,上面mysql的mock是针对的原生sql ,而我们具体开发使用的是gorm进行数据库的操作,如果对gorm数据库连接mock,是后面需要考虑的问题。
后续如果解决掉此问题,会再发文章进行讲解。