V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
qloog
V2EX  ›  Go 编程语言

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

  •  
  •   qloog · 2020-04-04 08:39:08 +08:00 · 2621 次点击
    这是一个创建于 1475 天前的主题,其中的信息可能已经有所发展或是发生改变。

    tools.jpeg

    概述

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

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

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

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

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

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

    blog-repo.png

    定义 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
    文章出处:1024 课堂

    5 条回复    2020-04-04 15:43:47 +08:00
    mcfog
        1
    mcfog  
       2020-04-04 09:57:31 +08:00   ❤️ 1
    看在没二维码只有个链接的份上回一下,这是经典的没搞明白什么是单元测试,测试有哪些类型和目标而导致的无效瞎折腾
    ahmcsxcc
        2
    ahmcsxcc  
       2020-04-04 10:32:24 +08:00
    @mcfog #1 能详细说下吗, 我感觉这个写的挺不错的, 你这么一说我迷茫了
    qloog
        3
    qloog  
    OP
       2020-04-04 10:44:14 +08:00
    @mcfog 来详细说说呗,针对数据库的单元测试
    mcfog
        4
    mcfog  
       2020-04-04 13:19:06 +08:00
    @ahmcsxcc 去了解一下静态分析,单元测试,集成测试,系统测试,验收测试的概念和区别,想想各自的目的,然后分析一下主楼写的这些属于哪种,提示:绝对不是单元测试

    @qloog 数据库不是单元测试的有效目标,硬要说的话,你可以去读 MySQL 源码里的测试,或者 GORM 里面的测试,看看是不是你想要的
    qloog
        5
    qloog  
    OP
       2020-04-04 15:43:47 +08:00
    @mcfog [数据库不是单元测试的有效目标] ,说的也没错。
    GORM 里面的测试,我去看看,另外本文只是对如果想要测试数据库的提供的一种测试方式。感觉你把问题有点放大了。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   3451 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 00:07 · PVG 08:07 · LAX 17:07 · JFK 20:07
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.