解析 Golang 测试(5)- MySQL 经典 mock driver—— sqlmock

4,023 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第18天,点击查看活动详情

时至今日,国内互联网绝大多数公司都在使用 MySQL 作为持久化存储,使用频率非常高。

上一篇文章中我们已经对 Fake,Mock,Stub 的概念做了区分 解析 Golang 测试(4)- 一篇文章教你分清 Mock,Stub,Fake,不清楚的同学建议先阅读一下再看这一篇。

所以,今天我们就从这个切入点入手,聊聊怎样基于 MySQL 来 mock 测试我们的代码。要注意的是,针对 MySQL 的 Test Double,社区里经常见到的主要是两类:

  1. 基于 sqlite 或内存 mysql 的实现,分类上属于 Fake;
  2. 使用 sqlmock 直接来替换 driver,指定一些 predefined behavior,分类上属于 Mock。

这个系列,我们会对上面提到的几种类型进行解析,今天这篇,我们先来关注 mock 下经典的解决方案: sqlmock。

sqlmock 简介

go-sqlmock 本质是一个实现了 sql/driver 接口的 mock 库,它的设计目标是支持在测试中,模拟任何 sql driver 的行为,而不需要一个真正的数据库连接,这对 TDD 很有帮助。

下面是官方列出来的一些 feature:

  • this library is now complete and stable. (you may not find new changes for this reason)
  • supports concurrency and multiple connections.
  • supports go1.8 Context related feature mocking and Named sql parameters.
  • does not require any modifications to your source code.
  • the driver allows to mock any sql driver method behavior.
  • has strict by default expectation order matching.
  • has no third party dependencies.

安装 sqlmock 并不复杂,一个 go get 解决问题:

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

mock 实战

假设我们有一个商品表,需要记录用户访问的一些统计信息,正常的代码可能是这样的:

package main

import (
	"database/sql"

	_ "github.com/go-sql-driver/mysql"
)

func recordStats(db *sql.DB, userID, productID int64) (err error) {
	tx, err := db.Begin()
	if err != nil {
		return
	}

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

	if _, err = tx.Exec("UPDATE products SET views = views + 1"); err != nil {
		return
	}
	if _, err = tx.Exec("INSERT INTO product_viewers (user_id, product_id) VALUES (?, ?)", userID, productID); err != nil {
		return
	}
	return
}

func main() {
	// @NOTE: the real connection is not required for tests
	db, err := sql.Open("mysql", "root@/blog")
	if err != nil {
		panic(err)
	}
	defer db.Close()

	if err = recordStats(db, 1 /*some user id*/, 5 /*some product id*/); err != nil {
		panic(err)
	}
}

这里我们 import 进来经典的 Golang mysql driver 库:github.com/go-sql-driver/mysql 即可,使用 *sql.DB 的事务封装,以及 Exec 方法来执行我们的 sql 语句。

那么,问题来了,我不希望真的把这个数据写进 MySQL,而是希望对一些行为返回指定结果就好,怎么办呢?

下面我们用 sqlmock 来写一下测试case:

package main

import (
	"fmt"
	"testing"

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

// a successful case
func TestShouldUpdateStats(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).WillReturnResult(sqlmock.NewResult(1, 1))
	mock.ExpectCommit()

	// now we execute our method
	if err = recordStats(db, 2, 3); err != nil {
		t.Errorf("error was not expected while updating stats: %s", err)
	}

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

// a failing test case
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)
	}
}

可以看到,整体的流程并不复杂:

  • db, mock, err := sqlmock.New()创建:mock驱动 db, mock控制器 mock;
  • 对 mock 控制器进行配置,指明我们的预期(Expectation),通过mock.Expectxxx函数实现mock的入参及返回;
  • 将 mock 驱动这个 *sql.DB 作为参数传入我们正常的业务实现;
  • 执行完毕后,调用 mock.ExpectationsWereMet 方法,校验是否我们的预期得到满足。

替换 GORM 的 sql/driver 实现

我们前面说了,其实 sqlmock 是对 sql/driver 的实现。而平常如果我们想去用 MySQL 来搭配某个 orm(比如 Gorm)来使用,一定是需要 import 一个 driver 实现的,我们以 gorm 为例,参考一下说明文档:gorm.io/docs/connec…

import (
  "gorm.io/driver/mysql"
  "gorm.io/gorm"
)

func main() {
  // refer https://github.com/go-sql-driver/mysql#dsn-data-source-name for details
  dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
  db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
}

这样就是用默认的 gorm 官方提供的 mysql driver 来连接。而现在我们想用自己的 driver 实现,怎么办呢?很简单,我们直接 import 进来,然后在 mysql.Config 对象里用 DriverName 来指定 driver 名称即可。示例如下:

import (
  _ "example.com/my_mysql_driver"
  "gorm.io/driver/mysql"
  "gorm.io/gorm"
)

db, err := gorm.Open(mysql.New(mysql.Config{
  DriverName: "my_mysql_driver",
  DSN: "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local", // data source name, refer https://github.com/go-sql-driver/mysql#dsn-data-source-name
}), &gorm.Config{})

我们这里其实还有些不一样,因为此时我们并不是有一个现成的 driver 注册,回忆一下前面说的。我们通过 sqlmock.New 构建出来的,其实是一个 mock 驱动,以及一个 mock 控制器。

我们通过 mock 控制器来明确对于不同 sql 语句的预期。通过 mock 驱动来访问 DB。

那么,这种情况下应该如何结合 GORM 使用 sqlmock,让它直接来用我们的 mock 驱动呢?

此前有过开发者在 GORM issue 中问了一样的问题,根据 GORM 作者 jinzhu 大佬在 issue 中的回复来看,其实还是建议直接用真实数据库,(或者 fake 其实是一样的),而不是用 sqlmock

Checkout connect with existing database connection, personally I don't use sqlmock for testing, use real databases.

gorm.io/docs/connec…

但如果我们希望用 sqlmock 这里的 mock 驱动来访问 DB。GORM 也提供了【复用已经存在的 DB 驱动】的能力。

import (
  "database/sql"
  "gorm.io/driver/mysql"
  "gorm.io/gorm"
)

sqlDB, err := sql.Open("mysql", "mydb_dsn")
gormDB, err := gorm.Open(mysql.New(mysql.Config{
  Conn: sqlDB,
}), &gorm.Config{})

我们只需要把这里的 sqlDB 换成通过 sqlmock.New 生成的 mock 驱动即可。

这里需要注意一个点:

MySQL Driver provides few advanced configurations can be used during initialization, for example:

db, err := gorm.Open(mysql.New(mysql.Config{
  DSN: "gorm:gorm@tcp(127.0.0.1:3306)/gorm?charset=utf8&parseTime=True&loc=Local", // data source name
  DefaultStringSize: 256, // default size for string fields
  DisableDatetimePrecision: true, // disable datetime precision, which not supported before MySQL 5.6
  DontSupportRenameIndex: true, // drop & create when rename index, rename index not supported before MySQL 5.7, MariaDB
  DontSupportRenameColumn: true, // `change` when rename column, rename column not supported before MySQL 8, MariaDB
  SkipInitializeWithVersion: false, // auto configure based on currently MySQL version
}), &gorm.Config{})

GORM 官方提供的 Driver 其实还包含了一些高级配置,我们使用 sqlmock 的驱动来替代的时候,要把SkipInitializeWithVersion 这个选项设置为 true。

示例代码:

package main

import (
	"github.com/DATA-DOG/go-sqlmock"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func main() {

	sqlDB, _, err := sqlmock.New()
	if err != nil {
		panic(err)
	}

	gormDB, err := gorm.Open(mysql.New(mysql.Config{
		Conn: sqlDB,
	}), &gorm.Config{
            SkipInitializeWithVersion: true,
        })
	if err != nil {
		panic(err) // Error here
	}

	_ = gormDB
}

劣势

sqlmock 其实已经存在很多年了,从目前业界的态度,普遍看还是不太建议用了,因为目前基于内存的 Fake 实现已经比较成熟,这样用一个 fake working implementation 的心智负担,以及对真实 sql 语句的处理都会更直观,便捷。大体上看,用 sqlmock 的劣势有两点:

  • sqlmock 用起来还是有点繁琐的,不如直接用 sqlite 或其他内存MySQL 实现去端到端检验效果(纯指对dal方法对单测,不是指外层测试穿透).

  • DAL 层代码唯一会担心出问题的地方就是sql写错、返回内容不对或者未命中索引,或是并发问题。而 sqlmock 直接 mock了 DAL层,也就什么都测不出来了;如果我不担心sql出错, 那直接 mock DB 接口就完事了。 而且sql太多,mock起来太累了。 sqlmock对增加dao层代码信心没有帮助。

结语

今天我们了解了 sqlmock 的定位,以及简单用法。这里没有展开细节的配置,建议大家简单了解即可,感兴趣的同学可以自行看一下文档。平常写单测的时候尽量用 Fake 实现,这样能提前暴露 sql 语句的问题。

下来我们会接着介绍 MySQL 的两个经典 Fake 实现,敬请期待。

感谢阅读,有想法的话欢迎评论区交流!

参考资料