單測時儘量用 fake object

1. 單元測試的難點:外部協作者 (external collaborators) 的存在

單元測試是軟件開發的一個重要部分,它有助於在開發週期的早期發現錯誤,幫助開發人員增加對生產代碼正常工作的信心,同時也有助於改善代碼設計。Go 語言從誕生那天起就內置 Testing 框架 (以及測試覆蓋率計算工具),基於該框架,Gopher 們可以非常方便地爲自己設計實現的 package 編寫測試代碼。

注:《Go 語言精進之路》vol2[1] 中的第 40 條到第 44 條有關於 Go 包內、包外測試區別、測試代碼組織、表驅動測試、管理外部測試數據等內容的系統地講解,感興趣的童鞋可以讀讀。

不過即便如此,在實際開發工作中,大家發現單元測試的覆蓋率依舊很低,究其原因,排除那些對測試代碼不作要求的組織,剩下的無非就是代碼設計不佳,使得代碼不易測;或是代碼有外部協作者(比如數據庫、redis、其他服務等)。代碼不易測可以通過重構來改善,但如果代碼有外部協作者,我們該如何對代碼進行測試呢,這也是各種編程語言實施單元測試的一大共同難點

爲此,《xUnit Test Patterns : Refactoring Test Code》[2] 一書中提供了 Test Double(測試替身) 的概念專爲解決此難題。那麼什麼是 Test Double 呢?我們接下來就來簡單介紹一下 Test Double 的概念以及常見的種類。

2. 什麼是 Test Double?

測試替身是在測試階段用來替代被測系統依賴的真實組件的對象或程序 (如下圖),以方便測試,這些真實組件或程序即是外部協作者 (external collaborators)。這些外部協作者在測試環境下通常很難獲取或與之交互。測試替身可以使開發人員或 QA 專業人員專注於新的代碼而不是代碼與環境集成。

測試替身是通用術語,指的是不同類型的替換對象或程序。目前 xUnit Patterns[3] 至少定義了五種類型的 Test Doubles:

這其中最爲常用的是 Fake objects、stub 和 mock objects。下面逐一說說這三種 test double:

2.1 fake object

fake object 最容易理解,它是被測系統 SUT(System Under Test) 依賴的外部協作者的 “替身”,和真實的外部協作者相比,fake object 外部行爲表現與真實組件幾乎是一致的,但更簡單也更易於使用,實現更輕量,僅用於滿足測試需求即可。

fake object 也是 Go testing 中最爲常用的一類 fake object。以 Go 的標準庫爲例,我們在 src/database/sql 下面就看到了 Go 標準庫爲進行 sql 包測試而實現的一個 database driver:

// $GOROOT/src/database/fakedb_test.go

var fdriver driver.Driver = &fakeDriver{}

func init() {
    Register("test", fdriver)
}

我們知道一個真實的 sql 數據庫的代碼量可是數以百萬計的,這裏不可能實現一個生產級的真實 SQL 數據庫,從 fakedb_test.go 源文件的註釋我們也可以看到,這個 fakeDriver 僅僅是用於 testing,它是一個實現了 driver.Driver 接口的、支持少數幾個 DDL(create)、DML(insert) 和 DQL(selet) 的 toy 版的純內存數據庫:

// fakeDriver is a fake database that implements Go's driver.Driver
// interface, just for testing.
//
// It speaks a query language that's semantically similar to but
// syntactically different and simpler than SQL.  The syntax is as
// follows:
//
//  WIPE
//  CREATE|<tablename>|<col>=<type>,<col>=<type>,...
//    where types are: "string", [u]int{8,16,32,64}, "bool"
//  INSERT|<tablename>|col=val,col2=val2,col3=?
//  SELECT|<tablename>|projectcol1,projectcol2|filtercol=?,filtercol2=?
//  SELECT|<tablename>|projectcol1,projectcol2|filtercol=?param1,filtercol2=?param2

與此類似的,Go 標準庫中還有 net/dnsclient_unix_test.go 中的 fakeDNSServer 等。此外,Go 標準庫中一些以 mock 做前綴命名的變量、類型等其實質上是 fake object。

我們再來看第二種 test double: stub。

2.2 stub

stub 顯然也是一個在測試階段專用的、用來替代真實外部協作者與 SUT 進行交互的對象。與 fake object 稍有不同的是,stub 是一個內置了預期值 / 響應值且可以在多個測試間複用的替身 object。

stub 可以理解爲一種 fake object 的特例。

注:fakeDriver 在 sql_test.go 中的不同測試場景中時而是 fake object,時而是 stub(見 sql_test.go 中的 newTestDBConnector 函數)。

Go 標準庫中的 net/http/httptest 就是一個提供創建 stub 的典型的測試輔助包,十分適合對 http.Handler 進行測試,這樣我們無需真正啓動一個 http server。下面就是基於 httptest 的一個測試例子:

// 被測對象 client.go

package main

import (
 "bytes"
 "net/http"
)

// Function that uses the client to make a request and parse the response
func GetResponse(client *http.Client, url string) (string, error) {
 req, err := http.NewRequest("GET", url, nil)
 if err != nil {
  return "", err
 }

 resp, err := client.Do(req)
 if err != nil {
  return "", err
 }
 defer resp.Body.Close()

 buf := new(bytes.Buffer)
 _, err = buf.ReadFrom(resp.Body)
 if err != nil {
  return "", err
 }

 return buf.String(), nil
}

// 測試代碼 client_test.go

package main

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

func TestClient(t *testing.T) {
 // Create a new test server with a handler that returns a specific response
 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  w.WriteHeader(http.StatusOK)
  w.Write([]byte(`{"message": "Hello, world!"}`))
 }))
 defer server.Close()

 // Create a new client that uses the test server
 client := server.Client()

 // Call the function that uses the client
 message, err := GetResponse(client, server.URL)

 // Check that the response is correct
 expected := `{"message": "Hello, world!"}`
 if message != expected {
  t.Errorf("Expected response %q, but got %q", expected, message)
 }

 // Check that no errors were returned
 if err != nil {
  t.Errorf("Unexpected error: %v", err)
 }
}

在這個例子中,我們要測試一個名爲 GetResponse 的函數,該函數通過 client 向 url 發送 Get 請求,並將收到的響應內容讀取出來並返回。爲了測試這個函數,我們需要 “建立” 一個與 GetResponse 進行協作的外部 http server,這裏我們使用的就是 httptest 包。我們通過 httptest.NewServer 建立這個 server,該 server 預置了一個返回特定響應的 HTTP handler。我們通過該 server 得到 client 和對應的 url 參數後,將其傳給被測目標 GetResponse,並將其返回的結果與預期作比較來完成這個測試。注意,我們在測試結束後使用 defer server.Close()來關閉測試服務器,以確保該服務器不會在測試結束後繼續運行。

httptest 還常用來做 http.Handler 的測試,比如下面這個例子:

// handler.go

package main
  
import (
    "bytes"
    "io"
    "net/http"
)

func AddHelloPrefix(w http.ResponseWriter, r *http.Request) {
    b, err := io.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    w.Write(bytes.Join([][]byte{[]byte("hello, "), b}, nil))
    w.WriteHeader(http.StatusOK)
}

// handler_test.go

package main
  
import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestHandler(t *testing.T) {
    r := strings.NewReader("world!")
    req, err := http.NewRequest("GET", "/test", r)
    if err != nil {
        t.Fatal(err)
    }

    rr := httptest.NewRecorder()
    handler := http.HandlerFunc(AddHelloPrefix)
    handler.ServeHTTP(rr, req)

    if status := rr.Code; status != http.StatusOK {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
    }

    expected := "hello, world!"
    if rr.Body.String() != expected {
        t.Errorf("handler returned unexpected body: got %v want %v",
            rr.Body.String(), expected)
    }
}

在這個例子中,我們創建一個新的 http.Request 對象,用於向 / test 路徑發出 GET 請求。然後我們創建一個新的 httptest.ResponseRecorder 對象來捕獲服務器的響應。 我們定義一個簡單的 HTTP Handler(被測函數): AddHelloPrefix,該 Handler 會在請求的內容之前加上 "hello," 並返回 200 OK 狀態代碼作爲響應體。之後,我們在 handler 上調用 ServeHTTP 方法,傳入 httptest.ResponseRecorder 和 http.Request 對象,這會將請求 “發送” 到處理程序並捕獲響應。最後,我們使用標準的 Go 測試包來檢查響應是否具有預期的狀態碼和正文。

在這個例子中,我們利用 net/http/httptest 創建了一個測試服務器 “替身”,並向其“發送” 間接預置信息的請求以測試 Go 中的 HTTP handler。這個過程中其實並沒有任何網絡通信,也沒有 http 協議打包和解包的過程,我們也不關心 http 通信,那是 Go net/http 包的事情,我們只 care 我們的 Handler 是否能按邏輯運行。

fake object 與 stub 的優缺點基本一樣。多數情況下,大家也無需將這二者劃分的很清晰

2.3 mock object

和 fake/stub 一樣,mock object 也是一個測試替身。通過上面的例子我們看到 fake 建立困難 (比如創建一個近 2 千行代碼的 fakeDriver),但使用簡單。而 mock object 則是一種建立簡單,使用簡單程度因被測目標與外部協作者交互複雜程度而異的 test double,我們看一下下面這個例子:

// db.go 被測目標

package main

// Define the `Database` interface
type Database interface {
    Save(data string) error
    Get(id int) (string, error)
}

// Example functions that use the `Database` interface
func saveData(db Database, data string) error {
    return db.Save(data)
}

func getData(db Database, id int) (string, error) {
    return db.Get(id)
}

// 測試代碼

package main

import (
 "testing"

 "github.com/stretchr/testify/assert"
 "github.com/stretchr/testify/mock"
)

// Define a mock struct that implements the `Database` interface
type MockDatabase struct {
 mock.Mock
}

func (m *MockDatabase) Save(data string) error {
 args := m.Called(data)
 return args.Error(0)
}

func (m *MockDatabase) Get(id int) (string, error) {
 args := m.Called(id)
 return args.String(0), args.Error(1)
}

func TestSaveData(t *testing.T) {
 // Create a new mock database
 db := new(MockDatabase)

 // Expect the `Save` method to be called with "test data"
 db.On("Save", "test data").Return(nil)

 // Call the code that uses the database
 err := saveData(db, "test data")

 // Assert that the `Save` method was called with the correct argument
 db.AssertCalled(t, "Save", "test data")

 // Assert that no errors were returned
 assert.NoError(t, err)
}

func TestGetData(t *testing.T) {
 // Create a new mock database
 db := new(MockDatabase)

 // Expect the `Get` method to be called with ID 123 and return "test data"
 db.On("Get", 123).Return("test data", nil)

 // Call the code that uses the database
 data, err := getData(db, 123)

 // Assert that the `Get` method was called with the correct argument
 db.AssertCalled(t, "Get", 123)

 // Assert that the correct data was returned
 assert.Equal(t, "test data", data)

 // Assert that no errors were returned
 assert.NoError(t, err)
}

在這個例子中,被測目標是兩個接受 Database 接口類型參數的函數:saveData 和 getData。顯然在單元測試階段,我們不能真正爲這兩個函數傳入真實的 Database 實例去測試。

這裏,我們沒有使用 fake object,而是定義了一個 mock object:MockDatabase,該類型實現了 Database 接口。然後我們定義了兩個測試函數,TestSaveData 和 TestGetData,它們分別使用 MockDatabase 實例來測試 saveData 和 getData 函數。

在每個測試函數中,我們對 MockDatabase 實例進行設置,包括期待特定參數的方法調用,然後調用使用該數據庫的代碼 (即被測目標函數 saveData 和 getData)。然後我們使用 github.com/stretchr/testify 中的 assert 包,對代碼的預期行爲進行斷言。

注:除了上述測試中使用的 AssertCalled 方法外,MockDatabase 結構還提供了其他方法來斷言方法被調用的次數、方法被調用的順序等。請查看 github.com/stretchr/testify/mock 包的文檔,瞭解更多信息。

3. Test Double 有多種,選哪個呢?

從 mock object 的例子來看,測試代碼的核心就是 mock object 的構建與 mock object 的方法的參數和返回結果的設置,相較於 fake object 的簡單直接,mock object 在使用上較爲難於理解。而且對 Go 語言來說,mock object 要與接口類型聯合使用,如果被測目標的參數是非接口類型,mock object 便 “無從下嘴” 了。此外,mock object 使用難易程度與被測目標與外部協作者的交互複雜度相關。像上面這個例子,建立 mock object 就比較簡單。但對於一些複雜的函數,當存在多個外部協作者且與每個協作者都有多次交互的情況下,建立和設置 mock object 就將變得困難並更加難於理解。

mock object 僅是滿足了被測目標對依賴的外部協作者的調用需求,比如設置不同參數傳入下的不同返回值,但 mock object 並未真實處理被測目標傳入的參數,這會降低測試的可信度以及開發人員對代碼正確性的信心。

此外,如果被測函數的輸入輸出未發生變化,但內部邏輯發生了變化,比如調用的外部協作者的方法參數、調用次數等,使用 mock object 的測試代碼也需要一併更新維護。

而通過上面的 fakeDriver、fakeDNSSever 以及 httptest 應用的例子,我們看到:作爲 test double,fake object/stub 有如下優點:

不過 fake object 也有自己的不足之處,比如:

綜上,測試的主要意義是保證 SUT 代碼的正確性,讓開發人員對自己編寫的代碼更有信心,從這個角度來看,我們在單測時應首選爲外部協作者提供 fake object 以滿足測試需要

4. fake object 的實現和獲取方法

隨着技術的進步,fake object 的實現和獲取日益容易。

我們可以藉助類似 ChatGPT/copilot 的工具快速構建出一個 fake object,即便是幾百行代碼的 fake object 的實現也很容易。

如果要更高的可信度和更高的功能覆蓋水平,我們還可以藉助 docker 來構建 “真實版 / 無閹割版” 的 fake object。

藉助 github 上開源的 testcontainers-go[4] 可以更爲簡便的構建出一個 fake object,並且 testcontainer 提供了常見的外部協作者的封裝實現,比如:MySQL、Redis、Postgres 等。

以測試 redis client 爲例,我們使用 testcontainer 建立如下測試代碼:

// redis_test.go

package main

import (
 "context"
 "fmt"
 "testing"

 "github.com/go-redis/redis/v8"
 "github.com/testcontainers/testcontainers-go"
 "github.com/testcontainers/testcontainers-go/wait"
)

func TestRedisClient(t *testing.T) {
 // Create a Redis container with a random port and wait for it to start
 req := testcontainers.ContainerRequest{
  Image:        "redis:latest",
  ExposedPorts: []string{"6379/tcp"},
  WaitingFor:   wait.ForLog("Ready to accept connections"),
 }
 ctx := context.Background()
 redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
  ContainerRequest: req,
  Started:          true,
 })
 if err != nil {
  t.Fatalf("Failed to start Redis container: %v", err)
 }
 defer redisC.Terminate(ctx)

 // Get the Redis container's host and port
 redisHost, err := redisC.Host(ctx)
 if err != nil {
  t.Fatalf("Failed to get Redis container's host: %v", err)
 }
 redisPort, err := redisC.MappedPort(ctx, "6379/tcp")
 if err != nil {
  t.Fatalf("Failed to get Redis container's port: %v", err)
 }

 // Create a Redis client and perform some operations
 client := redis.NewClient(&redis.Options{
  Addr: fmt.Sprintf("%s:%s", redisHost, redisPort.Port()),
 })
 defer client.Close()

 err = client.Set(ctx, "key", "value", 0).Err()
 if err != nil {
  t.Fatalf("Failed to set key: %v", err)
 }

 val, err := client.Get(ctx, "key").Result()
 if err != nil {
  t.Fatalf("Failed to get key: %v", err)
 }

 if val != "value" {
  t.Errorf("Expected value %q, but got %q", "value", val)
 }
}

運行該測試將看到類似如下結果:

$go test
2023/04/15 16:18:20 github.com/testcontainers/testcontainers-go - Connected to docker: 
  Server Version: 20.10.8
  API Version: 1.41
  Operating System: Ubuntu 20.04.3 LTS
  Total Memory: 10632 MB
2023/04/15 16:18:21 Failed to get image auth for docker.io. Setting empty credentials for the image: docker.io/testcontainers/ryuk:0.3.4. Error is:credentials not found in native keychain

2023/04/15 16:19:06 Starting container id: 0d8341b2270e image: docker.io/testcontainers/ryuk:0.3.4
2023/04/15 16:19:10 Waiting for container id 0d8341b2270e image: docker.io/testcontainers/ryuk:0.3.4
2023/04/15 16:19:10 Container is ready id: 0d8341b2270e image: docker.io/testcontainers/ryuk:0.3.4
2023/04/15 16:19:28 Starting container id: 999cf02b5a82 image: redis:latest
2023/04/15 16:19:30 Waiting for container id 999cf02b5a82 image: redis:latest
2023/04/15 16:19:30 Container is ready id: 999cf02b5a82 image: redis:latest
PASS
ok   demo 73.262s

我們看到建立這種真實版的 “fake object” 的一大不足就是依賴網絡下載 container image 且耗時過長,在單元測試階段使用還是要謹慎一些。testcontainer 更多也會被用在集成測試或冒煙測試上。

一些開源項目,比如 etcd,也提供了用於測試的自身簡化版的實現 (embed)[5]。這一點也值得我們效仿,在團隊內部每個服務的開發者如果都能提供一個服務的簡化版實現,那麼對於該服務調用者來說,它的單測就會變得十分容易。

5. 參考資料


Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily

我的聯繫方式:

商務合作方式:撰稿、出書、培訓、在線課程、合夥創業、諮詢、廣告合作。

參考資料

[1]  《Go 語言精進之路》vol2: https://book.douban.com/subject/35720729/

[2]  《xUnit Test Patterns : Refactoring Test Code》: https://book.douban.com/subject/1859393/

[3]  xUnit Patterns: http://xunitpatterns.com/Test%20Double%20Patterns.html

[4]  testcontainers-go: https://golang.testcontainers.org/

[5]  用於測試的自身簡化版的實現 (embed): https://github.com/etcd-io/etcd/blob/main/tests/integration/embed

[6]  “Gopher 部落” 知識星球: https://wx.zsxq.com/dweb2/index/group/51284458844544

[7]  鏈接地址: https://m.do.co/c/bff6eed92687

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