借助 docker 对 GORM 应用进行单元测试
- 5 minutes read - 2137 words两年前我曾经写过一篇文章讨论如何用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了。
- 查看完整源码,在这里