Golang 單元測試 - 資料層

LinkinStar發表於2023-03-08

前言

今天我們先來看看有關資料層(repo)的單元測試應該如何實踐。

資料層,就是我們常常說的 repo/dao,其功能就是和資料庫、快取或者其他資料來源打交道。它需要從資料來源中獲取資料,並返回給上一層。在這一層通常沒有複雜業務的邏輯,所以最重要的就是測試各個資料欄位的編寫是否正確,以及 SQL 等查詢條件是否正常能被篩選。

當然,資料層也基本上是最底層了,通常這一層的單元測試更加的重要,因為如果一個欄位名稱和資料庫不一致上層所有依賴這個方法的地方全部都會報錯。

由於資料層和資料來源打交道,那麼測試的麻煩點就在於,通常我們不能要求外接一定能提供一個資料來源供我們測試:一方面是由於我們不可能隨時都能連上測試伺服器的資料庫,另一方面我們也不能要求單元測試執行的時候只有你一個人在使用這個資料庫,而且資料庫資料乾淨。退一步講,我們也沒辦法 mock,如果 mock 了 sql,那麼測試的意義就不大了。

下面我們就以我們常見的 mysql 資料庫為例,看看在 golang 中如何進行單元測試的編寫。

準備工作的說明

資料來源

首先,我們需要一個乾淨的資料來源,由於我們沒有辦法依賴於外部伺服器的資料庫,那麼我們就利用最常使用的 docker 來幫助我們構建一個所需要使用的資料來源。

我們這裡使用 github.com/ory/dockertest 來幫助我們構建測試的環境,它能幫助我們啟動一個所需要的環境,當然你也可以選擇手動使用 docker 或者 docker-compose 來建立。

初始資料

有了資料庫之後,我們還需要表結構和初始資料,這部分也有兩種方案:

  1. 使用 orm 提供的 sync/migration 類似的功能,將結構體直接對映生成表欄位,透過 go 程式碼建立初始資料
  2. 直接使用 sql 語句,透過執行 sql 語句來建立對應的表結構和欄位資料
    本案例使用第一種方式進行,第二種也類似

基本 case 程式碼

我們首先來快速搞定一下預設的 case 程式碼,也就是我們常常搬磚的 CRUD。(這裡僅給出最基本的實現,重點主要關注在單元測試上)

package repo

import (
    "context"

    "go-demo/m/unit-test/entity"
    "xorm.io/xorm"
)

type UserRepo interface {
    AddUser(ctx context.Context, user *entity.User) (err error)
    DelUser(ctx context.Context, userID int) (err error)
    GetUser(ctx context.Context, userID int) (user *entity.User, exist bool, err error)
}

type userRepo struct {
    db *xorm.Engine
}

func NewUserRepo(db *xorm.Engine) UserRepo {
    return &userRepo{db: db}
}

func (ur userRepo) AddUser(ctx context.Context, user *entity.User) error {
    _, err := ur.db.Insert(user)
    return err
}

func (ur userRepo) DelUser(ctx context.Context, userID int) error {
    _, err := ur.db.Delete(&entity.User{ID: userID})
    return err
}

func (ur userRepo) GetUser(ctx context.Context, userID int) (user *entity.User, exist bool, err error) {
    user = &entity.User{ID: userID}
    exist, err = ur.db.Get(user)
    return user, exist, err
}

初始化測試環境

首先建立 repo_main_test.go 檔案

package repo

import (
    "database/sql"
    "fmt"
    "testing"
    "time"

    _ "github.com/go-sql-driver/mysql"
    "github.com/ory/dockertest/v3"
    "github.com/ory/dockertest/v3/docker"
    "go-demo/m/unit-test/entity"
    "xorm.io/xorm"
    "xorm.io/xorm/schemas"
)

type TestDBSetting struct {
    Driver       string
    ImageName    string
    ImageVersion string
    ENV          []string
    PortID       string
    Connection   string
}

var (
    mysqlDBSetting = TestDBSetting{
        Driver:       string(schemas.MYSQL),
        ImageName:    "mariadb",
        ImageVersion: "10.4.7",
        ENV:          []string{"MYSQL_ROOT_PASSWORD=root", "MYSQL_DATABASE=linkinstar", "MYSQL_ROOT_HOST=%"},
        PortID:       "3306/tcp",
        Connection:   "root:root@(localhost:%s)/linkinstar?parseTime=true",
    }
    tearDown       func()
    testDataSource *xorm.Engine
)

func TestMain(t *testing.M) {
    defer func() {
        if tearDown != nil {
            tearDown()
        }
    }()
    if err := initTestDataSource(mysqlDBSetting); err != nil {
        panic(err)
    }
    if ret := t.Run(); ret != 0 {
        panic(ret)
    }
}

func initTestDataSource(dbSetting TestDBSetting) (err error) {
    connection, imageCleanUp, err := initDatabaseImage(dbSetting)
    if err != nil {
        return err
    }
    dbSetting.Connection = connection

    testDataSource, err = initDatabase(dbSetting)
    if err != nil {
        return err
    }

    tearDown = func() {
        testDataSource.Close()
        imageCleanUp()
    }
    return nil
}

func initDatabaseImage(dbSetting TestDBSetting) (connection string, cleanup func(), err error) {
    pool, err := dockertest.NewPool("")
    pool.MaxWait = time.Minute * 5
    if err != nil {
        return "", nil, fmt.Errorf("could not connect to docker: %s", err)
    }

    resource, err := pool.RunWithOptions(&dockertest.RunOptions{
        Repository: dbSetting.ImageName,
        Tag:        dbSetting.ImageVersion,
        Env:        dbSetting.ENV,
    }, func(config *docker.HostConfig) {
        config.AutoRemove = true
        config.RestartPolicy = docker.RestartPolicy{Name: "no"}
    })
    if err != nil {
        return "", nil, fmt.Errorf("could not pull resource: %s", err)
    }

    connection = fmt.Sprintf(dbSetting.Connection, resource.GetPort(dbSetting.PortID))
    if err := pool.Retry(func() error {
        db, err := sql.Open(dbSetting.Driver, connection)
        if err != nil {
            fmt.Println(err)
            return err
        }
        return db.Ping()
    }); err != nil {
        return "", nil, fmt.Errorf("could not connect to database: %s", err)
    }
    return connection, func() { _ = pool.Purge(resource) }, nil
}

func initDatabase(dbSetting TestDBSetting) (dbEngine *xorm.Engine, err error) {
    dbEngine, err = xorm.NewEngine(dbSetting.Driver, dbSetting.Connection)
    if err != nil {
        return nil, err
    }
    err = initDatabaseData(dbEngine)
    if err != nil {
        return nil, fmt.Errorf("init database data failed: %s", err)
    }
    return dbEngine, nil
}

func initDatabaseData(dbEngine *xorm.Engine) error {
    return dbEngine.Sync(new(entity.User))
}

下面說明其中的方法和要點

  • TestMain:這個方法是 golang test 的一個特性,它會在所有 單元測試 之前自動執行,特別適合用於初始化資料和清理測試遺留環境。這個方法中 tearDown 是為了清理連線和映象用的
  • initDatabaseImage:方法主要就是利用 github.com/ory/dockertest 提供功能拉取一個對應的 docker 映象並啟動
  • initDatabaseData:方法主要利用了 xorm 的 Sync 方法去初始化了資料庫,當然這裡也可以構建你所需要的初始化資料,比如你需要初始化一個超級管理員等等
  • testDataSource:我們將最終初始化的資料來源放在了這裡,由於後續單元測試的時候使用,這裡由於只有一個資料庫,就沒有封裝

編寫單元測試

有了前面的準備工作,單元測試就變得簡單了

package repo

import (
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
    "go-demo/m/unit-test/entity"
)

func Test_userRepo_AddUser(t *testing.T) {
    ur := NewUserRepo(testDataSource)
    user := &entity.User{
        Username: "LinkinStar",
    }
    err := ur.AddUser(context.TODO(), user)
    assert.NoError(t, err)

    dbUser, exist, err := ur.GetUser(context.TODO(), user.ID)
    assert.NoError(t, err)
    assert.True(t, exist)
    assert.Equal(t, user.Username, dbUser.Username)

    err = ur.DelUser(context.TODO(), user.ID)
    assert.NoError(t, err)
}

可以看到我們只需要像平常寫程式碼一樣直接呼叫對應的方法就可以進行單元測試了。

  • 其中 https://github.com/stretchr/testify 是一個非常好用的斷言工具,能幫助我們快速實現單元測試中的斷言,以便我們快速確定單元測試是否正確。
  • 單元測試需要注意的是,我們這裡測試的是新增使用者,也就是插入資料,為保證單元測試的獨立性,測試完當前方法後資料應該保持一致,故需要進行資料刪除,以保證不會干擾到其他的單元測試。

注意事項

  1. 本地需要有 docker 環境
  2. 第一次啟動由於需要拉取映象,根據網路情況不同,拉取時間不同
  3. 正常情況下,我們設定了 AutoRemovetrue 並且不再重啟,測試完成之後會將測試使用的 mysql 映象關閉並刪除,但是如果測試意外中斷,或者強制中斷時,會導致映象被遺留下來。故,本地測試之後可以使用 docker ps 命令檢視是否有遺留
  4. 當然根據所需要的資料來源不同,你可以使用其他不同的映象進行操作,效果是一樣的

總結

  1. repo 資料層的單元測試透過 docker 來啟動資料來源進行測試
  2. 使用 orm 或者匯入 sql 的方式進行資料初始化
  3. 測試完單個方法後保證測試前後資料一致,不影響其他單元測試

相關文章