照片由 Nicolas Thomas 在 Unsplash 上提供。
2022 年 7 月更新:在过去的两年中,我的观点有了一些改变,如果你有兴趣,也可以查看我关于这个主题的最新 文章。
对于涉及数据库操作的代码进行单元测试并不容易。当涉及到 ORM 库,如 GORM 时,这变得更加困难。
理论上,我们可以使用伟大的模拟工具 GoMock 来模拟 database/sql/driver
的所有接口(例如 Conn 和 Driver)。然而,即使在 GoMock 的帮助下,我们仍然需要大量手动工作才能完成这种类型的测试。
好消息是 Sqlmock 可以解决上述问题。正如它的官方网站所宣布的那样,它是一个“用于测试数据库交互的 golang Sql 模拟驱动程序。”
本文将展示如何使用 Sqlmock 对一个简单的博客应用程序进行单元测试。该应用程序使用 PostgreSQL 作为后端,并使用 GORM 简化 O-R 映射。
我们将使用 BDD 测试框架 Ginkgo 编写测试用例,但你也可以选择任何你喜欢的测试库。
我们的博客应用程序将包含一个博客数据模型和一个用于处理数据库操作的存储库结构。
定义 GORM 数据模型和存储库
首先,让我们定义博客数据模型和存储库结构:
// modle.go
import "github.com/lib/pq"
...
type Blog struct {
ID uint
Title string
Content string
Tags pq.StringArray // string array for tags
CreatedAt time.Time
}
// repository.go
import "github.com/jinzhu/gorm"
...
type Repository struct {
db *gorm.DB
}
func (p *Repository) ListAll() ([]*Blog, error) {
var l []*Blog
err := p.db.Find(&l).Error
return l, err
}
func (p *Repository) Load(id uint) (*Blog, error) {
blog := &Blog{}
err := p.db.Where(`id = ?`, id).First(blog).Error
return blog, err
}
...
请注意,Blog.Tags
的类型是 pq.StringArray
,它代表 PostgreSQL 中的字符串数组。
我们的 Repository
结构非常简单。它有一个 *gorm.DB
字段,所有的 DB 操作都依赖于这个字段。出于简洁考虑,我省略了一些代码。除了 Load
和 ListAll
之外,Repository
结构中还声明了几个其他方法,例如 Save
、Delete
、SearchByTitle
等。这些方法将在本文后面解释。
设置测试用例
为了使用 Sqlmock 和 GORM,我们需要在 BeforeEach
中进行一些准备工作,以确保每个测试用例都能获得一个新的 Repository
实例,并在 AfterEach
中断言预期。
import (
...
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/DATA-DOG/go-sqlmock"
"github.com/jinzhu/gorm"
)
var _ = Describe("Repository", func() {
var repository *Repository
var mock sqlmock.Sqlmock
BeforeEach(func() {
var db *sql.DB
var err error
db, mock, err = sqlmock.New() // mock sql.DB
Expect(err).ShouldNot(HaveOccurred())
gdb, err := gorm.Open("postgres", db) // open gorm db
Expect(err).ShouldNot(HaveOccurred())
repository = &Repository{db: gdb}
})
AfterEach(func() {
err := mock.ExpectationsWereMet() // make sure all expectations were met
Expect(err).ShouldNot(HaveOccurred())
})
It("test something", func(){
...
})
})
在 BeforeEach
中有三个步骤来设置这个测试用例:
- 使用
sqlmock.New()
创建一个模拟的*sql.DB
实例和一个模拟控制器。 - 通过
gorm.Open("postgres", db)
打开 GORM(使用 PostgreSQL 方言)。 - 创建一个新的
Repository
实例。 在AfterEach
中,我们调用mock.ExpectationsWereMet()
来确保所有预期都得到了满足。
现在让我们从最简单的情况开始编写测试用例。
测试 ListAll 方法
// repository.go
...
func (p *Repository) ListAll() ([]*Blog, error) {
var l []*Blog
err := p.db.Find(&l).Error
return l, err
}
...
// repository_test.go
...
Context("list all", func() {
It("empty", func() {
const sqlSelectAll = `SELECT * FROM "blogs"`
mock.ExpectQuery(sqlSelectAll).
WillReturnRows(sqlmock.NewRows(nil))
l, err := repository.ListAll()
Expect(err).ShouldNot(HaveOccurred())
Expect(l).Should(BeEmpty())
})
})
...
如上面的代码片段所示,ListAll
查找数据库中的所有记录,并将它们映射到一个 *Blog
的切片中。
测试用例很简单。我们将预期的查询设置为 SELECT * FROM "blogs"
,并返回一个空结果集。
然后运行所有的测试:
➜ ginkgo
Running Suite: Pg Suite
=======================
Random Seed: 1585542357
Will run 8 of 8 specs
(/Users/dche423/dbtest/pg/repository.go:24)
[2020-03-30 12:26:01] Query: could not match actual sql: "SELECT * FROM "blogs"" with expected regexp "SELECT * FROM "blogs""
• Failure [0.001 seconds]
Repository
/Users/dche423/dbtest/pg/repository_test.go:16
list all
/Users/dche423/dbtest/pg/repository_test.go:37
empty [It]
/Users/dche423/dbtest/pg/repository_test.go:38
...
Test Suite Failed
➜
你可能会惊讶于这个简单的测试用例会失败。但控制台日志已经给了我们提示:“could not match actual sql with expected regexp。”
事实证明,Sqlmock 使用 sqlmock.QueryMatcherRegex
作为默认的 SQL 匹配器。在这种情况下,sqlmock.ExpectQuery
方法需要以正则表达式字符串作为参数,而不是普通的 SQL 字符串。
我们有两种选择来解决这个问题:
- 使用
regexp.QuoteMeta
方法转义 SQL 字符串中的所有正则表达式元字符。因此,我们可以将ExcectQuery
改为mock.ExpectQuery(regexp.QuoteMeta(sqlSelectAll))...
。 - 更改默认的 SQL 匹配器。我们可以在创建模拟实例时提供一个匹配器选项:
sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
。 通常,正则表达式匹配器比相等匹配器更灵活(这就是为什么 Sqlmock 将其作为默认值的原因)。
提示:Sqlmock 默认使用正则表达式匹配 SQL。
接下来,让我们测试将单个 DB 记录加载到数据模型中的方法。
测试 Load 方法
// repository.go
func (p *Repository) Load(id uint) (*Blog, error) {
blog := &Blog{}
err := p.db.Where(`id = ?`, id).First(blog).Error
return blog, err
}
...
// repository_test.go
Context("load", func() {
It("found", func() {
blog := &Blog{
ID: 1,
Title: "post",
...
}
rows := sqlmock.
NewRows([]string{"id", "title", "content", "tags", "created_at"}).
AddRow(blog.ID, blog.Title, blog.Content, blog.Tags, blog.CreatedAt)
const sqlSelectOne = `SELECT * FROM "blogs" WHERE (id = $1) ORDER BY "blogs"."id" ASC LIMIT 1`
mock.ExpectQuery(regexp.QuoteMeta(sqlSelectOne)).WithArgs(blog.ID).WillReturnRows(rows)
dbBlog, err := repository.Load(blog.ID)
Expect(err).ShouldNot(HaveOccurred())
Expect(dbBlog).Should(Equal(blog))
})
It("not found", func() {
// ignore sql match
mock.ExpectQuery(`.+`).WillReturnRows(sqlmock.NewRows(nil))
_, err := repository.Load(1)
Expect(err).Should(Equal(gorm.ErrRecordNotFound))
})
})
...
Load
方法以博客 ID 作为参数,然后查找具有此 ID 的第一条记录。
我们将为这个方法测试两种情况。
在第一个测试用例(命名为“found”)中,我们构建了一个博客实例,并将其转换为 sql.Row
。然后我们调用 ExpectQuery
来定义预期。在此测试用例结束时,我们断言加载的博客实例等于原始实例。
注意:如果你不确定 GORM 会生成什么 SQL,可以使用 gorm.DB
的 Debug()
方法打开调试标志。
另一个测试用例涵盖了“未找到”的情况。它还演示了如何使用正则表达式简化 SQL 匹配,当我们不关心 SQL 输入时(我们使用 .+
作为可以匹配任何内容的输入字符串)。
在这种情况下,我们关心的只是当 Load
方法找不到博客时应该返回一个 gorm.ErrRecordNotFound
错误。
提示:使用正则表达式简化 SQL 匹配。
在下一节中,我们将介绍使用 GORM 插入记录的单元测试,这是最棘手的部分。
测试 Save 方法
// repository.go
...
func (p *Repository) Save(blog *Blog) error {
return p.db.Save(blog).Error
}
// repository_test.go
...
Context("save", func() {
var blog *Blog
BeforeEach(func() {
blog = &Blog{
Title: "post",
Content: "hello",
Tags: pq.StringArray{"a", "b"},
CreatedAt: time.Now(),
}
})
It("insert", func() {
// gorm use query instead of exec
// https://github.com/DATA-DOG/go-sqlmock/issues/118
const sqlInsert = `
INSERT INTO "blogs" ("title","content","tags","created_at")
VALUES ($1,$2,$3,$4) RETURNING "blogs"."id"`
const newId = 1
mock.ExpectBegin() // begin transaction
mock.ExpectQuery(regexp.QuoteMeta(sqlInsert)).
WithArgs(blog.Title, blog.Content, blog.Tags, blog.CreatedAt).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(newId))
mock.ExpectCommit() // commit transaction
Expect(blog.ID).Should(BeZero())
err := repository.Save(blog)
Expect(err).ShouldNot(HaveOccurred())
Expect(blog.ID).Should(BeEquivalentTo(newId))
})
It("update", func() {
...
})
})
当数据模型有主键时,Save
方法将更新数据库记录。而当没有主键时,该方法将在数据库中插入一条新记录。
上面的代码片段展示了后一种情况。
我们创建了一个没有设置主键的新博客实例,然后使用mock.ExpectQuery
定义期望值。在查询之前启动事务,在查询之后提交事务。
通常,非查询SQL期望(例如Insert/Update
)应该由mock.ExpectExec
定义,但这是一个特殊情况。由于某种原因,GROM在postgres
方言中使用QueryRow
而不是Exec
(更多细节请参见此问题)。
最后,我们使用Expect(blog.ID).Should(BeEquivalentTo(<em>newId</em>))
断言Save
方法后blog.ID
被设置。
提示:如果你使用PostgreSQL,请在GORM模型插入时使用mock.ExpectQuery
。
你可能认为对于一个简单的Insert/Update
操作没有必要进行单元测试。实际上,是的,这是不必要的。我们想要向你展示的是,GORM可能会执行一些你之前没有注意到的隐式操作。
结论
Sqlmock是一个很好的用于单元测试与数据库交互代码的工具,但在与GORM和PostgreSQL一起使用时,需要注意一些细节。
在本文中,我们构建了一个简单的博客应用程序,并使用Sqlmock进行了单元测试。相信你可以借助这个例子开始你的单元测试。
完整的源代码,请访问其代码库。
译自:https://betterprogramming.pub/how-to-unit-test-a-gorm-application-with-sqlmock-97ee73e36526
评论(0)