為程式碼編寫穩定的單元測試 [Go]

機智的小小帥發表於2021-10-19

為程式碼編寫穩定的單元測試

本文件配套程式碼倉庫地址:

https://github.com/liweiforeveryoung/curd_demo

配合 git checkout 出指定 commit 以及 git diff 比較 commit 間的差別食用更佳

單元測試的作用

  1. 功能交付的保障,確保不會發生一些低階錯誤,只要你覺得哪處邏輯在某種 case 的情況下會有風險,都可以為其編寫一個測試用例(不過對於自己壓根沒有想到的 case,肯定就沒法保證了)
  2. 功能重構的保障,重構某一段程式碼時,如何保障重構沒有出現問題,就看重構之後的程式碼能否通過之前的單元測試
  3. 增加研發對功能的信心,每當新寫一個功能,為它編寫單元測試後,在執行單元測試時,那不停出現的 pass能讓研發人員更加自信。在交付功能後,不再那麼焦慮了,不再擔心睡覺時被事故電話吵醒,保持愉悅的心情,頭髮掉的也不那麼快了。
  4. 如果專案是使用程式碼行數來進行績效考核的,那多編寫單元測試還能起到增加 kpi 的作用,一個方法可能就 50 行,為它編寫的單元測試程式碼可能有 100 行。因此單元測試對程式碼行數的增益效果很高!

對測試的理解

程式碼裡面總是存在呼叫鏈的,例如

functionA -> functionB -> functionC -> functionD

只要可以確保

functionA -> functionB

functionB -> functionC

functionC -> functionD

這三個環節不出現問題,那麼整條鏈路就是正確的。

function實質上是對於: 對於一個初始狀態A,接收一個訊號B,變成了另一個狀態C 這個過程的抽象

只要 A 和 B 確定了,那 C 也就確定了。

為了寫出便於單元測試的程式碼,需要做到兩點:

  1. 對程式碼進行分層,上一層的程式碼最好只依賴下一層的程式碼,不要跨級依賴
  2. 減少對包級別變數的引用

為依賴外部服務的程式碼編寫單元測試

程式碼可以分為兩個部分,一部分程式碼沒有不依賴外界條件的程式碼,也不會對外界條件造成影響。最典型的是一些僅僅涉及到計算的或者在構建 struct 的程式碼。

func Add(i,j int) int{
  return i + j
}
func NewXXX() *XXX{
  return new(XXX)
}

另一部分程式碼則是依賴外界條件的程式碼,最典型的是一些涉及到 IO 操作的程式碼,例如往控制檯中輸出字串,往網路卡讀寫資料。

func Hello() {
	fmt.Println("hello world")
}

這部分程式碼能否執行成功是不確定的,也許在一條機器上可以執行成功,在另一臺沒有顯示卡的機器上就會執行失敗。

在為不依賴外界條件的程式碼寫測試時,例如 model 層的程式碼,一般是比較容易的,先設計好測試用例,然後 go test -v 執行就 ok 了,無論在哪臺機器上,測試用例的結果總是一致的。

在為依賴外界條件的程式碼寫測試時,卻比較犯難了,最典型的例如業務層的程式碼,往往涉及到對資料庫的 curd,如果連線的資料庫不對/資料庫裡面的表結構不對/資料庫中的資料不對,都會導致最終測試的結果不如預期。

如何為依賴外部服務的程式碼編寫單元測試,需要做到這兩點:

  1. 用程式碼創造條件
  2. 用程式碼來檢驗結果
為 db 操作寫單元測試為例

我原本對業務層程式碼寫單元測試時,採取的是比較笨拙的方式,首先在本機的 mysql 中手動建庫,建表。

  • 當測試 SearchXXX() 時,就先手動在資料庫裡面插入兩條 XXX 記錄,之後呼叫 SearchXXX() ,看能否查詢到該資料
  • 當測試 CreateXXX() 時,就呼叫 CreateXXX() 插入記錄,然後在手動去 mysql 中尋找,看是否存在剛剛用 CreateXXX() 插入的記錄
  • 當測試 UpdateXXX() 是,先手動在 mysql 插入一條記錄,然後執行 UpdateXXX(),只看在用肉眼去 mysql 中檢視該條記錄是否被更新了

這種測試方法雖然是可以 work 的,但它的缺點也很大

  • 它是一次性的,就拿 CreateXXX() 來說把,當你第二次 Create 一條相同的記錄時就會發生 duplicate entry 的 error 了。拿 SearchXXX() 來說,如果資料庫中的記錄發生了變化,那麼 SearchXXX() 的結果也肯定不如預期。
  • 過分依賴手動,需要手動去資料庫中建表,手動去資料庫中準備資料,等測試完之後再手動去資料庫中核對結果。每次測試結束後,需要手動的去清理資料庫。

這種不穩定的單元測試,顯然是沒法整合到 CI 中的,畢竟誰也不想,同樣的測試用例,第一次可以成功,但是第二次卻失敗了。如何為這種 crud 邏輯寫出穩定的單元測試?我認為需要做到以下幾點:

  1. 對 db 的測試必須用真實的資料庫,不要 mock sql 的執行結果。畢竟 curd 業務的最終結果就是為了在資料庫中留下正確的痕跡。
  2. 不能依賴之前的遺留的資料,每次執行測試時,自動連線資料庫清理歷史資料並重新完成建表操作
  3. 用程式碼創造條件,對於需要資料庫中有記錄的測試用例,請在執行該測試用例時,用程式碼往資料庫中插入資料,例如 UpdateXXX() 的測試用例,肯定需要提前往資料庫中插入資料,那就在執行 UpdateXXX() 是,先呼叫 db.Insert(obj) 插入資料即可
  4. 用程式碼來檢驗結果,用程式碼對用例的執行結果進行校驗,而不是用肉眼去資料庫中核對記錄是否正確
  5. 為了方便寫單元測試需要合理對程式碼分層
  6. 編寫正確的單元測試,採用靈活的方法去核對結果,慎用 Equal 語義。舉個例子,在對 SearchXXX() 進行測試時,可能會在呼叫 SearchXXX() 前在資料庫中插入 m 條記錄,之後對 SearchXXX() 的執行結果進行校驗,假設 SearchXXX() 的執行結果為 records,這時候不要使用 len(records) == m 去校驗,因為其他的測試用例裡面也可能涉及到資料的插入,records 的數量很可能是大於 m 的,我們能確保的是 records 的數量肯定不會小於 (GreaterOrEqual) m。且 records 中肯定應該包含 (Contains) 剛才插入的那 m 條記錄。

概述

這篇文章將說明如何利用 Gin 編寫一個 mini 版本的 curd 伺服器,這套程式碼的最大優點就是方便進行單元測試。

主要涉及以下內容:

  • 利用 Gin 編寫一個 http 伺服器
  • 利用 Gorm 來進行 sql 操作
  • 利用 Viper 來管理配置檔案
  • 利用 httptest 對 http 層進行測試
  • 自動 migrate sql 檔案到資料庫
  • 利用 mock 根據 interface 生成 mock 程式碼
  • 利用 testify 編寫單元測試
  • 利用 testify/suite 來管理單元測試
  • 利用 uber/dig 來管理依賴
  • 利用 cobra 來管理 cli 命令

實踐

一個簡單的 http 伺服器

利用 gin 很容易做到,直接上程式碼

// main.go
package main

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func main() {
	engine := gin.Default()
	SetUpRoutes(engine)
	err := engine.Run(":3344")
	if err != nil {
		panic(err)
	}
}

func SetUpRoutes(engine *gin.Engine) {
	engine.GET("/hello", Hello)
}

func Hello(context *gin.Context) {
	context.String(http.StatusOK, "hello world")
}

利用 curl 手動測試

curl --location --request GET ':3344/hello'

如願得到

hello world

利用 httptest 庫為 Hello route 寫一個簡單的單元測試,僅僅校驗一下 status code 正不正確

// main_test.go
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
)

func TestHello(t *testing.T) {
	g := gin.Default()
	SetUpRoutes(g)

	req := httptest.NewRequest("GET", "/hello", nil)
	w := httptest.NewRecorder()
	g.ServeHTTP(w, req)
	
	resp := w.Result()
	if resp.StatusCode != http.StatusOK {
		t.Errorf("status code is %d,want: %d", resp.StatusCode, http.StatusOK)
	}
}
引入 testify 庫編寫單元測試

在編寫單元測試時,每次都需要手動編寫

if actualVal != expectVal {
  t.Errorf("want: %v,actual: %v",xxx,xxx)
}

這種校驗程式碼,不太方便,使用 testify 這個庫可以解決這個問題,testify 庫裡的 assert 包提供了很多有用的斷言函式,例如 Equal,Less,Greater,LessOrEqual,GreaterOrEqual

使用 assert.Equal 替換上面的 if 語句

// main_test.go
package main

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"	// here!
)

func TestHello(t *testing.T) {
	g := gin.Default()
	SetUpRoutes(g)

	req := httptest.NewRequest("GET", "/hello", nil)
	w := httptest.NewRecorder()
	g.ServeHTTP(w, req)

	resp := w.Result()
	assert.Equal(t, http.StatusOK, resp.StatusCode) // here!
}
新增 CRUD 業務邏輯

先在資料庫表建好表

mysql> CREATE TABLE IF NOT EXISTS `users`
    -> (
    ->     `id`         BIGINT(20)   NOT NULL AUTO_INCREMENT,
    ->     `user_id`    BIGINT(20)   NOT NULL DEFAULT 0 COMMENT '使用者 id',
    ->     `name`       VARCHAR(191) NOT NULL DEFAULT '' COMMENT '使用者暱稱',
    ->     `age`        INT          NOT NULL DEFAULT 0 COMMENT '使用者年齡',
    ->     `deleted_at` BIGINT(20)   NOT NULL DEFAULT 0,
    ->     `created_at` BIGINT(20)   NOT NULL DEFAULT 0,
    ->     `updated_at` BIGINT(20)   NOT NULL DEFAULT 0,
    ->     PRIMARY KEY (id),
    ->     UNIQUE INDEX `udx_user_id` (`user_id`)
    -> ) ENGINE = InnoDB
    ->   DEFAULT CHARSET = utf8mb4
    ->   COLLATE = utf8mb4_unicode_ci COMMENT '使用者資訊表';
Query OK, 0 rows affected (0.04 sec)

mysql> ALTER TABLE `users`
    ->     ADD COLUMN `sex` TINYINT NOT NULL DEFAULT 0 COMMENT '性別';
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

寫一個 Create 的 handler

func SetUpRoutes(engine *gin.Engine) {
	engine.GET("/hello", Hello)
	engine.POST("/user/create", UserCreate)		// here!!!
}

func UserCreate(ctx *gin.Context) {
	userCreateRequest := new(model.UserCreateRequest)
	err := ctx.BindJSON(userCreateRequest)
	if err != nil {
		ctx.JSON(http.StatusBadRequest, err)
		return
	}
	user := model.NewUser(userCreateRequest)
	err = db.WithContext(ctx).Create(user).Error
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, err)
	}
	ctx.JSON(http.StatusOK, model.NewUserCreateResponse(user))
}
手動測試
curl --location --request POST ':3344/user/create' \
--header 'Content-Type: application/json' \
--data-raw '{
    "user": {
        "name": "levy",
        "age": 18,
        "sex": 1
    }
}'

得到

{
    "user": {
        "user_id": 5577006791947779410,
        "name": "levy",
        "age": 18,
        "sex": 1
    }
}

再去資料庫裡面瞅瞅,觀察到記錄

mysql> select * from users;
+----+---------------------+------+-----+------------+------------+------------+-----+
| id | user_id             | name | age | deleted_at | created_at | updated_at | sex |
+----+---------------------+------+-----+------------+------------+------------+-----+
|  1 | 5577006791947779410 | levy |  18 |          0 | 1632641193 | 1632641193 |   1 |
+----+---------------------+------+-----+------------+------------+------------+-----+
1 row in set (0.00 sec)
自動測試

結合上面說的要點,也很容易寫出測試程式碼

// main_test.go
func TestUserCreate(t *testing.T) {
	g := gin.Default()
	SetUpRoutes(g)

	user := &model.User{
		Name: "levy",
		Age:  18,
		Sex:  model.MAN,
	}
	userCreateReq := model.NewUserCreateRequest(user)
	contentBytes, _ := json.Marshal(userCreateReq)
	reader := bytes.NewReader(contentBytes)

	req := httptest.NewRequest("POST", "/user/create", reader)
	w := httptest.NewRecorder()
	g.ServeHTTP(w, req)

	// 檢查 resp
	resp := w.Result()
	assert.Equal(t, http.StatusOK, resp.StatusCode)
	userCreateResp := new(model.UserCreateResponse)
	err := BindResp(resp, userCreateResp)
	assert.NoError(t, err)
	assert.Equal(t, user.Name, userCreateResp.User.Name)
	assert.Equal(t, user.Age, userCreateResp.User.Age)
	assert.Equal(t, user.Sex, userCreateResp.User.Sex)
	// 檢查資料庫
	userFromDB := user.DeepCopy()
	err = db.First(userFromDB).Error
	assert.NoError(t, err)
	assert.NotEqual(t, int64(0), userFromDB.UserId)
}

但是這個程式碼肯定通過不了測試,因為 UserCreate() 這個 handler 依賴 db,但是這裡卻並沒有初始化 db。

之前說過,在編寫單元測試時,為了防止被之前的髒資料所影響,每次都需要建一個嶄新的資料庫,並將表結構遷移到資料庫裡面。後面會有遷移資料表的具體過程。

// main_test.go
func initDB() {
   var err error
   dsn := "root:@tcp(127.0.0.1:3306)/curd_db_test?charset=utf8mb4&parseTime=True&loc=Local"
   db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
   if err != nil {
      panic(err)
   }
   // 遷移資料庫
   // ...
}
func TestUserCreate(t *testing.T) {
	initDB()
  // ...
}

這裡比較巧的是 main_test.go 和 main.go 都屬於 main 包,var db *gorm.DB 在 main.go 裡面被定義,UserCreate()引用的是 main.go 裡面的 db 變數。因此在 main_test.go 裡面直接將 main.go 裡面的 db 變數給初始化了。(其實這種做法並不好)

有了單元測試後,之後每次 push 程式碼時都需要確保通過所有的單元測試。

遷移資料表到資料庫
func initDB() {
	// 當 clientFoundRows 為 true, db.Update().RowsAffected 返回的是匹配到的記錄行數
	// 當 clientFoundRows 為 false, db.Update().RowsAffected 返回的是實際更新的記錄行數
	dsn := "root:@tcp(127.0.0.1:3306)/curd_db_test?charset=utf8mb4&parseTime=True&loc=Local&clientFoundRows=true"

	dsnCfg, err := gomysql.ParseDSN(dsn)
	if err != nil {
		panic(err)
	}
	dbName := dsnCfg.DBName
	// 在 dsn 中不指定 dbname
	dsnCfg.DBName = ""

	db, err = gorm.Open(mysql.Open(dsnCfg.FormatDSN()), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	// 開啟 debug 模式
	db.Logger = db.Logger.LogMode(logger.Info)
	drop := fmt.Sprintf("DROP DATABASE IF EXISTS %s;", dbName)
	create := fmt.Sprintf("CREATE DATABASE %s DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;", dbName)
	use := fmt.Sprintf(`USE %s;`, dbName)
	migrations := LoadMysqlPath("migrations")
	err = db.Exec(drop).Exec(create).Exec(use).Error
	if err != nil {
		panic(err)
	}
	for _, migrate := range migrations {
		err = db.Exec(migrate).Error
		if err != nil {
			panic(err)
		}
	}
}

如何做到?只需要將 sql 檔案整理到一起,在每次執行單元測試時,將這些 sql 檔案全部執行一遍就好了。

建立一個資料夾,名為 migrations專門用於存放 sql 檔案,每次業務中需要增加新的 sql 表或者修改表的結構時都需要將這些 sql 語句放在 migrations 資料夾下面。

➜  curd_demo git:(master) ✗ tree
.
├── go.mod
├── go.sum
├── main.go
├── main_test.go
├── migrations
│   ├── v0000_create_users.sql
│   └── v0001_alter_users.sql
一個坑

單元測試檔案執行時的 root path 和 main.go 所在的 root path 並不一致

projectname
	main.go
	config
	pkg_to_be_test

在 main.go 中,我們可以採用 ./config的形式來 load projectname/config 資料夾下面的配置檔案。

可是在對 pkg_to_be_test 包做單元測試時,採用 ./config load 的將會是 projectname/pkg_to_be_test/config而非 projectname/config,因此註定會失敗。

可以用標準庫提供的 Getwd 獲取當前的 root path

// Getwd returns a rooted path name corresponding to the
// current directory. If the current directory can be
// reached via multiple paths (due to symbolic links),
// Getwd may return any one of them.
func Getwd() (dir string, err error)

做個實驗

➜  test_wd tree
.
├── main.go
└── pkg
    └── wd_test.go
➜  test_wd cat main.go
package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println(os.Getwd())
}
➜  test_wd cat pkg/wd_test.go
package pkg

import (
	"os"
	"testing"
)

func TestWD(t *testing.T) {
	t.Log(os.Getwd())
}

➜  test_wd go run main.go
/workspace/personal_projects/test_wd <nil> // here!!!
➜  test_wd go test pkg/wd_test.go -v
=== RUN   TestWD
    wd_test.go:9: /workspace/personal_projects/test_wd/pkg <nil> // here!!!
--- PASS: TestWD (0.00s)
PASS
ok  	command-line-arguments	0.148s

wd_test.go 執行時的 work path 是 /workspace/personal_projects/test_wd/pkg,在路徑上包含了 pkg 這個包名。

main.go 執行時的 work path 是 /workspace/personal_projects/test_wd

回到主題上來,當測試檔案和 main.go 同一級時,load ./migrations可以正確的將裡面的 sql 檔案給 load 出來。可當測試檔案和 main.go 不是同一級時,load 就會失敗。

所以說使用相對路勁是不靠譜的。如何獲取 migrations 資料夾的絕對路徑?

只要在 curd_demo 專案路徑下面,無論在哪個地方呼叫 Getwd(),得到的絕對路徑肯定是 /aas/bbb/curd_demo/xxx/yyy的形式,而 migrations 資料夾的絕對路徑肯定是 /aaa/bbb/curd_demo/migrations

所以只要先通過 Getwd() 得到當前工作的決定路徑,然後通過專案名curd_demo擷取字串,即可以得到專案的絕對路徑/aaa/bbb/curd_demo,之後以 /aaa/bbb/curd_demo/為起點,需要名為 migrations的資料夾得到它的絕對路徑就 ok 了。

利用 viper 管理配置

目前有一些配置是 hardcode 的,例如資料庫的 DSN,http 服務的監聽埠。現在將這邊配置放在配置檔案裡面。

➜  curd_demo git:(master) ✗ tree      
.
├── config
│   ├── config.go
│   ├── db.production.yaml
│   ├── db.test.yaml
│   ├── http.production.yaml
│   └── http.test.yaml

為了方便對配置進行管理,根據內容將配置放在了不同的配置檔案裡面,且對於 dev 環境和 production 環境,將會讀取不同的配置檔案。

利用 viper 簡單封裝了一個方法放在 util 包下面,它會將 yaml 格式的配置檔案 unmarshal 並 bind 到 struct 上

// BindYamlConfig 負責根據 yaml config file unmarshal 出 struct
// cfgFileName shouldn't contain file type suffix
func BindYamlConfig(cfgBaseDir, cfgFileName string, cfgObjPtr interface{}) error {
	vp := viper.New()
	// jww.SetStdoutThreshold(jww.LevelInfo) 開啟 viper 的日誌
	vp.AddConfigPath(cfgBaseDir)
	vp.SetConfigName(cfgFileName)
	vp.SetConfigType("yaml")

	err := vp.ReadInConfig()
	if err != nil {
		return fmt.Errorf("ReadInConfig(),err[%w]", err)
	}
	if err = vp.Unmarshal(cfgObjPtr, func(config *mapstructure.DecoderConfig) {
		config.TagName = "yaml"
		// 不能多出不用的配置項
		config.ErrorUnused = true
	}); err != nil {
		return fmt.Errorf("unmarshal(),err[%w]", err)
	}
	return nil
}

根據環境讀取不同的配置檔案

type HttpSetting struct {
	Addr string `yaml:"addr"`
}

func httpConfigInit(env string) *HttpSetting {
	cfg := new(HttpSetting)
	var err error
	switch env {
	default:
		err = util.BindYamlConfig(CfgAbsolutePath, "http.test", cfg)
	case "TEST":
		err = util.BindYamlConfig(CfgAbsolutePath, "http.test", cfg)
	case "PRODUCTION":
		err = util.BindYamlConfig(CfgAbsolutePath, "http.production", cfg)
	}
	if err != nil {
		panic(fmt.Errorf("BindYamlConfig(),err[%w]", err))
	}
	return cfg
}
談談依賴 (dependency)

如果 A 的實現需要 B 的參與,則可以稱 B 是 A 的依賴。

目前的程式碼中,UserCreate 是依賴 db 的,業務終究是要落地到資料庫。此時 db 作為 main 包的一個物件,來供 UserCreate 呼叫。實際上這是不合理的。

  • 第一個不合理是db 作為一個底層依賴(畢竟各個業務方都需要呼叫),不應該是放在 main 這麼一個頂層的包裡面
  • 第二個不合理是db不應該作為一個全域性的變數暴露出來

依賴不應該放在作為 package 級別的變數暴露給全域性,而應該被需要使用該依賴的物件各自去持有。

理由是方便寫單元測試,在一個 package 裡對另一個 package 內的物件進行讀寫操作稍不注意就會造成迴圈引用。

後面將會使用 Entry 來持有依賴,並用 dig 庫進行依賴注入

api 層

目前 main.go 的負擔很重,它需要

  • 初始化 config

  • 初始化 db

  • 註冊 route

  • 實現各個 route 下面的 handler

  • 啟動 gin 服務

main.go 的工作應該越少越好,僅僅應該完成一些基礎配置的初始化。現在將業務相關的一些程式碼全都轉移到 api 層。

➜  curd_demo git:(master) ✗ tree 
.
├── apis
│   ├── hello.go
│   ├── hello_test.go
│   ├── http.go
│   ├── user.go
│   └── user_test.go

hello.go 裡面放的是 Hello()

user.go 裡面放的是和 user 相關的 handler,目前只有 UserCreate()

http.go 裡面進行 db 的初始化,http route 的註冊以及 gin 服務的啟動

// http.go
var db *gorm.DB

func StartHttpService() {
	var err error
	db, err = gorm.Open(mysql.Open(config.Hub.DBSetting.MysqlDSN), &gorm.Config{})
	if err != nil {
		panic(err)
	}

	engine := gin.Default()
	SetUpRoutes(engine)
	err = engine.Run(config.Hub.HttpSetting.Addr)
	if err != nil {
		panic(err)
	}
}

現在 main() 裡面只有簡簡單單 3 行程式碼了

func main() {
	config.Initialize()
	rand.Seed(time.Now().Unix())
	apis.StartHttpService()
}

go test 一下,確保通過單元測試

go test curd_demo/apis -v
PASS
ok      curd_demo/apis  0.729s
interface 和 implement 以及 pkg 層

將介面與實現分離,能夠降低耦合,使程式碼編寫單元測試更加容易,一般將 implement 命名為 XXXEntry。

有兩個好處

  • 利用 Entry 去持有依賴,可以避免依賴在全域性到處亂飛
  • 根據 interface 可以生成 MockEntry,利用 MockEntry 能夠很方便的進行單元測試
graph BT Entry --> interface MockEntry --> interface

目前UserCreate的程式碼如下

func UserCreate(ctx *gin.Context) {
	userCreateRequest := new(model.UserCreateRequest)
	err := ctx.BindJSON(userCreateRequest)
	if err != nil {
		ctx.JSON(http.StatusBadRequest, err)
		return
	}
	user := model.NewUser(userCreateRequest)
	err = db.WithContext(ctx).Create(user).Error
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, err)
	}
	ctx.JSON(http.StatusOK, model.NewUserCreateResponse(user))
}

這個 handler 主要完成了三件事情

  1. 從 http 的 body 中讀取請求,得到 userCreateRequest
  2. 根據 userCreateRequest 的內容進行真正的業務處理處理,操作邏輯,得到 userCreateResponse
  3. userCreateResponse寫入 http response

其中 1,3 都是與 http 相關的,而 2 則是與 http 無關的,因為我們僅僅想對資料的操作邏輯進行測試,因此可以將 2 抽離成一個獨立的 interface

func UserCreate(ctx *gin.Context) {
	req := new(model.UserCreateRequest)
	err := ctx.BindJSON(req)
	if err != nil {
		ctx.JSON(http.StatusBadRequest, err)
		return
	}
	resp, err := NewUser(db).Create(ctx, req)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, err)
	}
	ctx.JSON(http.StatusOK, resp)
}

type User interface {
	Create(ctx context.Context, req *model.UserCreateRequest) (*model.UserCreateResponse, error)
}

func NewUser(db *gorm.DB) User {
	return &UserEntry{db: db}
}

type UserEntry struct {
	db *gorm.DB
}

func (entry *UserEntry) Create(ctx context.Context, req *model.UserCreateRequest) (*model.UserCreateResponse, error) {
	user := model.NewUser(req)
	err := entry.db.WithContext(ctx).Create(user).Error
	if err != nil {
		return nil, errors.WithStack(err)
	}
	return model.NewUserCreateResponse(user), nil
}

單元測試一下

go test curd_demo/apis -v
PASS
ok      curd_demo/apis  1.126s

很自然的,會發現 User 這個 interface 並不屬於 api 層。於是新建一個 pkg 層,將 User 放進去

// api/user.go
func UserCreate(ctx *gin.Context) {
	req := new(model.UserCreateRequest)
	err := ctx.BindJSON(req)
	if err != nil {
		ctx.JSON(http.StatusBadRequest, err)
		return
	}
	resp, err := pkg.NewUser(db).Create(ctx, req) // 呼叫 pkg 層的介面
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, err)
	}
	ctx.JSON(http.StatusOK, resp)
}

依舊來一個單元測試,理所當然是pass

抽離出 dep 層來管理依賴

db 的初始化應該由誰來負責?

目前 db 物件是有 api 層的程式碼來初始化的,而 api 這麼上層的程式碼是不應該和 db 打交道的,它應該僅僅呼叫 pkg 層提供的介面,至於 pkg 層的介面是怎麼實現的,pkg 層依賴誰,api 層才不需要去關心呢。

db 作為一個被各個上層業務呼叫的工具人,毫無疑問應該在一個很底層的包裡去初始化

新建一個 dep 包吧,用來放置各種底層依賴。

下面 api 層就只需要依賴 dep 層裡面的介面,pkg 層負責實現 dep 層中的介面

// dep/hub.go
package dep

import (
   "curd_demo/config"
   "curd_demo/pkg"
   "gorm.io/driver/mysql"
   "gorm.io/gorm"
)

var Hub struct {
   DB   *gorm.DB
   User pkg.User
}

func Prepare() {
   db, err := gorm.Open(mysql.Open(config.Hub.DBSetting.MysqlDSN), &gorm.Config{})
   if err != nil {
      panic(err)
   }
   Hub.DB = db
   Hub.User = pkg.NewUser(db)
}
// main.go
package main

import (
	"math/rand"
	"time"

	"curd_demo/api"
	"curd_demo/config"
	"curd_demo/dep"
)

func main() {
	config.Initialize()
	dep.Prepare()			// here!!! 在服務剛開始啟動初始化配置後,就把所有依賴都初始化
	rand.Seed(time.Now().Unix())
	api.StartHttpService()
}
// api/user.go
func UserCreate(ctx *gin.Context) {
	req := new(model.UserCreateRequest)
	err := ctx.BindJSON(req)
	if err != nil {
		ctx.JSON(http.StatusBadRequest, err)
		return
	}
	resp, err := dep.Hub.User.Create(ctx, req)	// 呼叫 dep 包裡面的 user 介面
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, err)
	}
	ctx.JSON(http.StatusOK, resp)
}
func initDB() {
	config.Initialize()
	dsnCfg, err := gomysql.ParseDSN(config.Hub.DBSetting.MysqlDSN)
	if err != nil {
		panic(err)
	}
	dbName := dsnCfg.DBName
	// 在 dsn 中不指定 dbname
	dsnCfg.DBName = ""

	db, err := gorm.Open(mysql.Open(dsnCfg.FormatDSN()), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	// 開啟 debug 模式 方便看到每次執行時的 sql 語句
	db.Logger = db.Logger.LogMode(logger.Info)
	drop := fmt.Sprintf("DROP DATABASE IF EXISTS %s;", dbName)
	create := fmt.Sprintf("CREATE DATABASE %s DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;", dbName)
	use := fmt.Sprintf(`USE %s;`, dbName)
	migrations := FolderContentLoad(config.MigrationsFolderName)
	err = db.Exec(drop).Exec(create).Exec(use).Error
	if err != nil {
		panic(err)
	}
	for _, migrate := range migrations {
		err = db.Exec(migrate).Error
		if err != nil {
			panic(err)
		}
	}
	dep.Hub.DB = db			// here!!! 初始化 Hub 包裡面的依賴
	dep.Hub.User = pkg.NewUser(db) // here!!!
}
每層的單元測試也應該各司其職

先看看 api/user_test.go 裡面的測試程式碼

func TestUserCreate(t *testing.T) {
	initDB()

	g := gin.Default()
	SetUpRoutes(g)

	user := &model.User{
		Name: "levy",
		Age:  18,
		Sex:  model.MAN,
	}
	userCreateReq := model.NewUserCreateRequest(user)
	contentBytes, _ := json.Marshal(userCreateReq)
	reader := bytes.NewReader(contentBytes)

	req := httptest.NewRequest("POST", "/user/create", reader)
	w := httptest.NewRecorder()
	g.ServeHTTP(w, req)

	// 檢查 resp
	resp := w.Result()
	assert.Equal(t, http.StatusOK, resp.StatusCode)
	userCreateResp := new(model.UserCreateResponse)
	err := BindResp(resp, userCreateResp)
	assert.NoError(t, err)
	assert.Equal(t, user.Name, userCreateResp.User.Name)
	assert.Equal(t, user.Age, userCreateResp.User.Age)
	assert.Equal(t, user.Sex, userCreateResp.User.Sex)
	// 檢查資料庫
	userFromDB := user.DeepCopy()
	err = dep.Hub.DB.First(userFromDB, user).Error
	assert.NoError(t, err)
	assert.NotEqual(t, int64(0), userFromDB.UserId)
}

這段測試程式碼模擬了一個 http 請求的處理過程,它會檢查 http reponse 的 body 以及資料庫裡面的資料

但是 api 層只應該處理 http 相關的東西,我們把具體業務邏輯實現應該交由了 pkg 層去處理

同理,api 層的單元測試也只應該處理 http 相關的東西,業務邏輯的單元測試應該放到 pkg 層的單元測試裡面

在 pkg 層下面新建 user_test.go , 在裡面進行業務邏輯的測試

// pkg/user_test.go
package pkg

import (
	"context"
	"fmt"
	"testing"

	"curd_demo/config"
	"curd_demo/model"
	"curd_demo/util"
	gomysql "github.com/go-sql-driver/mysql"
	"github.com/stretchr/testify/assert"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func initDB() *gorm.DB {
	config.Initialize()
	dsnCfg, err := gomysql.ParseDSN(config.Hub.DBSetting.MysqlDSN)
	if err != nil {
		panic(err)
	}
	dbName := dsnCfg.DBName
	// 在 dsn 中不指定 dbname
	dsnCfg.DBName = ""

	db, err := gorm.Open(mysql.Open(dsnCfg.FormatDSN()), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	// 開啟 debug 模式 方便看到每次執行時的 sql 語句
	db.Logger = db.Logger.LogMode(logger.Info)
	drop := fmt.Sprintf("DROP DATABASE IF EXISTS %s;", dbName)
	create := fmt.Sprintf("CREATE DATABASE %s DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;", dbName)
	use := fmt.Sprintf(`USE %s;`, dbName)
	migrations := util.FolderContentLoad(config.ProjectName, config.MigrationsFolderName)
	err = db.Exec(drop).Exec(create).Exec(use).Error
	if err != nil {
		panic(err)
	}
	for _, migrate := range migrations {
		err = db.Exec(migrate).Error
		if err != nil {
			panic(err)
		}
	}
	return db
}

func TestUserEntry_Create(t *testing.T) {
	db := initDB()
	entry := UserEntry{db: db}
	user := &model.User{
		Name: "levy",
		Age:  18,
		Sex:  model.MAN,
	}
	userCreateReq := model.NewUserCreateRequest(user)
	userCreateResp, err := entry.Create(context.TODO(), userCreateReq)
	assert.NoError(t, err)
	assert.Equal(t, user.Name, userCreateResp.User.Name)
	assert.Equal(t, user.Age, userCreateResp.User.Age)
	assert.Equal(t, user.Sex, userCreateResp.User.Sex)
	// 檢查資料庫
	userFromDB := user.DeepCopy()
	err = db.First(userFromDB, user).Error
	assert.NoError(t, err)
	assert.NotEqual(t, int64(0), userFromDB.UserId)
}
利用 mock 來進行測試

再看看 UserCreate 方法

// api/user.go
func UserCreate(ctx *gin.Context) {
	req := new(model.UserCreateRequest)
	err := ctx.BindJSON(req)
	if err != nil {
		ctx.JSON(http.StatusBadRequest, err)
		return
	}
	resp, err := dep.Hub.User.Create(ctx, req)	// 不想進入 UserEntry 裡面
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, err)
	}
	ctx.JSON(http.StatusOK, resp)
}

如何做到在不接觸 UserEntry 的情況下,只對 http 層進行測試?

答:用 mock

利用 mockgen 工具可以根據介面來生成 mock entry 的程式碼

//go:generate mockgen -destination user_mock.go -package pkg -source user.go User
type User interface {
	Create(ctx context.Context, req *model.UserCreateRequest) (*model.UserCreateResponse, error)
}
// api/user_test.go
func TestUserCreate(t *testing.T) {
	g := gin.Default()
	SetUpRoutes(g)

	user := &model.User{
		Name: "levy",
		Age:  18,
		Sex:  model.MAN,
	}
	userCreateReq := model.NewUserCreateRequest(user)
	contentBytes, _ := json.Marshal(userCreateReq)
	reader := bytes.NewReader(contentBytes)

	req := httptest.NewRequest("POST", "/user/create", reader)
	w := httptest.NewRecorder()

  // here !!!!!!!!!!!!!!
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()
	userMock := pkg.NewMockUser(ctrl)
  // 注意
	dep.Hub.User = userMock
	// 可以用 gomock.Any() 代表任何型別的引數
	userMock.EXPECT().Create(gomock.Any(), userCreateReq).Return(new(model.UserCreateResponse), nil)
  // end !!!!!!!!!!!!!!!

	g.ServeHTTP(w, req)

	// 只簡單檢查一下 StatusCode
	assert.Equal(t, http.StatusOK, w.Code)
}

userMock.EXPECT().Create(gomock.Any(), userCreateReq).Return(new(model.UserCreateResponse), nil)

這句程式碼宣告瞭:在引數為 gomock.Any() 和 userCreateReq 時,呼叫 userMock.Create() 方法,得到的返回值將會是 new(model.UserCreateResponse) 和 nil。

mock 的一個更實用的例子: 對 rpc 介面進行 mock

單元測試的生命週期 && 利用 suite 包管理測試

單元測試大概有這五個階段

  1. BeforeAllTests 在開始執行第一條測試用例之前的階段
  2. BeforeTest 在執行每一條測試用例之前的階段
  3. InTest 正在執行某一條測試用例
  4. AfterTest 在執行每一條測試用例之後的階段
  5. AfterAllTests 在所有測試用例執行完成之後的階段
BeforeAllTests ---> BeforeTest ---> InTestA ---> AfterTest
							 ---> BeforeTest ---> InTestB ---> AfterTest ---> AfterAllTests

可以看到,步驟 1,5 是對稱的,2,4 也是對稱的

在 1,2,4,5 這幾個階段,是可以進行一次資源初始化和清理工作的

例如在階段 1,可以初始化所有的測試用例都需要用到的資源,例如在 pkg 包裡,所有測試用例都需要用到的資源肯定就是 db 啦,畢竟 pkg 包所進行的就是一些 db 的邏輯操作。

在階段 5,可以進行一些資源的清理操作,或者統計測試時間等

在階段2,4,可以進行一些單個測試需要的操作,例如之前在 api 層的 mock 測試有這麼兩句程式碼

ctrl := gomock.NewController(t)
defer ctrl.Finish()

實際在在每個單元測試用例裡面都是需要先建立一個嶄新的 ctrl (避免被之前的測試用例影響到) ,並在該條測試用例執行結束後把 ctrl 給 Finish() 掉

testify 的 suite 包裡面實現了對單元測試宣告週期的各個階段進行管理,只需要實現對應的介面即可

// SetupAllSuite has a SetupSuite method, which will run before the
// tests in the suite are run.
// 對應 BeforeAllTests 階段
type SetupAllSuite interface {
	SetupSuite()
}

// SetupTestSuite has a SetupTest method, which will run before each
// test in the suite.
// 對應 BeforeTest 階段
type SetupTestSuite interface {
	SetupTest()
}

// TearDownAllSuite has a TearDownSuite method, which will run after
// all the tests in the suite have been run.
// // 對應 AfterAllTests 階段
type TearDownAllSuite interface {
	TearDownSuite()
}

// TearDownTestSuite has a TearDownTest method, which will run after
// each test in the suite.
// 對應 AfterTest 階段
type TearDownTestSuite interface {
	TearDownTest()
}

// BeforeTest has a function to be executed right before the test
// starts and receives the suite and test names as input
type BeforeTest interface {
	BeforeTest(suiteName, testName string)
}

// AfterTest has a function to be executed right after the test
// finishes and receives the suite and test names as input
type AfterTest interface {
	AfterTest(suiteName, testName string)
}

// WithStats implements HandleStats, a function that will be executed
// when a test suite is finished. The stats contain information about
// the execution of that suite and its tests.
type WithStats interface {
	HandleStats(suiteName string, stats *SuiteInformation)
}

在 api 包下面建一個 suite_test.go 在裡面完成 suite 物件的建立操作

// api/user_test.go
type SuiteTest struct {
	suite.Suite

	userMock *pkg.MockUser
	ctrl     *gomock.Controller
}

// In order for 'go test' to run this suite, we need to create
// a normal test function and pass our suite to suite.Run
func TestSuiteTest(t *testing.T) {
	suite.Run(t, new(SuiteTest))
}

// SetupAllSuite has a SetupSuite method, which will run before the
// tests in the suite are run.
func (s *SuiteTest) SetupSuite() {
	config.Initialize()
}

// SetupTestSuite has a SetupTest method, which will run before each
// test in the suite.
func (s *SuiteTest) SetupTest() {
	s.ctrl = gomock.NewController(s.T())
	s.userMock = pkg.NewMockUser(s.ctrl)
	dep.Hub.User = s.userMock
}

// TearDownTestSuite has a TearDownTest method, which will run after
// each test in the suite.
func (s *SuiteTest) TearDownTest() {
	s.ctrl.Finish()
}
// pkg/user_test.go
func (s *SuiteTest) TestUserCreate() {
	g := gin.Default()
	SetUpRoutes(g)

	user := &model.User{
		Name: "levy",
		Age:  18,
		Sex:  model.MAN,
	
	userCreateReq := model.NewUserCreateRequest(user)
	contentBytes, _ := json.Marshal(userCreateReq)
	reader := bytes.NewReader(contentBytes)

	req := httptest.NewRequest("POST", "/user/create", reader)
	w := httptest.NewRecorder()
	// 可以用 gomock.Any() 代表任何型別的引數
	s.userMock.EXPECT().Create(gomock.Any(), userCreateReq).Return(new(model.UserCreateResponse), nil)		// here!!!!

	g.ServeHTTP(w, req)
	// 只簡單檢查一下 StatusCode
	s.Equal(http.StatusOK, w.Code)
}

同理 pkg 層也做一下改造

// pkg/user_test.go
type SuiteTest struct {
	suite.Suite
	db        *gorm.DB
	UserEntry *UserEntry
}

// In order for 'go test' to run this suite, we need to create
// a normal test function and pass our suite to suite.Run
func TestSuiteTest(t *testing.T) {
	suite.Run(t, new(SuiteTest))
}

// SetupAllSuite has a SetupSuite method, which will run before the
// tests in the suite are run.
func (s *SuiteTest) SetupSuite() {
	config.Initialize()
	dsnCfg, err := gomysql.ParseDSN(config.Hub.DBSetting.MysqlDSN)
	if err != nil {
		panic(err)
	}
	dbName := dsnCfg.DBName
	// 在 dsn 中不指定 dbname
	dsnCfg.DBName = ""

	db, err := gorm.Open(mysql.Open(dsnCfg.FormatDSN()), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	// 開啟 debug 模式 方便看到每次執行時的 sql 語句
	db.Logger = db.Logger.LogMode(logger.Info)
	drop := fmt.Sprintf("DROP DATABASE IF EXISTS %s;", dbName)
	create := fmt.Sprintf("CREATE DATABASE %s DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;", dbName)
	use := fmt.Sprintf(`USE %s;`, dbName)
	migrations := util.FolderContentLoad(config.ProjectName, config.MigrationsFolderName)
	err = db.Exec(drop).Exec(create).Exec(use).Error
	if err != nil {
		panic(err)
	}
	for _, migrate := range migrations {
		err = db.Exec(migrate).Error
		if err != nil {
			panic(err)
		}
	}
	s.db = db
	s.UserEntry = &UserEntry{db: db}
}
// user_test.go
func (s *SuiteTest) TestUserEntry_Create() {
	user := &model.User{
		Name: "levy",
		Age:  18,
		Sex:  model.MAN,
	}
	userCreateReq := model.NewUserCreateRequest(user)
	userCreateResp, err := s.UserEntry.Create(context.TODO(), userCreateReq) // // here!!! 使用 SuiteTest 裡面的 userEntry 成員
	s.NoError(err)						// here!!! 斷言方法可以全都用 suite 的方法替代
  s.Equal(user.Name, userCreateResp.User.Name)
	s.Equal(user.Age, userCreateResp.User.Age)
	s.Equal(user.Sex, userCreateResp.User.Sex)
	// 檢查資料庫
	userFromDB := user.DeepCopy()
	err = s.db.First(userFromDB, user).Error	// here!!! 使用 SuiteTest 裡面的 db 成員
	s.NoError(err)
	s.NotEqual(int64(0), userFromDB.UserId)
}

單元測試一下

➜  curd_demo git:(master) ✗ go test ./...      
?       curd_demo       [no test files]
ok      curd_demo/api   3.036s
?       curd_demo/config        [no test files]
?       curd_demo/dep   [no test files]
?       curd_demo/model [no test files]
ok      curd_demo/pkg   3.159s
ok      curd_demo/util  0.186s

介面的好處:可能當前這個介面的實現是在自己服務內部進行資料查詢,但是如果以後想拆分成微服務,也是非常容易的,新建立一個實現,在這個實現內部呼叫 grpc 就行,都上層程式碼沒有一絲一毫的影響。

利用 dig 庫管理依賴

此時專案還不是太複雜,進行依賴管理似乎顯得有點多餘

目前專案中的業務層介面還只有一個: User 介面,它依賴於一個 db 物件

graph BT db --> User

隨著業務的複雜度增加,以後肯定會有很多新的介面,

例如 Product (商品) 和 Comment (評論) 介面,他們也依賴 db 物件。

可能還會出現介面的組合,比如某個 UserProductCompose 介面,他可能依賴 user 和 product

graph BT db --> Comment db --> User db --> Product User --> ComposeUserComment Comment --> ComposeUserComment User --> ComposeUserProduct Product --> ComposeUserProduct

此時 dep 包裡面的景象可能是這樣的

var Hub struct {
	DB                 *gorm.DB
	User               pkg.User
	Comment            pkg.Comment
	Product            pkg.Product
	ComposeUserProduct pkg.ComposeUserProduct
	ComposeUserComment pkg.ComposeUserComment
}

func Prepare() {
	db, err := gorm.Open(mysql.Open(config.Hub.DBSetting.MysqlDSN), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	Hub.DB = db
	Hub.User = pkg.NewUser(db)				// here!!!
	Hub.Comment = pkg.NewComment(db)  // here!!!
	Hub.Product = pkg.NewProduct(db)	// here!!!
	Hub.ComposeUserProduct = pkg.NewComposeUserProduct(Hub.User, Hub.Product) 	// here!!!
	Hub.ComposeUserComment = pkg.NewComposeUserComment(Hub.User, Hub.Comment)		// here!!!
}
// pkg/xxx.go
type Comment interface{}
type Product interface{}
type ComposeUserProduct interface{}
type ComposeUserComment interface{}

type CommentEntry struct {
	db *gorm.DB
}
type ProductEntry struct {
	db *gorm.DB
}
type ComposeUserProductEntry struct {
	user    User
	product Product
}
type ComposeUserCommentEntry struct {
	user    User
	comment Comment
}

func NewComment(db *gorm.DB) Comment {
	return &CommentEntry{db: db}
}
func NewProduct(db *gorm.DB) Product {
	return &ProductEntry{db: db}
}
func NewComposeUserProduct(user User, product Product) ComposeUserProduct {
	return &ComposeUserProductEntry{
		user:    user,
		product: product,
	}
}
func NewComposeUserComment(user User, comment Comment) ComposeUserComment {
	return &ComposeUserCommentEntry{
		user:    user,
		comment: comment,
	}
}

每增加一個新的 interfaceImplement ,都需要在 Prepare() 裡面呼叫 NewXXX(dep1,dep2...depn) 例項出一個物件出來。其中 dep1,dep2...depn 指的是該 implement 所需要的依賴。

用上面的 NewComposeUserProduct() 來舉例,它依賴 UserProduct這兩個介面,而為了得到這兩個介面,你就必須呼叫 NewUser() 和 NewProduct() 傳入 db 物件來生成這兩個介面。對於高層的介面,它的依賴鏈可能會很長。當依賴鏈過長且依賴鏈間彼此交織,如果需要自己去初始化依賴鏈條上面的每一個節點,可能會有點繁瑣。

dig 庫提供了一種自動生成依賴的能力,只要使用者給出物件的生成方法( constructor) ,它就能就能初始化出相應的物件。

這裡有一個 DI 庫使用方法的簡單介紹: https://www.cnblogs.com/XiaoXiaoShuai-/p/15316584.html

應用了 DI 庫之後,程式碼就變成這樣了

// dep/hub.go
var Hub hub

type hub struct {
	dig.In
	DB   *gorm.DB
	User pkg.User
}

var diContainer = dig.New()

func NewGormDB() (*gorm.DB, error) {
	return gorm.Open(mysql.Open(config.Hub.DBSetting.MysqlDSN), &gorm.Config{})
}

func Prepare() {
	_ = diContainer.Provide(NewGormDB)
	_ = diContainer.Provide(pkg.NewUser)

	err := diContainer.Invoke(func(h hub) {
		Hub = h
	})
	if err != nil {
		panic(err)
	}
}
將 table migrate 到生產環境

隨著業務的擴大,肯定會需要新增表結構或者修改原來的表結構。我原來的做法是先這個 sql 語句儲存下來,每當需要釋出新的版本時,先用 jumpserver 連線到 production 的機器,然後在上面連線到 production 的資料庫。之後再將這些 sql 檔案全都執行進去。每次在 production 環境手動輸入 sql 語句都讓人有點心驚膽戰,生怕某個字元輸錯了。那是否可以自動的遷移 sql 檔案到 production 的資料庫中?

在之前的單元測試中,會自動 migrate sql 檔案到測試用的資料庫裡面。做法是先 drop 掉原來的資料庫並重新建一個新的,之後再將資料庫全都遷移到新的資料庫裡面,因此資料庫每次都是新的,所以不會存在某個 sql 檔案被重複 migrate 的情況。在 production 環境中,肯定是不能做 drop database 這種操作的。為了防止 sql 檔案被重複 migrate,可以在資料庫裡面新建一張表,這張表裡面會記錄已經 migrate 的 sql 檔名。

CREATE TABLE IF NOT EXISTS migrations
(
    id         BIGINT(20)   NOT NULL AUTO_INCREMENT,
    file_name  VARCHAR(191) NOT NULL DEFAULT '' COMMENT '已經遷移的檔名',
    deleted_at BIGINT(20)   NOT NULL DEFAULT 0,
    created_at BIGINT(20)   NOT NULL DEFAULT 0,
    updated_at BIGINT(20)   NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    UNIQUE INDEX udx_file_name (file_name)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci COMMENT '遷移資訊表';

每次遷移時,會將 migrations 資料夾下面的 sql 檔案全都 load 出來,將 migrations 表裡面的資料也 load 出來,得到還沒有被遷移的 sql 檔案,將這些檔案全都遷移到資料庫裡面,並在 migrations 表裡面新增這些檔案的記錄。

type DBEntry struct {
	dig.In
	*gorm.DB
}

func (entry *DBEntry) Migrate(ctx context.Context) error {
	entry.DB = entry.Debug()
	err := entry.WithContext(ctx).Exec(new(model.Migration).SQL()).Error
	if err != nil {
		return errors.WithStack(err)
	}
	migrations := make([]*model.Migration, 0, 0)
	err = entry.WithContext(ctx).Find(&migrations).Error
	if err != nil {
		return errors.WithStack(err)
	}
	folder, err := util.LoadFolderUnderProject(config.ProjectName, config.MigrationsFolderName)
	if err != nil {
		return errors.WithStack(err)
	}
	filesNotMigrated := model.MigrationSlice(migrations).FilesNotMigrated(folder)
	for _, file := range filesNotMigrated {
		// 執行遷移檔案
		if err = entry.WithContext(ctx).Exec(string(file.Content)).Error; err != nil {
			return errors.Errorf("exec err,file[%v],err[%v]", file, err)
		}
		// 插入 migrations 表中一條記錄
		m := &model.Migration{FileName: file.Name}
		if err = entry.WithContext(ctx).Create(m).Error; err != nil {
			return errors.Errorf("create err,migration[%v],err[%v]", m, err)
		}
	}
	return nil
}
利用 cobra 來管理 cli 命令

Cobra is both a library for creating powerful modern CLI applications as well as a program to generate applications and command files.

可以把上面遷庫工具整合到我們的專案裡面,用一個命令去執行它。我們的專案叫做 curd_demo ,它目前有兩個功能

  1. 啟動 http 服務
  2. migrate sql 檔案

結合 cobra 庫,可以用兩個單獨的子命令來分別啟動這兩個功能

➜  curd_demo git:(master) ✗ ./curd_demo -h
a curd demo to learn how to write testable code

Usage:
  curd_demo [command]

Available Commands:
  completion  generate the autocompletion script for the specified shell
  help        Help about any command
  http        啟動 http 服務
  migrate     遷移 sql 檔案到資料庫

Flags:
  -h, --help   help for curd_demo

Use "curd_demo [command] --help" for more information about a command.

啟動 http 服務

➜  curd_demo git:(master) ✗ ./curd_demo http
INFO[0000] start http service...                        
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /hello                    --> curd_demo/api.Hello (3 handlers)
[GIN-debug] POST   /user/create              --> curd_demo/api.UserCreate (3 handlers)
[GIN-debug] Listening and serving HTTP on :3344

執行 migrate 命令

➜  curd_demo git:(master) ✗ ./curd_demo migrate    
INFO[0000] start migrate ...  
...

這樣每次釋出新版本時,只需要執行 curd_demo migrate 命令就可以將 sql 表遷移過去了。

程式碼的變化

新建一個 cmd 包,下面有三個檔案

// cmd/root.go
package cmd

import (
	"math/rand"
	"time"

	"curd_demo/config"
	"curd_demo/dep"
	"github.com/spf13/cobra"
)

// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
	Use:   "curd_demo",
	Short: "a curd demo to learn how to write testable code",
}

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
	cobra.OnInitialize(initialize)
	rootCmd.AddCommand(&migrateCommand)
	rootCmd.AddCommand(&httpCommand)
	cobra.CheckErr(rootCmd.Execute())
}

func initialize() {
	config.Initialize()
	dep.Prepare()
	rand.Seed(time.Now().Unix())
}
// cmd/http.go
package cmd

import (
	"curd_demo/api"
	"github.com/spf13/cobra"
)

var httpCommand = cobra.Command{
	Use:   "http",
	Short: "啟動 http 服務",
	RunE: func(cmd *cobra.Command, args []string) error {
		return api.StartHttpService()
	},
}
// cmd/migrate.go
package cmd

import (
	"context"
	"time"

	"curd_demo/dep"
	"github.com/pkg/errors"
	"github.com/sirupsen/logrus"
	"github.com/spf13/cobra"
)

var migrateCommand = cobra.Command{
	Use:   "migrate",
	Short: "遷移 sql 檔案到資料庫",
	RunE: func(cmd *cobra.Command, args []string) error {
		logrus.Info("start migrate ...")
		ctx, cancel := context.WithTimeout(context.Background(), time.Minute*2)
		defer cancel()
		err := dep.Hub.DB.Migrate(ctx)
		if err != nil {
			return errors.WithStack(err)
		}
		logrus.Info("end migrate")
		return nil
	},
}

在 main.go 裡面呼叫 Execute() 即可

// main
package main

import "curd_demo/cmd"

func main() {
	cmd.Execute()
}
gitlab ci

終於講到 CI 了,單元測試是 CI 裡面比不可少的一部分。目前我們專案使用的是 gitlab 自帶的 CI 工具。使用起來還是蠻方便的。

首先需要把專案打包成一個 image,config 資料夾裡面防止了專案啟動所必須要的配置檔案,migrations 資料夾裡面放置了 sql 檔案,這兩貨是專案啟動所必不可少的。所以需要把這兩個資料夾裡面的檔案全都放到 image 裡面,

其次,由於我們的 pkg 包裡面的單元測試都是直接操作的資料庫,因此 CI 裡面肯定也需要一個 mysql 服務來陪伴。gitlab ci 的 runner 裡面就提供了這樣的功能,和 docker-compose 一樣,同樣使用了 services 這個關鍵字。

下面就是 test 階段所用到的 gitlab-ci.yml,

# .gitlab-ci.yml
test:
  stage: test
  image: golang:1.16
  services:
    - name: mysql:5.7
#      entrypoint: [ "docker-entrypoint.sh" ]
#      command: [ "mysqld" ]
  before_script:
    - echo 'wait for mysql, sleep 5s zzz'
    - sleep 5
  script:
    - make test
  variables:
    MYSQL_ALLOW_EMPTY_PASSWORD: "yes" # 這個環境變數會傳遞到 mysql 映象
  tags:
    - docker # 這個 tag 是公司的 gitlab runner 要求的, 如果沒有 tag 的話, 公司的 runner 就不會執行這個 CI job

簡單說一下 gitlab-ci.yml ,更詳細的資料可以去官網瞭解或者去網上找資料

gitlab-ci.yml 由一個個 job 構成,上面只有一個 job,job 名為 test,該 job 所屬的 stage 也同樣名為 test。

stage 可以有三個值: build,test,deploy, 每個階段可以有多個 job

test1:
	stage: test
test2:
	stage: test

如上,在 test 這個 stage 上有 test1 和 test2 這兩個 job

將會按照 build,test,deploy 的順序來執行 stage 上面的 job

image 指定這個 job 會在那個 image 上面執行,如果沒有 image,將會在一個 linux shell 環境下面執行。因為這裡的單元測試需要用到 go sdk,所以用到了 go 的映象。

services 指定這個 job 需要用到哪些映象,services 裡面指定的映象將會和 image 中指定的映象關聯在一起

Similar to image property, but will link the specified services to the image container.

script 指定了該 job 執行的指令

before-script 將會在執行 script 之前執行

variables 中指定的變數將會傳遞到該 job 裡面容器的環境變數裡面。

job 都是放在 gitlab runner 上去跑的,tags 則用來指定 runner

Use tags to select a specific runner from the list of all runners that are available for the project

執行的效果是這樣的

$ echo 'wait for mysql, sleep 5s zzz'
wait for mysql, sleep 5s zzz
$ sleep 5
$ make test
go test ./...
?   	curd_demo	[no test files]
ok  	curd_demo/api	0.018s
?   	curd_demo/cmd	[no test files]
?   	curd_demo/config	[no test files]
?   	curd_demo/dep	[no test files]
ok  	curd_demo/model	0.005s
ok  	curd_demo/pkg	0.076s
ok  	curd_demo/util	0.011s
Job succeeded
單元測試的 tip

tip1: 單元測試所用到的資料儘量不要 hard code, 多用隨機函式去生成

tip2: 利用工具函式來生成單元測試所需要的 model

func fakeUser(opts ...func(*User)) *User {
	m := &User{
		UserId: rand.Int63(),
		Age:    int32(rand.Intn(100)),
	}
	for _, opt := range opts {
		opt(m)
	}
	return m
}

題外話: 單倉和多倉

我原本是多倉的忠實擁躉,按照功能的不同,建立不同的微服務,每個微服務一個 repo,不同的 repo 有著各自的一畝三分地,不干涉不屬於自己的業務,不同的微服務通過 grpc 相互呼叫。

但是微服務就一定得是一個單獨的 repo 麼?不能把這些微服務全都放在同一個 repo 下面麼?

例如之前我將 GameServer 和 GMServer 放在了不同的 repo 裡面,優點就是各自的功能比較明確,GMServer 就是用來負責一些後臺的 CURD ,不會干涉 GameServer 核心的業務邏輯(當然肯定可以通過影響 mysql 從而影響到 gameserver)。

缺點是,兩個 repo 有很多重複的程式碼,例如資料庫裡某些表的 model struct 以及對這些 struct 的增刪改查程式碼就是相同的,因為兩個 repo 都是對相同的表在進行增刪改查。

一開始的做法是將這些 model 層的程式碼 copy 一份放在另一個 repo 裡面,但是之後當 GameServer 裡面 model 層的程式碼發生變動時可能並不會同步到 GMServer 那邊,兩邊的 model 層程式碼差異慢慢會變大,最終會變成各自維護一份程式碼,雖然這兩份程式碼的內容可能很多都是一樣的。

之後又想著將這些公用的 model 都放進一個獨立的包裡單獨釋出,然後讓兩個 repo 都 import 這個公用的包,一旦 model 層發生了變化,兩邊只要都 go get 到最新的程式碼就行了。可這樣也有問題,因為 model 層的程式碼是可能進場發生變化的,比如說,為了程式碼的可讀性,需要一些邏輯封裝成一個個 method,每當有這樣的變動時,都需要去那個公共的 model 包裡面更新程式碼,打 tag,之後再 go get 更新 model 包的版本,get 到新的 model 程式碼後,在繼續寫邏輯。所以特別繁瑣,效率很低。

所以就乾脆把 GameServer 和 GMServer 放在一個 repo 下面唄,他們的 model 和 curd 介面都是相同的,只要寫一份,兩邊的上層邏輯都能呼叫,他們都屬於這個大的 repo 下面的不同的 component,使用不同的 cmd 命令啟動即可。

這樣在遷移/修復資料庫資料時非常方便,因為有 model 包以及各種 db 介面依賴的支援。上層的業務程式碼(遷移資料也算是業務程式碼)呼叫時會特別方便。

所以,我漸漸變成單倉的擁躉了。

我認為可以把專案的程式碼都放在一個倉庫裡,不同的服務用一個不同的 cmd 啟動即可(利用 cobra 包)。

據說 Google 全公司的專案都在一個很大的 repo 裡面,我感覺這不是挺合適的。。

題外話: compose 層

介面之間的層級關係應該是清晰的,不要出現介面 A 呼叫介面 B,介面 B 又反過來呼叫介面 A 的情況,如果有這種情況,那就抽離出一個介面 C,讓介面 C 來呼叫介面 A 和 介面 B,或者讓介面 A 和 介面 B 去呼叫介面 C

如果採用讓介面 C 來呼叫介面 A 和 介面 B的方式,則 C 介面依賴 A 和 B 這兩個介面,那就可以新建一個 compose 層,把 C 介面放在 compose 層裡面,這樣當對 C 介面進行單元測試時,可以 mock A 和 B 介面的結果。

相關連結

https://github.com/bouk/monkey

https://geektutu.com/post/quick-go-test.html

https://geektutu.com/post/quick-gomock.html

相關文章