單測時盡量用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:
- Test stubs
- Mock objects
- Test spies
- Fake objects
- Dummy objects
這其中最為常用的是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的交互方式與與真實外部協作者交互的方式相同,這讓其顯得更簡單,更容易使用,也降低了測試的復雜性;
- fake objet的行為更像真正的協作者,可以給開發人員更多的信心;
- 當真實協作者更新時,我們不需要更新使用fake object時設置的expection和結果驗證條件,因此,使用fake object時,重構代碼往往比使用其他test double更容易。
不過fake object也有自己的不足之處,比如:
- fake object的創建和維護可能很費時,就像上面的fakeDriver,源碼有近2k行;
- fake object可能無法提供與真實組件相同的功能覆蓋水平,這與fake object的提供方式有關。
- fake object的實現需要維護,每當真正的協作者更新時,都必須更新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. 參考資料
- 《xUnit Test Patterns : Refactoring Test Code》- https://book.douban.com/subject/1859393/
- Test Double Patterns - http://xunitpatterns.com/Test%20Double%20Patterns.html
- The Unit in Unit Testing - https://www.infoq.com/articles/unit-testing-approach/
- Test Doubles — Fakes, Mocks and Stubs - https://blog.pragmatists.com/test-doubles-fakes-mocks-and-stubs-1a7491dfa3da
本文轉載自微信公眾號「TonyBai 」,可以通過以下二維碼關注。轉載本文請聯系
公眾號。