Table of contents
两年前我曾经写过一篇文章讨论如何用sql mock 对GROM应用进行单元测试。
回顾这两年,这种测试方法至少在我团队中并没有被广泛采纳。 究其原因,还是编写测试用例太麻烦了。
最核心的问题需要手工拼出 GORM 生成的 SQL 语句,然后进行对比验证。 这个工作量已经远超过要测试的方法本身了。 毕竟, 我们采用 GORM 的主要原因就是为了避免手写每一段SQL的麻烦。
显然这个方案需要改进。
改进方案
通常, 我们说数据库应用不好测试原因主要在于数据库服务本身。
如果大家共用一个远程数据库,则会导致数据冲突。
如果为每个开发者建一个独立的账户, 则需要考虑不同人在运行测试用例时使用不同账户,并持续维护这些账户。
如果由每个开发者在开发机上安装并维护一个数据库服务的实例,又增加了开发环境搭建的工作量。
最后一点给了我启发: 我们可以借助docker在本地运行数据库。 如果测试用例能与docker整合到一起,那就更完美了。
设想中的流程如下:
test suite 启动时, 用docker启动 db server,将 GORM 连接到这个 server
每个 test case 运行之前,删除并重建所有 table
执行测试用例
test suite 结束时,关闭db server
理论上我们可以通过命令行控制docker,不过有了dockertest 的帮助, 我们可以更容易达成这个目标。
具体实现
下面将演示如何一步步实现 Gorm 应用的单元测试。 示例应用与前文一致。只是把 gorm 升级到了最新版本。这里可以查看完整源码。
本文仍以 Postgres 为例, 当然下述方法可以适用于任意数据库应用的。
构建 test suite
...
var Db *gorm.DB
var cleanupDocker func()
var _ = BeforeSuite(func() {
// setup *gorm.Db with docker
Db, cleanupDocker = setupGormWithDocker()
})
var _ = AfterSuite(func() {
// cleanup resource
cleanupDocker()
})
var _ = BeforeEach(func() {
// clear db tables before each test
err := Db.Exec(`DROP SCHEMA public CASCADE;CREATE SCHEMA public;`).Error
Ω(err).To(Succeed())
})
const (
dbName = "test"
passwd = "test"
)
func setupGormWithDocker() (*gorm.DB, func()) {
pool, err := dockertest.NewPool("")
chk(err)
runDockerOpt := &dockertest.RunOptions{
Repository: "postgres", // image
Tag: "14", // version
Env: []string{"POSTGRES_PASSWORD=" + passwd, "POSTGRES_DB=" + dbName},
}
fnConfig := func(config *docker.HostConfig) {
config.AutoRemove = true // set AutoRemove to true so that stopped container goes away by itself
config.RestartPolicy = docker.NeverRestart() // don't restart container
}
resource, err := pool.RunWithOptions(runDockerOpt, fnConfig)
chk(err)
// call clean up function to release resource
fnCleanup := func() {
err := resource.Close()
chk(err)
}
conStr := fmt.Sprintf("host=localhost port=%s user=postgres dbname=%s password=%s sslmode=disable",
resource.GetPort("5432/tcp"), // get port of localhost
dbName,
passwd,
)
var gdb *gorm.DB
// retry until db server is ready
err = pool.Retry(func() error {
gdb, err = gorm.Open(postgres.Open(conStr), &gorm.Config{})
if err != nil {
return err
}
db, err := gdb.DB()
if err != nil {
return err
}
return db.Ping()
})
chk(err)
// container is ready, return *gorm.Db for testing
return gdb, fnCleanup
}
...
按上述时序图的设想, 我们需要作一系列准备工作:
在
BeforeSuite
中, 我们创建了*gorm.Db
实例, 同时得到了一个用于清理回收docker资源的 函数cleanupDocker
,核心函数setupGormWithDocker
将在下面详细介绍在
AfterSuite
中, 我们调用了cleanupDocker
方法, 以便回收资源。在
BeforeEach
中, 我们删除并重建schema,确保每个test case 运行前,数据库都是"干净"的
下面详细说明 setupGormWithDocker 函数。
首先用
dockertest.NewPool
创建一个资源池, 是这使用 dockertest 来运行docker 容器的先决条件通过
dockertest.RunOptions
指定数据加镜像,版本号,以及启动Postgres所需要环境变量通过
fnConfig
控制docker启动的策略,这里指定了自动删除以及容器出错时不要重新启动。pool.RunWithOptions
真正启动了容器,此时创建 fnCleanup 用于资源回收由于容器启动需要时间,所以我们需要确保容器启动成功以后再返回
*gorm.DB
, 这时会用到pool.Retry
函数, 它的作用是反复执行传入的函数(在本例中,只是简单地ping 了数据库,确保它是可用的), 直到此函数不返回error为止。当 pool.Retry 执行通过, 说明容器已经准备好了,最终返回
构建 test case
...
var Db *gorm.DB
var cleanupDocker func()
var _ = BeforeSuite(func() {
// setup *gorm.Db with docker
Db, cleanupDocker = setupGormWithDocker()
})
var _ = AfterSuite(func() {
// cleanup resource
cleanupDocker()
})
var _ = BeforeEach(func() {
// clear db tables before each test
err := Db.Exec(`DROP SCHEMA public CASCADE;CREATE SCHEMA public;`).Error
Ω(err).To(Succeed())
})
const (
dbName = "test"
passwd = "test"
)
func setupGormWithDocker() (*gorm.DB, func()) {
pool, err := dockertest.NewPool("")
chk(err)
runDockerOpt := &dockertest.RunOptions{
Repository: "postgres", // image
Tag: "14", // version
Env: []string{"POSTGRES_PASSWORD=" + passwd, "POSTGRES_DB=" + dbName},
}
fnConfig := func(config *docker.HostConfig) {
config.AutoRemove = true // set AutoRemove to true so that stopped container goes away by itself
config.RestartPolicy = docker.NeverRestart() // don't restart container
}
resource, err := pool.RunWithOptions(runDockerOpt, fnConfig)
chk(err)
// call clean up function to release resource
fnCleanup := func() {
err := resource.Close()
chk(err)
}
conStr := fmt.Sprintf("host=localhost port=%s user=postgres dbname=%s password=%s sslmode=disable",
resource.GetPort("5432/tcp"), // get port of localhost
dbName,
passwd,
)
var gdb *gorm.DB
// retry until db server is ready
err = pool.Retry(func() error {
gdb, err = gorm.Open(postgres.Open(conStr), &gorm.Config{})
if err != nil {
return err
}
db, err := gdb.DB()
if err != nil {
return err
}
return db.Ping()
})
chk(err)
// container is ready, return *gorm.Db for testing
return gdb, fnCleanup
}
...
有了前面的准备,我们可以确保在test case 中可以拿到一个连接到本地数据库的 *gorm.DB
,并且在每个case运行前数据库中的数据都会被清空。
我们在 BeforeEach 为测试用例准备 Repository 实例。在其中调用 repo.Migrate 是为了自动创建 model 对应的 table,然后保存一条样本数据。
后面的各个测试用例就非常简单了, 只需要调用Repo对应的方法, 确保方法返回了期望的数据即可。 毕竟这里我们是直接调用了真实的数据加, 并不需要任何 mock
与Sqlmock 方案相比, 现在再也不用为了测试而手工拼写sql了。 完成全部方法的测试, 只需要几十行代码。
注意事项
由于需要启动完整的postgres db server,所以在准备test suite时需等待几秒钟。 好在执行每个test case 时仍然非常快, 虽然美中不足, 但还是能接受的
容器启动过程中,
pool.Retry
会输出连接出错信息,如果不想受此打扰,可以为gorm.Open
传入&gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
参数, 关于日志虽然 dockertest 可以自动下载所需镜像,但其下载过程没有进度提示。 所以建议在运行 test case 之前用
docker pull postgres:14
提前下载, 这样可以避免 初次运行 test case 可能出现的长久等待。如果你的应用需要用到某个特定的 Postgres extension, 只需要使用相应的镜像即可。 例如,如果你用到了 postgres hll, 那么只需要使用这个镜像替代标准镜像
总结
在真实数据库上作GORM应对作单元测试有着巨优势。
有了 dockertest 的帮助, 本地docker容器可以与测试用例无缝集成在一起
与Sqlmock 不同, 所以逻辑都是真实运行的,无需mock。 所以编写test case 也大幅减化了。
综合对比后, 似乎没有什么理由再使用Sqlmock了。
查看完整源码,在这里