解析 Golang 测试(6)- 如何针对 MySQL 进行 Fake 测试

时间:2022-9-2     作者:smarteng     分类: Go语言


今天继续我们的【解析Golang测试】第六篇,对此前文章感兴趣的同学可以点击进入:

前面我们了解了什么是 Fake 和 Mock,并学习了对 sql 进行 Mock 的经典driver 库 sqlmock ,今天我们则是来看一看,针对 MySQL 我们有什么 Fake 的选项。

(概念不清楚的同学强烈建议看一下我们此前的第 4, 5 两篇文章)

开篇

SQL 本身意味着 Structured Query Language,也就是一套结构化语言。用这套语言来表达出开发者希望查询的数据并不是一件非常容易的事,或者说,很容易犯错。

这也就是为什么,即便我们可以直接将 DAL 这一层给 mock 掉,或者干脆用前一篇文章提到的 sqlmock 来指明我针对 sql 语句的 expectation。一句大白话:担心 SQL 写错。

而且,用 Mock 来处理也会稍显冗余,尤其是针对业内一些经典的存储,我们陆续都有了可以作为 Fake 的系统(一个现成的,无法用于线上,但针对测试来说足够的 working implementation),也没有什么副作用,那么直接拿来用是最有效,也是最节省开发者精力的选项。

而 MySQL 的重要地位自然不用多少,今天我们就来了解一下,开源社区里面们可以直接拿来用的 MySQL Fake 实现:SQLite 以及 go-mysql-server。

SQLite

SQLite is a C-language library that implements a small, fast, self-contained, high-reliability, full-featured, SQL database engine. SQLite is the most used database engine in the world. SQLite is built into all mobile phones and most computers and comes bundled inside countless other applications that people use every day. More Information...
The SQLite file format is stable, cross-platform, and backwards compatible and the developers pledge to keep it that way through the year 2050. SQLite database files are commonly used as containers to transfer rich content between systems [1] [2] [3] and as a long-term archival format for data [4]. There are over 1 trillion (1e12) SQLite databases in active use [5].
SQLite source code is in the public-domain and is free to everyone to use for any purpose.

说是 MySQL,其实是对关系型数据库的一类统称,因为大家都基本遵循了 SQL 92 标准,在没有用到比较复杂的,或者 MySQL 特有的一些语法特性时,我们可以用 SQLite 来作为一个平替。

SQLite 正如其名,本身是非常轻量级的关系型数据库,底层是用 C 语言实现的,它实现了一个 小型 , 快速 , 自包含 , 高可靠性 , 功能齐全 的 SQL数据库引擎。

需要注意的是它并不是一个独立的 app,而是作为一个库,被内嵌到各个 app 中,事实上,你的智能手机里面就内置了很多 SQLite。

用 SQLite 来存取数据时,你会发现跟 MySQL 不同,它只依赖一个文件进行读取和写入,非常轻量级。我们可以在单测执行结束的时候清理掉这个文件即可。

当然,更多时候,鉴于只是跑个单测,没必要用写硬盘的方式,SQLite 还提供了内存的模式,这样我们就能完全不依赖存储,直接用 SQLite 来验证我们的语句是否正确。

下面我们来看下实战。

实战用法

本篇我们还是基于 GORM 来进行实战解析。

针对 sqlite, GORM 已经提供了官方的 driver。使用起来也很简单:

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

// github.com/mattn/go-sqlite3
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{})
复制代码

这里 Open 方法中的 gorm.db 其实就是我们的 SQLite 依赖的文件,启动之后在本地目录中会生成这个文件。

要转化为内存模式,我们只需要将 gorm.db 替换成 :memory: 即可,这里是参考 SQLite 的规范:

An SQLite database is normally stored in a single ordinary disk file. However, in certain circumstances, the database might be stored in memory.
The most common way to force an SQLite database to exist purely in memory is to open the database using the special filename " :memory: ". In other words, instead of passing the name of a real disk file into one of the sqlite3_open(), sqlite3_open16(), or sqlite3_open_v2() functions, pass in the string ":memory:". For example:

rc = sqlite3_open(":memory:", &db);
复制代码

When this is done, no disk file is opened. Instead, a new database is created purely in memory. The database ceases to exist as soon as the database connection is closed. Every :memory: database is distinct from every other. So, opening two database connections each with the filename ":memory:" will create two independent in-memory databases.

所以,要提供一个内存版 SQLite,我们只需调整一下参数即可,代码如下

package mysql

import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)

var (
sqliteDBConn *gorm.DB
)

func GetInMemoryDB() *gorm.DB {
return sqliteDBConn
}

func InitInMemoryDB() {
var err error
sqliteDBConn, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
panic(err)
}
}
复制代码

在单测启动前,调用 InitInMemoryDB 来初始化,拿到一个基于我们的内存 SQLite 生成的 *gorm.DB 对象,并通过 GetInMemoryDB 返回即可。

对于业务代码来说,我们只需要传入这个内存 SQLite 的 *gorm.DB 作为 MySQL 的平替,其他逻辑都不需要改变,非常方便。

当然,Fake 带来的另一个问题也需要解决。

你既然是 Fake,用了一个真实的 working implementation,那等于说是个新的数据库,并没有我们此前在线上环境建好的那些数据表,我们如果直接跑测试,INSERT 数据的时候是要报错的。

针对这一点,GORM 已经提供了 AutoMigrate 支持,详细说明参照迁移文档

undefined

简单说,我们可以直接把自己的 Model 指针传入 db.AutoMigrate 方法,GORM 会自行帮我们创建相关的表。

假设我们的 DAL 层相关方法依托于一个 DBTunnel 对象,线上运行时通过依赖注入进去一个 *gorm.DB 对象来执行逻辑。当我们希望获取一个针对测试环境的 DBTunnel 时,只需要这样即可:

type DBTunnel struct {
DB *gorm.DB
}

func getImpl() tunnel.DBTunnel {
InitInMemoryDB()
db := GetInMemoryDB()
err := db.AutoMigrate(&model.XXXXModel{})
if err != nil {
panic(err)
}
return DBTunnel{
DB: db,
}
}
复制代码

go-mysql-server

A MySQL-compatible relational database with a storage agnostic query engine. Implemented in pure Go.
go-mysql-server has two primary uses case:

  1. Stand-in for MySQL in a golang test environment, using the built-in memory database implementation.
  2. Providing access to aribtrary data sources with SQL queries by implementing a handful of interfaces. The most complete real-world implementation is Dolt.

go-mysql-server 是一个用 Golang 实现的,和 MySQL 完全兼容的数据库,能够用于golang的测试环境,它可以启动一个内存级别的mysql db,初始化一些数据, 可以让被测试对象的db连接指向该内存db。这样做测试的好处是:

  1. 没有很夸张的mock成本;

  2. 不用担心产生的脏数据问题;

  3. 能顺带着测出 DAL 层sql不符合预期的问题。

和 SQLite 相比,它进一步规避了很多 SQLite 和 MySQL 语法不兼容的问题(虽然哪怕是 MySQL 自身,5.7 和 8.0 的语法也不相同),但还是那句话,我们只是单测,对于一些基础用法的场景,还涉及不到那些变化。是否适用还是一个因业务而定的决策。

详细的 go-mysql-server 文档请参考这里,下来我们看一下怎样在单测中使用。

实战用法

启动 Server

我们可以直接使用 go get 添加到你的项目依赖中:

go get github.com/dolthub/go-mysql-server@latest
复制代码

我们先参考官方示例,来看一下怎样基于 go-mysql-server 来构建内存数据库:

package main

import (
"time"
sqle "github.com/dolthub/go-mysql-server"
"github.com/dolthub/go-mysql-server/memory"
"github.com/dolthub/go-mysql-server/server"
"github.com/dolthub/go-mysql-server/sql"
"github.com/dolthub/go-mysql-server/sql/information_schema"
)

// Example of how to implement a MySQL server based on a Engine:
//
// ```
// > mysql --host=127.0.0.1 --port=3306 -u root mydb -e "SELECT * FROM mytable"
// +----------+-------------------+-------------------------------+---------------------+
// | name     | email             | phone_numbers                 | created_at          |
// +----------+-------------------+-------------------------------+---------------------+
// | John Doe | john@doe.com      | ["555-555-555"]               | 2018-04-18 09:41:13 |
// | John Doe | johnalt@doe.com   | []                            | 2018-04-18 09:41:13 |
// | Jane Doe | jane@doe.com      | []                            | 2018-04-18 09:41:13 |
// | Evil Bob | evilbob@gmail.com | ["555-666-555","666-666-666"] | 2018-04-18 09:41:13 |
// +----------+-------------------+-------------------------------+---------------------+
// ```
func main() {
engine := sqle.NewDefault(
sql.NewDatabaseProvider(
createTestDatabase(),
information_schema.NewInformationSchemaDatabase(),
))
config := server.Config{
Protocol: "tcp",
Address:  "localhost:3306",
}
s, err := server.NewDefaultServer(config, engine)
if err != nil {
panic(err)
}
s.Start()
}

func createTestDatabase() *memory.Database {
const (
dbName    = "mydb"
tableName = "mytable"
)
db := memory.NewDatabase(dbName)
table := memory.NewTable(tableName, sql.NewPrimaryKeySchema(sql.Schema{
{Name: "name", Type: sql.Text, Nullable: false, Source: tableName},
{Name: "email", Type: sql.Text, Nullable: false, Source: tableName},
{Name: "phone_numbers", Type: sql.JSON, Nullable: false, Source: tableName},
{Name: "created_at", Type: sql.Timestamp, Nullable: false, Source: tableName},
}))

db.AddTable(tableName, table)
ctx := sql.NewEmptyContext()
table.Insert(ctx, sql.NewRow("John Doe", "john@doe.com", []string{"555-555-555"}, time.Now()))
table.Insert(ctx, sql.NewRow("John Doe", "johnalt@doe.com", []string{}, time.Now()))
table.Insert(ctx, sql.NewRow("Jane Doe", "jane@doe.com", []string{}, time.Now()))
table.Insert(ctx, sql.NewRow("Evil Bob", "evilbob@gmail.com", []string{"555-666-555", "666-666-666"}, time.Now()))
return db
}
复制代码

可以看到,我们需要通过 memory.NewDatabase 以及 memory.NewTable 来构建我们的 DB 和表,然后插入一些数据,最后在 main 函数里面通过 s.Start 启动。

这里就看出来和 SQLite 的区别了,注意,我们构造这个测试数据库,并 Start 其实就是将 server 启动。

这件事情本身是一个单测的前置条件。正如如果你要用 MySQL 真实数据库来测试的话,同样需要先启动一个 server,才能通过 client 去连接,一样的道理。

注意上面我们的 server 配置:

config := server.Config{
        Protocol: "tcp",
        Address:  "localhost:3306",
}
复制代码

这也就是说我们可以从本地 3306 端口对启动的实例进行访问。

单测实战

有了已经启动的 Server,我们就可以创建一个测试的 Client,并改造我们的单测了。

怎么写?

其实非常简单,你只需要把 go-mysql-server 当成是一个真实的 MySQL Server 即可,平常怎么连,现在就怎么连。区别在于,我们通过 GORM 连接 MySQL 时需要指定一个 DSN,通常可能是我们在某个测试环境的数据库 ip 和 端口。此时我们需要把它改成在本地启动的地址即可。

原来的代码:

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

// https://github.com/go-sql-driver/mysql
dsn := "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
复制代码

鉴于这里已经是 localhost 了,我们只需要改成上面的 Server 地址 3306 即可,其他都不需要动:

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

// https://github.com/go-sql-driver/mysql
dsn := "gorm:gorm@tcp(localhost:3306)/gorm?charset=utf8&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
复制代码

当然,GORM 提供的 MySQL driver 还提供了其他配置能力,通常还是建议和你的线上配置保持一致,示例:

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

var datetimePrecision = 2

db, err := gorm.Open(mysql.New(mysql.Config{
  DSN: "gorm:gorm@tcp(localhost:3306)/gorm?charset=utf8&parseTime=True&loc=Local", // data source name, refer https://github.com/go-sql-driver/mysql#dsn-data-source-name
  DefaultStringSize: 256, // add default size for string fields, by default, will use db type `longtext` for fields without size, not a primary key, no index defined and don't have default values
  DisableDatetimePrecision: true, // disable datetime precision support, which not supported before MySQL 5.6
  DefaultDatetimePrecision: &datetimePrecision, // default datetime precision
  DontSupportRenameIndex: true, // drop & create index when rename index, rename index not supported before MySQL 5.7, MariaDB
  DontSupportRenameColumn: true, // use change when rename column, rename rename not supported before MySQL 8, MariaDB
  SkipInitializeWithVersion: false, // smart configure based on used version
}), &gorm.Config{})
复制代码

根据项目实际场景来调整我们测试数据库的 Client 端配置即可。

通过 gorm.Open 拿到一个 *gorm.DB 对象后,还是原样走我们的业务逻辑,其他都不需要更改。

总结

SQLite 和 go-mysql-server 都是业界用的比较多的针对单测场景下 MySQL 的 Fake 实现,前者更为轻量级,只依赖一个文件,所以我们直接从 client 端调整即可。而后者则需要先启动一个 MySQL server,再更新 client DSN 的 ip 和 端口。

但整体上来讲代码都是可复用的,复杂度并不高。相对于 sqlmock 这类 Mock 实现,还是建议大家采用今天介绍的 Fake 来提前暴露 SQL 问题。

标签: 测试