Go 單測從零到溜系列—2-mock 數據庫測試

這是 Go 語言單元測試從零到溜系列教程的第 2 篇,介紹瞭如何使用 go-sqlmock 和 miniredis 工具進行 MySQL 和 Redis 的 mock 測試。

在上一篇《Go 單測從零到溜系列——1. 網絡測試》中,我們介紹瞭如何使用 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 操作:

// 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 接口。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/dI1sWh_RK7MZEgRw-6kxxA