Go單元測試對數據庫CRUD進行Mock測試

前言

最近在實踐中也總結瞭一些如何用表格驅動的方式使用 gock Mock測試外部接口調用。以及怎麼對GORM做mock測試,這些等這篇學完基礎後,後面再單獨寫文章給大傢介紹。

這是Go語言單元測試系列教程的第3篇,介紹瞭如何使用go-sqlmockminiredis工具進行MySQLRedismock測試。

在上一篇《Go單元測試–模擬服務請求和接口返回》中,我們介紹瞭如何使用httptest和gock工具進行網絡測試。

除瞭網絡依賴之外,我們在開發中也會經常用到各種數據庫,比如常見的MySQL和Redis等。本文就分別舉例來演示如何在編寫單元測試的時候對MySQL和Redis進行mock。

go-sqlmock

sqlmock 是一個實現 sql/driver 的mock庫。它不需要建立真正的數據庫連接就可以在測試中模擬任何 sql 驅動程序的行為。使用它可以很方便的在編寫單元測試的時候mock sql語句的執行結果。

安裝

go get github.com/DATA-DOG/go-sqlmock

使用示例

這裡使用的是go-sqlmock官方文檔中提供的基礎示例代碼。在下面的代碼中,我們實現瞭一個recordStats函數用來記錄用戶瀏覽商品時產生的相關數據。具體實現的功能是在一個事務中進行以下兩次SQL操作:

  • 在表中將當前商品的瀏覽次數+1
  • product_viewers表中記錄瀏覽當前商品的用戶id
// app.go
package main
import "database/sql"
// recordStats 記錄用戶瀏覽產品信息
func recordStats(db *sql.DB, userID, productID int64) (err error) {
 // 開啟事務
 // 操作views和product_viewers兩張表
 tx, err := db.Begin()
 if err != nil {
  return
 }
 defer func() {
  switch err {
  case nil:
   err = tx.Commit()
  default:
   tx.Rollback()
  }
 }()
 // 更新products表
 if _, err = tx.Exec("UPDATE products SET views = views + 1"); err != nil {
  return
 }
 // product_viewers表中插入一條數據
 if _, err = tx.Exec(
  "INSERT INTO product_viewers (user_id, product_id) VALUES (?, ?)",
  userID, productID); err != nil {
  return
 }
 return
}
func main() {
 // 註意:測試的過程中並不需要真正的連接
 db, err := sql.Open("mysql", "root@/blog")
 if err != nil {
  panic(err)
 }
 defer db.Close()
 // userID為1的用戶瀏覽瞭productID為5的產品
 if err = recordStats(db, 1 /*some user id*/, 5 /*some product id*/); err != nil {
  panic(err)
 }
}

現在我們需要為代碼中的recordStats函數編寫單元測試,但是又不想在測試過程中連接真實的數據庫進行測試。這個時候我們就可以像下面示例代碼中那樣使用sqlmock工具去mock數據庫操作。

package main
import (
 "fmt"
 "testing"
 "github.com/DATA-DOG/go-sqlmock"
)
// TestShouldUpdateStats sql執行成功的測試用例
func TestShouldUpdateStats(t *testing.T) {
 // mock一個*sql.DB對象,不需要連接真實的數據庫
 db, mock, err := sqlmock.New()
 if err != nil {
  t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
 }
 defer db.Close()
 // mock執行指定SQL語句時的返回結果
 mock.ExpectBegin()
 mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
 mock.ExpectExec("INSERT INTO product_viewers").WithArgs(2, 3).WillReturnResult(sqlmock.NewResult(1, 1))
 mock.ExpectCommit()
 // 將mock的DB對象傳入我們的函數中
 if err = recordStats(db, 2, 3); err != nil {
  t.Errorf("error was not expected while updating stats: %s", err)
 }
 // 確保期望的結果都滿足
 if err := mock.ExpectationsWereMet(); err != nil {
  t.Errorf("there were unfulfilled expectations: %s", err)
 }
}
// TestShouldRollbackStatUpdatesOnFailure sql執行失敗回滾的測試用例
func TestShouldRollbackStatUpdatesOnFailure(t *testing.T) {
 db, mock, err := sqlmock.New()
 if err != nil {
  t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
 }
 defer db.Close()
 mock.ExpectBegin()
 mock.ExpectExec("UPDATE products").WillReturnResult(sqlmock.NewResult(1, 1))
 mock.ExpectExec("INSERT INTO product_viewers").
  WithArgs(2, 3).
  WillReturnError(fmt.Errorf("some error"))
 mock.ExpectRollback()
 // now we execute our method
 if err = recordStats(db, 2, 3); err == nil {
  t.Errorf("was expecting an error, but there was none")
 }
 // we make sure that all expectations were met
 if err := mock.ExpectationsWereMet(); err != nil {
  t.Errorf("there were unfulfilled expectations: %s", err)
 }
}

上面的代碼中,定義瞭一個執行成功的測試用例和一個執行失敗回滾的測試用例,確保我們代碼中的每個邏輯分支都能被測試到,提高單元測試覆蓋率的同時也保證瞭代碼的健壯性。

執行單元測試,看一下最終的測試結果。

❯ go test -v
=== RUN   TestShouldUpdateStats
— PASS: TestShouldUpdateStats (0.00s)
=== RUN   TestShouldRollbackStatUpdatesOnFailure
— PASS: TestShouldRollbackStatUpdatesOnFailure (0.00s)
PASS
ok      golang-unit-test-demo/sqlmock_demo      0.011s

可以看到兩個測試用例的結果都符合預期,單元測試通過。

在很多使用ORM工具的場景下,也可以使用go-sqlmock庫mock數據庫操作進行測試。

miniredis

除瞭經常用到MySQL外,Redis在日常開發中也會經常用到。接下來的這一小節,我們將一起學習如何在單元測試中mock Redis的相關操作。

miniredis是一個純go實現的用於單元測試的redis server。它是一個簡單易用的、基於內存的redis替代品,它具有真正的TCP接口,你可以把它當成是redis版本的net/http/httptest

當我們為一些包含Redis操作的代碼編寫單元測試時就可以使用它來mock Redis操作。

安裝

go get github.com/alicebob/miniredis/v2

使用示例

這裡以github.com/go-redis/redis庫為例,編寫瞭一個包含若幹Redis操作的DoSomethingWithRedis函數。

// redis_op.go
package miniredis_demo
import (
 "context"
 "github.com/go-redis/redis/v8" // 註意導入版本
 "strings"
 "time"
)
const (
 KeyValidWebsite = "app:valid:website:list"
)
func DoSomethingWithRedis(rdb *redis.Client, key string) bool {
 // 這裡可以是對redis操作的一些邏輯
 ctx := context.TODO()
 if !rdb.SIsMember(ctx, KeyValidWebsite, key).Val() {
  return false
 }
 val, err := rdb.Get(ctx, key).Result()
 if err != nil {
  return false
 }
 if !strings.HasPrefix(val, "https://") {
  val = "https://" + val
 }
 // 設置 blog key 五秒過期
 if err := rdb.Set(ctx, "blog", val, 5*time.Second).Err(); err != nil {
  return false
 }
 return true
}

下面的代碼是我使用miniredis庫為DoSomethingWithRedis函數編寫的單元測試代碼,其中miniredis不僅支持mock常用的Redis操作,還提供瞭很多實用的幫助函數,例如檢查key的值是否與預期相等的s.CheckGet()和幫助檢查key過期時間的s.FastForward()

// redis_op_test.go
package miniredis_demo
import (
 "github.com/alicebob/miniredis/v2"
 "github.com/go-redis/redis/v8"
 "testing"
 "time"
)
func TestDoSomethingWithRedis(t *testing.T) {
 // mock一個redis server
 s, err := miniredis.Run()
 if err != nil {
  panic(err)
 }
 defer s.Close()
 // 準備數據
 s.Set("q1mi", "liwenzhou.com")
 s.SAdd(KeyValidWebsite, "q1mi")
 // 連接mock的redis server
 rdb := redis.NewClient(&redis.Options{
  Addr: s.Addr(), // mock redis server的地址
 })
 // 調用函數
 ok := DoSomethingWithRedis(rdb, "q1mi")
 if !ok {
  t.Fatal()
 }
 // 可以手動檢查redis中的值是否復合預期
 if got, err := s.Get("blog"); err != nil || got != "https://liwenzhou.com" {
  t.Fatalf("'blog' has the wrong value")
 }
 // 也可以使用幫助工具檢查
 s.CheckGet(t, "blog", "https://liwenzhou.com")
 // 過期檢查
 s.FastForward(5 * time.Second) // 快進5秒
 if s.Exists("blog") {
  t.Fatal("'blog' should not have existed anymore")
 }
}

執行執行測試,查看單元測試結果:

❯ go test -v
=== RUN   ;TestDoSomethingWithRedis
— PASS: TestDoSomethingWithRedis (0.00s)
PASS
ok      golang-unit-test-demo/miniredis_demo    0.052s

miniredis基本上支持絕大多數的Redis命令,大傢可以通過查看文檔瞭解更多用法。

當然除瞭使用miniredis搭建本地redis server這種方法外,還可以使用各種打樁工具對具體方法進行打樁。在編寫單元測試時具體使用哪種mock方式還是要根據實際情況來決定。

總結

在日常工作開發中為代碼編寫單元測試時如何處理數據庫的依賴是最常見的問題,本文介紹瞭如何使用go-sqlmockminiredis工具mock相關依賴。

接下來,我們將更進一步,詳細介紹如何在編寫單元測試時mock接口實現,更多關於Go數據庫CRUD Mock測試的資料請關註WalkonNet其它相關文章!

推薦閱讀: