13

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

 4 years ago
source link: https://studygolang.com/articles/27670
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

3IzArqM.jpg!web

概述

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

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

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

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

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

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

2imQJzF.png!web

定义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://1024casts.com/topics/R9re7QDaq8MnJoaXRZxdljbNA5BwoK


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK