如何使用Sqlmock对GORM应用进行单元测试

Go qloog · 2020年04月04日 · 516 次阅读


img

概述

对DB交互代码进行单元测试并不容易,当涉及到诸如GORM之类的ORM库时,这将变得更加困难。

从理论上讲,我们可以使用强大的模拟工具GoMock来模拟 database/sql/driver 的所有接口(例如Conn和Driver)。但是,即使在GoMock的帮助下,我们仍然需要大量的手工工作来完成这种测试。

好消息是Sqlmock可以解决上述问题。正如其官方网站所宣布的那样,它是一个“用于golang的SQL模拟驱动程序,用于测试数据库交互。”

本文将向您展示如何使用Sqlmock对一个简单的博客应用程序进行单元测试。该应用程序以PostgreSQL为例,并使用GORM简化了O-R映射。

我们将使用BDD测试框架Ginkgo编写测试用例,但是您可以更改为您喜欢的任何其他测试库。

我们的博客应用程序将包含一个博客数据model和一个用于处理数据库操作的repository 结构。

img

定义GORM数据Model和Repository

首先定义博客数据模型Model和Repository结构

// 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
}

...

Tips: 注意 Blog.Tags 的类型是 pq.StringArray ,它表示PostgreSQL中的字符串数组。

我们的Repository 结构非常简单。它只有gorm.DB 一个字段,并且所有数据库操作都取决于此字段。为了简洁起见,我省略了一些代码。除了LoadListAll 之外,Repository 结构中还声明了其他几种方法,例如 SaveDeleteSearchByTitle 等。这些方法将在本文后面解释。

设置测试用例

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(){
	    ...
	})
})

要将Sqlmock与GORM一起使用,我们需要在 BeforeEach中 进行一些准备,以确保每个测试规范都可以获取一个新的Repository实例,然后在 AfterEach 中断言预期的case。

BeforeEach 中,可以通过三个步骤来设置此测试用例:

  1. 使用 sqlmock.New() 创建 *sql.DB 的模拟实例和模拟控制器
  2. 通过使用 gorm.Open("postgres", db) 来打开一个GORM(使用PostgreSQL)
  3. 创建一个 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 在DB中查找所有记录,并将它们映射到 []*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.(翻译过来就是:无法将实际的sql与预期的regexp相匹配。)”

事实证明Sqlmock使用 sqlmock.QueryMatcherRegex 作为默认SQL匹配器。在这种情况下,方法 sqlmock.ExpectQuery 将正则表达式字符串作为其参数,而不是纯SQL字符串。

我们有两种选择来解决此问题:

  1. 使用 regexp.QuoteMeta 方法转义SQL字符串中的所有正则表达式元字符。因此我们可以将 ExcectQuery 更改为 mock.ExpectQuery(regexp.QuoteMeta(sqlSelectAll))...
  2. 更改默认的SQL匹配器。创建模拟实例时,我们可以提供匹配器选项:sqlmock.New(**sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)**)

通常,正则表达式匹配器比相等匹配器更灵活(这就是Sqlmock将其用作默认值的原因)。

提示:默认情况下,Sqlmock将SQL与正则表达式匹配。

接下来,让我们测试将单个数据库记录加载到数据模型中的方法。

测试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.DBDebug() 方法打开调试标志。

其他规范涵盖“not found”方案。它还演示了当我们不关心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() {
		...		
	})
		

})

当数据Model具有主键时,Save 方法将更新数据库记录。当没有记录时,该方法会将新记录插入数据库。

上面的代码段显示了后一种情况。

我们创建一个新的博客实例,而不设置其主键。然后,使用mock.ExpectQuery 定义期望。事务在查询之前启动,并在查询之后提交。

通常,非查询SQL期望值(例如,插入/更新)应由 mock.ExpectExec 定义,但这是一种特殊情况。由于某些原因,GROM使用 QueryRow 而不是 Exec 来表示 postgres 方言(有关更多详细信息,请参阅此问题)。

最后,我们使用 Expect(blog.ID).Should(BeEquivalentTo(*newId*)) 断言 blog.ID 是在 Save 方法之后设置的。

提示:如果您使用的是PostgreSQL,请对GORM模型插入使用 mock.ExpectQuery

您可能建议不必对简单的“插入/更新”操作进行单元测试。实际上,是的,没有必要。我们要向您展示的是,GORM可能会执行一些您之前没有注意到的隐式操作。

结论

Sqlmock是对DB交互式代码进行单元测试的好工具,但是在使用GORM和PostgreSQL时有一些陷阱。

在本文中,我们构建了一个简单的博客应用程序,并使用Sqlmock对它进行了单元测试。我相信您可以在此示例的帮助下开始单元测试。

有关完整的源代码,请访问 这个仓库

原文地址: https://medium.com/better-programming/how-to-unit-test-a-gorm-application-with-sqlmock-97ee73e36526