Go語言測試在開發中的最佳實踐 | 使用Docker容器進行測試

語言: CN / TW / HK

highlight: atelier-dune-light theme: orange


前言

最近看到很多Go語言測試的教程都非常水,只講了測試最基本的用法,幾乎沒有涉及到在開發中如何去設計一個很出色的測試。這篇部落格將會帶領大家一步一步完成一個出色的Go-Test

思考

Go語言擁有一套單元測試和效能測試系統,僅需要新增很少的程式碼就可以快速測試一段需求程式碼

一個好的測試該是什麼樣的呢,應該是脫離一切外部的限制或者說完成測試後並不會影響正式的開發。並且模擬出真實開發時的情況

實踐

初始化專案

開啟一個專案之後我們首先就要進行go mod init,後續這個專案也會拉取第三方的庫

shell $ go mod init awesomeTest

為了模擬真實的開發場景,我們使用MongoDB來進行操作,而為了避免太過複雜,只設置一個key

以下操作涉及MongoDB的知識,如果不會的同學也不用擔心,主要掌握的方法而不是哪個特定的資料庫

首先我們需要在本地執行MongoDB,推薦使用Docker,不會的同學可以看一看我的上一篇部落格使用Docker容器部署MongoDB並支援遠端訪問

接下來我們需要安裝操作MongoDB的第三方依賴

shell $ go get "go.mongodb.org/mongo-driver/mongo"

然後我們在專案中建立我們所需要的資料

```go //main.go package main

import ( "context" "fmt" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" )

func main() { c := context.Background() mc, err := mongo.Connect(c, options.Client().ApplyURI("mongodb://localhost:27017")) if err != nil { panic(err) } col := mc.Database("awesomeTest").Collection("test") insertRows(c, col) }

func insertRows(c context.Context, col *mongo.Collection) { res, err := col.InsertMany(c, []interface{}{ bson.M{ "test_id": "123", }, bson.M{ "test_id": "456", }, }) if err != nil { panic(err) }

fmt.Printf("%+v", res) } ```

&{InsertedIDs:[ObjectID("62ce8ed4e2aaad4e36242623") ObjectID("62ce8ed4e2aaad4e36 242624")]} 程序 已完成,退出程式碼為 0

main.go中我們插入了兩個test_id,並打印出了對應的ObjID,拿到ObjID之後我們就可以進行一些簡單的測試

初級測試

想要測試我們肯定需要先實現一些方法來進行呼叫 ```go // mongo/mongo.go // Mongo定義一個mongodb的資料訪問物件 type Mongo struct { col *mongo.Collection }

// 使用NewMongo來初始化一個mongodb的資料訪問物件 func NewMongo(db mongo.Database) Mongo { return &Mongo{ col: db.Collection("test"), } } ```

接下來可以給mongodb的資料訪問物件實現一些方法,由於是教程,我們不會搞一些很複雜的,但現實開發中肯定會複雜不少,但方法都是一致的

```go // mongo/mongo.go // 將test_id解析為ObjID func (m *Mongo) ResolveObjID(c context.Context, testID string) (string, error) { res := m.col.FindOneAndUpdate(c, bson.M{ "test_id": testID, }, bson.M{ "$set": bson.M{ "test_id": testID, }, }, options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After))

if err := res.Err(); err != nil { return "", fmt.Errorf("cannot findOneAndUpdate: %v", err) }

var row struct { ID primitive.ObjectID bson:"_id" } err := res.Decode(&row) if err != nil { return "", fmt.Errorf("cannot decode result: %v", err) } return row.ID.Hex(), nil } ```

當我們將上下文以及testID傳進去這個方法之後我們會在MongoDB中查詢相應的ObjID,並return出來,若有錯誤則直接將錯誤列印並結束程式

完成這些簡單的程式碼之後就可以開始初級測試,一個非常基礎的測試,但可以直接檢驗方法的正確與否而不需要例項呼叫

go // mongo/mongo_test.go func TestMongo_ResolveAccountID(t *testing.T) { c := context.Background() mc, err := mongo.Connect(c, options.Client().ApplyURI("mongodb://localhost:27017")) if err != nil { t.Fatalf("cannot connect mongodb: %v", err) } m := NewMongo(mc.Database("awesomeTest")) id, err := m.ResolveObjID(c, "123") if err != nil { t.Errorf("faild resolve Obj id for 123: %v", err) } else { want := "62ce8ed4e2aaad4e36242623" if id != want { t.Errorf("resolve Obj id: want: %q, got: %q", want, id) } } }

上述測試樣例先連線了資料庫並在測試中新建了一個MongoDB的資料訪問物件,然後將test_id傳了進去並解析出相應的ObjID,若未解析成功或答案不一致則測試失敗

再次思考

完成了上述測試之後我們會想,如果這樣進行測試的話會連線外部的資料庫,甚至是開發中使用的資料庫。一個完美的測試應該不會依賴外界或對外界有造成改變的可能,對此我想到了使用現在非常流行的容器工具Docker,在測試開始時,我們新建一個MongoDB的容器,在測試結束之後將其關閉,這樣就能完美實現我們的想法

Docker

Docker 是一個開源的應用容器引擎,讓開發者可以打包他們的應用以及依賴包到一個可移植的映象中,然後釋出到任何流行的 LinuxWindows作業系統的機器上,也可以實現虛擬化。容器是完全使用沙箱機制,相互之間不會有任何介面

關於Docker就介紹到這裡,如果不會使用的同學同上,可以去看看我Docker相關的文章

為了實踐我們上面所想到操作,我們先新建一個Docker資料夾進行實驗docker/main.go

首先我們拉取在Go語言中操作Docker的相關第三方包

shell $ go get -u "github.com/docker/docker"

大概的思路就是先新建一個新的Docker映象並給他找一個空的埠執行,預計了一下大概的測試用時以及其他的時間,我們穩妥地讓它存活五秒鐘,在時間到後立馬將其銷燬 ``` package main

import ( "context" "fmt" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" "time" )

func main() { c, err := client.NewClientWithOpts() if err != nil { panic(err) }

ctx := context.Background()

resp, err := c.ContainerCreate(ctx, &container.Config{ Image: "mongo:latest", ExposedPorts: nat.PortSet{ "27017/tcp": {}, }, }, &container.HostConfig{ PortBindings: nat.PortMap{ "27017/tcp": []nat.PortBinding{ { HostIP: "127.0.0.1", HostPort: "0", //隨意找一個空的埠 }, }, }, }, nil, nil, "") if err != nil { panic(err) }

err = c.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}) if err != nil { panic(err) }

fmt.Println("container started") time.Sleep(5 * time.Second)

inspRes, err := c.ContainerInspect(ctx, resp.ID) if err != nil { panic(err) }

fmt.Printf("listening at %+v\n", inspRes.NetworkSettings.Ports["27017/tcp"][0])

fmt.Println("killing container") err = c.ContainerRemove(ctx, resp.ID, types.ContainerRemoveOptions{ Force: true, }) if err != nil { panic(err) } } ``` 用Go語言簡單實現了一下,並不複雜

進階測試

在思路理清晰之後我們就要對剛才的測試進行改動,所謂進階測試當然不可能只測試一組資料,我們會使用到表格驅動測試

回到剛才的Docker操作,我們不可能將上面那麼多的程式碼都放進測試中,所以我們新建一個mongotesting.go檔案將此類函式封裝在外部

首先是在Docker中跑MongoDB的函式,與上文的區別不大,主要是在建立容器後的操作有所改變

```go //mongo/mongotesting.go

func RunWithMongoInDocker(m *testing.M) int {

...

containerID := resp.ID defer func() { err := c.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{ Force: true, }) if err != nil { panic(err) } }()

err = c.ContainerStart(ctx, containerID, types.ContainerStartOptions{}) if err != nil { panic(err) }

inspRes, err := c.ContainerInspect(ctx, containerID) if err != nil { panic(err) } hostPort := inspRes.NetworkSettings.Ports["27017/tcp"][0] mongoURI = fmt.Sprintf("mongodb://%s:%s", hostPort.HostIP, hostPort.HostPort)

return m.Run()

} ```

呼叫了一個defer使其在return之後就刪除掉

接下來是在Docker中與MongoDB建立一個新連線的函式

```go //mongo/mongotesting.go

func NewClient(c context.Context) (*mongo.Client, error) { if mongoURI == "" { return nil, fmt.Errorf("mong uri not set. Please run RunWithMongoInDocker in TestMain") } return mongo.Connect(c, options.Client().ApplyURI(mongoURI)) } ```

最後是建立ObjID的函式,也是非常簡單的呼叫

```go //mongo/mongotesting.go

func mustObjID(hex string) primitive.ObjectID { ObjID, err := primitive.ObjectIDFromHex(hex) if err != nil { panic(err) } return ObjID } ```

完成這一步準備之後我們就可以開始寫最終的測試程式碼

在此之前我們再縷一縷思路,我們的想法是在開始測試之前開啟一個MongoDBDocker容器,然後測試結束時自動關閉。測試中呢我們使用表格驅動測試,使用兩組資料來測試。開始測試後我們先起一個MongoDB的連線,然後新建一個test資料庫並插入兩組資料,接下來寫測試樣例,然後用一個for range結構跑完所有的資料並驗證正確性。

我們將以上的步驟分為三步走

第一步 新建連線,插入資料

```go //mongo/mongo_test.go func TestResolveObjID(t *testing.T) { c := context.Background() mc, err := NewClient(c) if err != nil { t.Fatalf("cannot connect mongodb: %v", err) }

m := NewMongo(mc.Database("test")) _, err = m.col.InsertMany(c, []interface{}{ bson.M{ "_id": mustObjID("5f7c245ab0361e00ffb9fd6f"), "test_id": "testid_1", }, bson.M{ "_id": mustObjID("5f7c245ab0361e00ffb9fd70"), "test_id": "testid_2", }, }) if err != nil { t.Fatalf("cannot insert initial values: %v", err) }

...

} ```

第一步中我們呼叫了寫在mongotesting.go中的NewClient來建立一個新的連線,並向其中加入了兩組資料

第二步 加入樣例,準備測試

```go //mongo/mongo_test.go func TestResolveObjID(t *testing.T) {

...第一步

cases := []struct { name string testID string want string }{ { name: "existing_user", testID: "testid_1", want: "5f7c245ab0361e00ffb9fd6f", }, { name: "another_existing_user", testID: "testid_2", want: "5f7c245ab0361e00ffb9fd70", }, }

...

} ```

在第二步中我們使用表格驅動測試的方法放入了兩個樣例準備進行測試

第三步 遍歷樣例,使用容器

```go //mongo/mongo_test.go func TestResolveObjID(t *testing.T) {

...第一步

...第二步

for _, cc := range cases {
  t.Run(cc.name, func(t *testing.T) {
     rid, err := m.ResolveObjID(context.Background(), cc.testID)
     if err != nil {
        t.Errorf("faild resolve Obj id for %q: %v", cc.testID, err)
     }
     if rid != cc.want {
        t.Errorf("resolve Obj id: want: %q; got: %q", cc.want, rid)
     }
  })

} } ```

在這裡我們使用了for range結構遍歷了我們所有了樣例,將test_id解析成了ObjID,成功對應後就會通過測試,反之會報錯

接下來是最重要的地方,我們需要使用Docker就還需要在測試檔案中加上最後一個函式

go func TestMain(m *testing.M) { os.Exit(RunWithMongoInDocker(m)) }

在此之後我們的測試就寫好了,大家可以開啟Docker來實驗一下

image.png

結語

如果有沒弄清楚的地方歡迎大家向我提問,我都會盡力解答

這是我的GitHub主頁 github.com/L2ncE

歡迎大家Follow/Star/Fork三連

本文正在參加技術專題18期-聊聊Go語言框架