原文中有一句话: 除了网络依赖之外,我们在开发中也会经常用到各种数据库,比如常见的MySQL和Redis等。本文就分别举例来演示如何在编写单元测试的时候对MySQL和Redis进行mock。

为什么我想要学单元测试?是因为实际开发的过程中,需要了一个很麻烦的问题,让我的效率降低了。此问题是:在后端开发过程中,需要经常dubug调试代码,在调试的过程中,总是很发现自己的逻辑问题,哪怕是很小很小的逻辑问题,都需要把项目重新运行,这个过程每次都要耗费30-60s,作为一名程序员,不能容忍。

为了解决解决这个问题,我尝试了几种办法,具体如下

  1. 使用热加载

之前看到前端写的代码,如果有问题的话,可以直接取IDE里面修改,然后crtl+s保存,然后前端页面就会直接更新好,用时1s,所以我就想着整一个后端的热加载,但是经过测试和请教学长,才发现,热加载只是把程序自动关闭并且重新运行,重新运行的时间,可是一点都不会减少。

所以,使用热加载解决上述问题失败。

  1. 注释掉一部分不相关的代码

开发go项目的时候,会用到gorm的自动迁移(自动建表),项目的启动时间主要就是用在了这个上面,所以如果我们的数据库对应的结构体没有变动的时候,我们可以把gorm自动迁移的这一部分给注释掉,这样就会节省百分之八十左右的启动时间。

所以,上述问题也算是被很好的解决了。

  1. 编写单元测试

很早就说要学单元测试,但是一直没有坚持学下去,其中最重要的原因就是:不懂单元测试究竟是干什么的,能够解决什么实际的问题,没有明确的目标和要解决的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,是后面需要考虑的问题。

后续如果解决掉此问题,会再发文章进行讲解。