# 借助 docker 对 GORM 应用进行单元测试

两年前我曾经写过一篇[文章](https://betterprogramming.pub/how-to-unit-test-a-gorm-application-with-sqlmock-97ee73e36526)讨论如何用sql mock 对GROM应用进行单元测试。

回顾这两年，这种测试方法至少在我团队中并没有被广泛采纳。 究其原因，还是编写测试用例太麻烦了。

最核心的问题需要手工拼出 GORM 生成的 SQL 语句，然后进行对比验证。 这个工作量已经远超过要测试的方法本身了。 毕竟， 我们采用 GORM 的主要原因就是为了避免手写每一段SQL的麻烦。

显然这个方案需要改进。

### 改进方案

通常， 我们说数据库应用不好测试原因主要在于数据库服务本身。

* 如果大家共用一个远程数据库，则会导致数据冲突。
    
* 如果为每个开发者建一个独立的账户， 则需要考虑不同人在运行测试用例时使用不同账户，并持续维护这些账户。
    
* 如果由每个开发者在开发机上安装并维护一个数据库服务的实例，又增加了开发环境搭建的工作量。
    

最后一点给了我启发： 我们可以借助docker在本地运行数据库。 如果测试用例能与docker整合到一起，那就更完美了。

设想中的流程如下：

1. test suite 启动时， 用docker启动 db server，将 GORM 连接到这个 server
    
2. 每个 test case 运行之前，删除并重建所有 table
    
3. 执行测试用例
    
4. test suite 结束时，关闭db server
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1716287067276/a08933ed-7fe5-4c82-9a69-5c7d78675920.png align="left")

理论上我们可以通过命令行控制docker，不过有了[dockertest](https://github.com/ory/dockertest) 的帮助， 我们可以更容易达成这个目标。

### 具体实现

下面将演示如何一步步实现 Gorm 应用的单元测试。 示例应用与[前文](https://betterprogramming.pub/how-to-unit-test-a-gorm-application-with-sqlmock-97ee73e36526)一致。只是把 gorm 升级到了最新版本。[这里](https://github.com/dche423/dbtest)可以查看完整源码。

本文仍以 Postgres 为例， 当然下述方法可以适用于任意数据库应用的。

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1716287075125/4e3d59ad-44ff-4331-b228-143075705ae5.png align="left")

#### 构建 test suite

```go
...  

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

```go
...  

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了。 完成全部方法的测试， 只需要几十行代码。
    

## 注意事项

1. 由于需要启动完整的postgres db server，所以在准备test suite时需等待几秒钟。 好在执行每个test case 时仍然非常快， 虽然美中不足， 但还是能接受的
    
2. 容器启动过程中， `pool.Retry` 会输出连接出错信息，如果不想受此打扰，可以为 `gorm.Open` 传入 `&gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}` 参数， 关于日志
    
3. 虽然 dockertest 可以自动下载所需镜像，但其下载过程没有进度提示。 所以建议在运行 test case 之前用 `docker pull postgres:14` 提前下载， 这样可以避免 初次运行 test case 可能出现的长久等待。
    
4. 如果你的应用需要用到某个特定的 Postgres extension， 只需要使用相应的镜像即可。 例如，如果你用到了 [postgres hll](https://github.com/citusdata/postgresql-hll), 那么只需要使用[这个镜像](https://github.com/vishnudxb/docker-postgres-hll)替代标准镜像
    

## 总结

* 在真实数据库上作GORM应对作单元测试有着巨优势。
    
* 有了 dockertest 的帮助， 本地docker容器可以与测试用例无缝集成在一起
    
* 与Sqlmock 不同， 所以逻辑都是真实运行的，无需mock。 所以编写test case 也大幅减化了。
    
* 综合对比后， 似乎没有什么理由再使用Sqlmock了。
    
* 查看完整源码，在[这里](https://github.com/dche423/dbtest)
