Go 單元測試之Mysql資料庫整合測試

贾维斯Echo發表於2024-04-17

目錄
  • 一、 sqlmock介紹
  • 二、安裝
  • 三、基本用法
  • 四、一個小案例
  • 五、Gorm 初始化注意點

一、 sqlmock介紹

sqlmock 是一個用於測試資料庫互動的 Go 模擬庫。它可以模擬 SQL 查詢、插入、更新等操作,並且可以驗證 SQL 語句的執行情況,非常適合用於單元測試中。

二、安裝

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

三、基本用法

使用 sqlmock 進行 MySQL 資料庫整合測試的基本步驟如下:

  1. 建立模擬 DB 連線:
import (
    "database/sql"
    "testing"
    "github.com/DATA-DOG/go-sqlmock"
)

func TestMyDBFunction(t *testing.T) {
    db, mock, err := sqlmock.New()
    if err != nil {
        t.Fatalf("Error creating mock database: %v", err)
    }
    defer db.Close()
    
    // 使用 mock 來替代真實的資料庫連線
    // db 可以傳遞給被測試的函式進行測試
}
  1. 設定模擬 SQL 查詢和預期結果:
// 模擬 SQL 查詢並設定預期結果
rows := sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "Alice").AddRow(2, "Bob")
mock.ExpectQuery("SELECT id, name FROM users").WillReturnRows(rows)
  1. 呼叫被測試的函式,並傳入模擬的資料庫連線:
// 呼叫被測試的函式,傳入模擬的資料庫連線
result := MyDBFunction(db)

// 驗證結果是否符合預期
if result != expected {
    t.Errorf("Expected %d, got %d", expected, result)
}

四、一個小案例

這裡我們定義了一個 GORMUserDAO 結構體,它實現了 UserDAO 介面,用於與使用者表進行互動。這個結構體透過 gorm.DB 例項與資料庫進行通訊。

具體來說,GORMUserDAO 提供了 Insert 方法,用於在資料庫中建立新使用者。這個方法接受一個 User 型別的結構體作為引數,該結構體定義了使用者的基本資訊,包括 ID、郵箱、密碼、手機號、生日、暱稱、自我介紹、微信 UnionID 和 OpenID 等欄位。

Insert 方法中,首先獲取當前時間戳(以毫秒為單位),並設定使用者的建立時間和更新時間。然後,使用 gorm.DBCreate 方法將使用者資訊插入到資料庫中。如果插入操作遇到唯一性約束錯誤(例如郵箱或手機號已存在),方法會返回一個特定的錯誤 ErrUserDuplicate

User 結構體定義了資料庫表的結構,其中包含了一些列的定義,如 EmailPhone 被設定為唯一索引。此外,還定義了一些列的型別和約束,如 AboutMe 欄位被設定為最大長度為 1024 的字串型別。

提供了一個使用 GORM 進行資料庫操作的 DAO 層,用於處理使用者資料的建立。

// internal/user/dao/user.go
package dao

import (
	"context"
	"database/sql"
	"errors"
	"github.com/go-sql-driver/mysql"
	"gorm.io/gorm"
	"time"
)

var (
	ErrUserDuplicate = errors.New("郵箱衝突")
)

type UserDAO interface {
	Insert(ctx context.Context, u User) error
}

type GORMUserDAO struct {
	db *gorm.DB
}

func NewUserDAO(db *gorm.DB) UserDAO {
	return &GORMUserDAO{
		db: db,
	}
}

func (dao *GORMUserDAO) Insert(ctx context.Context, u User) error {
	// 存毫秒數
	now := time.Now().UnixMilli()
	u.Utime = now
	u.Ctime = now
	err := dao.db.WithContext(ctx).Create(&u).Error
	if mysqlErr, ok := err.(*mysql.MySQLError); ok {
		const uniqueConflictsErrNo uint16 = 1062
		if mysqlErr.Number == uniqueConflictsErrNo {
			// 郵箱衝突 or 手機號碼衝突
			return ErrUserDuplicate
		}
	}
	return err
}

// User 直接對應資料庫表結構
// 有些人叫做 entity,有些人叫做 model,有些人叫做 PO(persistent object)
type User struct {
	Id int64 `gorm:"primaryKey,autoIncrement"`
	// 設定為唯一索引
	Email    sql.NullString `gorm:"unique"`
	Password string

	//Phone *string
	Phone sql.NullString `gorm:"unique"`

	Birthday sql.NullInt64
	// 暱稱
	Nickname sql.NullString
	// 自我介紹
	AboutMe       sql.NullString `gorm:"type=varchar(1024)"`
	WechatUnionID sql.NullString
	WechatOpenID  sql.NullString `gorm:"unique"`

	// 建立時間
	Ctime int64
	// 更新時間
	Utime int64
}

接著我們用編寫測試用例

package dao

import (
	"context"
	"database/sql"
	"errors"
	"github.com/DATA-DOG/go-sqlmock"
	"github.com/go-sql-driver/mysql"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	gormMysql "gorm.io/driver/mysql"
	"gorm.io/gorm"
	"testing"
)

func TestGORMUserDAO_Insert(t *testing.T) {
	//
	testCases := []struct {
		name string

		// 為什麼不用 ctrl ?
		// 因為你這裡是 sqlmock,不是 gomock
		mock func(t *testing.T) *sql.DB

		ctx  context.Context
		user User

		wantErr error
	}{
		{
			name: "插入成功",
			mock: func(t *testing.T) *sql.DB {
				mockDB, mock, err := sqlmock.New()
				res := sqlmock.NewResult(3, 1)
				// 這邊預期的是正規表示式
				// 這個寫法的意思就是,只要是 INSERT 到 users 的語句
				mock.ExpectExec("INSERT INTO `users` .*").
					WillReturnResult(res)
				require.NoError(t, err)
				return mockDB
			},
			user: User{
				Email: sql.NullString{
					String: "123@qq.com",
					Valid:  true,
				},
			},
		},
		{
			name: "郵箱衝突",
			mock: func(t *testing.T) *sql.DB {
				mockDB, mock, err := sqlmock.New()
				// 這邊預期的是正規表示式
				// 這個寫法的意思就是,只要是 INSERT 到 users 的語句
				mock.ExpectExec("INSERT INTO `users` .*").
					WillReturnError(&mysql.MySQLError{
						Number: 1062,
					})
				require.NoError(t, err)
				return mockDB
			},
			user:    User{},
			wantErr: ErrUserDuplicate,
		},
		{
			name: "資料庫錯誤",
			mock: func(t *testing.T) *sql.DB {
				mockDB, mock, err := sqlmock.New()
				// 這邊預期的是正規表示式
				// 這個寫法的意思就是,只要是 INSERT 到 users 的語句
				mock.ExpectExec("INSERT INTO `users` .*").
					WillReturnError(errors.New("資料庫錯誤"))
				require.NoError(t, err)
				return mockDB
			},
			user:    User{},
			wantErr: errors.New("資料庫錯誤"),
		},
	}
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			db, err := gorm.Open(gormMysql.New(gormMysql.Config{
				Conn: tc.mock(t),
				// SELECT VERSION;
				SkipInitializeWithVersion: true,
			}), &gorm.Config{
				// 你 mock DB 不需要 ping
				DisableAutomaticPing: true,
				// 這個是什麼呢?
				SkipDefaultTransaction: true,
			})
			d := NewUserDAO(db)
			u := tc.user
			err = d.Insert(tc.ctx, u)
			assert.Equal(t, tc.wantErr, err)

		})
	}
}

五、Gorm 初始化注意點

這裡執行測試的程式碼也有點與眾不同,在初始化 GORM 的時候需要額外設定三個引數。

  • SkipInitializeWithVersion:如果為 false,那麼 GORM 在初始化的時候,會先呼叫 show version
  • DisableAutomiticPing:為 true 不允許 Ping 資料庫。
  • SkipDefaultTransaction:為 false 的時候,即便是一個單一增刪改語句,GORM 也會開啟事務。

這三個選項禁用之後,就可以確保 GORM 不會在初始化的過程中發起額外的呼叫。

相關文章