單測時盡量用Fake Object,你學(xué)會了嗎?
1. 單元測試的難點(diǎn):外部協(xié)作者(external collaborators)的存在
單元測試是軟件開發(fā)的一個重要部分,它有助于在開發(fā)周期的早期發(fā)現(xiàn)錯誤,幫助開發(fā)人員增加對生產(chǎn)代碼正常工作的信心,同時也有助于改善代碼設(shè)計。**Go語言從誕生那天起就內(nèi)置Testing框架(以及測試覆蓋率計算工具)**,基于該框架,Gopher們可以非常方便地為自己設(shè)計實現(xiàn)的package編寫測試代碼。
注:《Go語言精進(jìn)之路》vol2[1]中的第40條到第44條有關(guān)于Go包內(nèi)、包外測試區(qū)別、測試代碼組織、表驅(qū)動測試、管理外部測試數(shù)據(jù)等內(nèi)容的系統(tǒng)地講解,感興趣的童鞋可以讀讀。
不過即便如此,在實際開發(fā)工作中,大家發(fā)現(xiàn)單元測試的覆蓋率依舊很低,究其原因,排除那些對測試代碼不作要求的組織,剩下的無非就是代碼設(shè)計不佳,使得代碼不易測;或是代碼有外部協(xié)作者(比如數(shù)據(jù)庫、redis、其他服務(wù)等)。代碼不易測可以通過重構(gòu)來改善,但如果代碼有外部協(xié)作者,我們該如何對代碼進(jìn)行測試呢,這也是各種編程語言實施單元測試的一大共同難點(diǎn)。
為此,《xUnit Test Patterns : Refactoring Test Code》[2]一書中提供了**Test Double(測試替身)**的概念專為解決此難題。那么什么是Test Double呢?我們接下來就來簡單介紹一下Test Double的概念以及常見的種類。
2. 什么是Test Double?
測試替身是在測試階段用來替代被測系統(tǒng)依賴的真實組件的對象或程序(如下圖),以方便測試,這些真實組件或程序即是外部協(xié)作者(external collaborators)。這些外部協(xié)作者在測試環(huán)境下通常很難獲取或與之交互。測試替身可以使開發(fā)人員或QA專業(yè)人員專注于新的代碼而不是代碼與環(huán)境集成。
測試替身是通用術(shù)語,指的是不同類型的替換對象或程序。目前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最容易理解,它是被測系統(tǒng)SUT(System Under Test)依賴的外部協(xié)作者的“替身”,和真實的外部協(xié)作者相比,fake object外部行為表現(xiàn)與真實組件幾乎是一致的,但更簡單也更易于使用,實現(xiàn)更輕量,僅用于滿足測試需求即可。
fake object也是Go testing中最為常用的一類fake object。以Go的標(biāo)準(zhǔn)庫為例,我們在src/database/sql下面就看到了Go標(biāo)準(zhǔn)庫為進(jìn)行sql包測試而實現(xiàn)的一個database driver:
// $GOROOT/src/database/fakedb_test.go
var fdriver driver.Driver = &fakeDriver{}
func init() {
Register("test", fdriver)
}
我們知道一個真實的sql數(shù)據(jù)庫的代碼量可是數(shù)以百萬計的,這里不可能實現(xiàn)一個生產(chǎn)級的真實SQL數(shù)據(jù)庫,從fakedb_test.go源文件的注釋我們也可以看到,這個fakeDriver僅僅是用于testing,它是一個實現(xiàn)了driver.Driver接口的、支持少數(shù)幾個DDL(create)、DML(insert)和DQL(selet)的toy版的純內(nèi)存數(shù)據(jù)庫:
// 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標(biāo)準(zhǔn)庫中還有net/dnsclient_unix_test.go中的fakeDNSServer等。此外,Go標(biāo)準(zhǔn)庫中一些以mock做前綴命名的變量、類型等其實質(zhì)上是fake object。
我們再來看第二種test double: stub。
2.2 stub
stub顯然也是一個在測試階段專用的、用來替代真實外部協(xié)作者與SUT進(jìn)行交互的對象。與fake object稍有不同的是,stub是一個內(nèi)置了預(yù)期值/響應(yīng)值且可以在多個測試間復(fù)用的替身object。
stub可以理解為一種fake object的特例。
注:fakeDriver在sql_test.go中的不同測試場景中時而是fake object,時而是stub(見sql_test.go中的newTestDBConnector函數(shù))。
Go標(biāo)準(zhǔn)庫中的net/http/httptest就是一個提供創(chuàng)建stub的典型的測試輔助包,十分適合對http.Handler進(jìn)行測試,這樣我們無需真正啟動一個http server。下面就是基于httptest的一個測試?yán)樱?/p>
// 被測對象 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的函數(shù),該函數(shù)通過client向url發(fā)送Get請求,并將收到的響應(yīng)內(nèi)容讀取出來并返回。為了測試這個函數(shù),我們需要“建立”一個與GetResponse進(jìn)行協(xié)作的外部http server,這里我們使用的就是httptest包。我們通過httptest.NewServer建立這個server,該server預(yù)置了一個返回特定響應(yīng)的HTTP handler。我們通過該server得到client和對應(yīng)的url參數(shù)后,將其傳給被測目標(biāo)GetResponse,并將其返回的結(jié)果與預(yù)期作比較來完成這個測試。注意,我們在測試結(jié)束后使用defer server.Close()來關(guān)閉測試服務(wù)器,以確保該服務(wù)器不會在測試結(jié)束后繼續(xù)運(yùn)行。
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)
}
}
在這個例子中,我們創(chuàng)建一個新的http.Request對象,用于向/test路徑發(fā)出GET請求。然后我們創(chuàng)建一個新的httptest.ResponseRecorder對象來捕獲服務(wù)器的響應(yīng)。 我們定義一個簡單的HTTP Handler(被測函數(shù)): AddHelloPrefix,該Handler會在請求的內(nèi)容之前加上"hello, "并返回200 OK狀態(tài)代碼作為響應(yīng)體。之后,我們在handler上調(diào)用ServeHTTP方法,傳入httptest.ResponseRecorder和http.Request對象,這會將請求“發(fā)送”到處理程序并捕獲響應(yīng)。最后,我們使用標(biāo)準(zhǔn)的Go測試包來檢查響應(yīng)是否具有預(yù)期的狀態(tài)碼和正文。
在這個例子中,我們利用net/http/httptest創(chuàng)建了一個測試服務(wù)器“替身”,并向其“發(fā)送”間接預(yù)置信息的請求以測試Go中的HTTP handler。這個過程中其實并沒有任何網(wǎng)絡(luò)通信,也沒有http協(xié)議打包和解包的過程,我們也不關(guān)心http通信,那是Go net/http包的事情,我們只care我們的Handler是否能按邏輯運(yùn)行。
fake object與stub的優(yōu)缺點(diǎn)基本一樣。多數(shù)情況下,大家也無需將這二者劃分的很清晰。
2.3 mock object
和fake/stub一樣,mock object也是一個測試替身。通過上面的例子我們看到fake建立困難(比如創(chuàng)建一個近2千行代碼的fakeDriver),但使用簡單。而mock object則是一種建立簡單,使用簡單程度因被測目標(biāo)與外部協(xié)作者交互復(fù)雜程度而異的test double,我們看一下下面這個例子:
// db.go 被測目標(biāo)
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)
}
在這個例子中,被測目標(biāo)是兩個接受Database接口類型參數(shù)的函數(shù):saveData和getData。顯然在單元測試階段,我們不能真正為這兩個函數(shù)傳入真實的Database實例去測試。
這里,我們沒有使用fake object,而是定義了一個mock object:MockDatabase,該類型實現(xiàn)了Database接口。然后我們定義了兩個測試函數(shù),TestSaveData和TestGetData,它們分別使用MockDatabase實例來測試saveData和getData函數(shù)。
在每個測試函數(shù)中,我們對MockDatabase實例進(jìn)行設(shè)置,包括期待特定參數(shù)的方法調(diào)用,然后調(diào)用使用該數(shù)據(jù)庫的代碼(即被測目標(biāo)函數(shù)saveData和getData)。然后我們使用github.com/stretchr/testify中的assert包,對代碼的預(yù)期行為進(jìn)行斷言。
注:除了上述測試中使用的AssertCalled方法外,MockDatabase結(jié)構(gòu)還提供了其他方法來斷言方法被調(diào)用的次數(shù)、方法被調(diào)用的順序等。請查看github.com/stretchr/testify/mock包的文檔,了解更多信息。
3. Test Double有多種,選哪個呢?
從mock object的例子來看,測試代碼的核心就是mock object的構(gòu)建與mock object的方法的參數(shù)和返回結(jié)果的設(shè)置,相較于fake object的簡單直接,mock object在使用上較為難于理解。而且對Go語言來說,mock object要與接口類型聯(lián)合使用,如果被測目標(biāo)的參數(shù)是非接口類型,mock object便“無從下嘴”了。此外,mock object使用難易程度與被測目標(biāo)與外部協(xié)作者的交互復(fù)雜度相關(guān)。像上面這個例子,建立mock object就比較簡單。但對于一些復(fù)雜的函數(shù),當(dāng)存在多個外部協(xié)作者且與每個協(xié)作者都有多次交互的情況下,建立和設(shè)置mock object就將變得困難并更加難于理解。
mock object僅是滿足了被測目標(biāo)對依賴的外部協(xié)作者的調(diào)用需求,比如設(shè)置不同參數(shù)傳入下的不同返回值,但mock object并未真實處理被測目標(biāo)傳入的參數(shù),這會降低測試的可信度以及開發(fā)人員對代碼正確性的信心。
此外,如果被測函數(shù)的輸入輸出未發(fā)生變化,但內(nèi)部邏輯發(fā)生了變化,比如調(diào)用的外部協(xié)作者的方法參數(shù)、調(diào)用次數(shù)等,使用mock object的測試代碼也需要一并更新維護(hù)。
而通過上面的fakeDriver、fakeDNSSever以及httptest應(yīng)用的例子,我們看到:作為test double,fake object/stub有如下優(yōu)點(diǎn):
- 我們與fake object的交互方式與與真實外部協(xié)作者交互的方式相同,這讓其顯得更簡單,更容易使用,也降低了測試的復(fù)雜性;
- fake objet的行為更像真正的協(xié)作者,可以給開發(fā)人員更多的信心;
- 當(dāng)真實協(xié)作者更新時,我們不需要更新使用fake object時設(shè)置的expection和結(jié)果驗證條件,因此,使用fake object時,重構(gòu)代碼往往比使用其他test double更容易。
不過fake object也有自己的不足之處,比如:
- fake object的創(chuàng)建和維護(hù)可能很費(fèi)時,就像上面的fakeDriver,源碼有近2k行;
- fake object可能無法提供與真實組件相同的功能覆蓋水平,這與fake object的提供方式有關(guān)。
- fake object的實現(xiàn)需要維護(hù),每當(dāng)真正的協(xié)作者更新時,都必須更新fake object。
綜上,測試的主要意義是保證SUT代碼的正確性,讓開發(fā)人員對自己編寫的代碼更有信心,從這個角度來看,我們在單測時應(yīng)首選為外部協(xié)作者提供fake object以滿足測試需要。
4. fake object的實現(xiàn)和獲取方法
隨著技術(shù)的進(jìn)步,fake object的實現(xiàn)和獲取日益容易。
我們可以借助類似ChatGPT/copilot的工具快速構(gòu)建出一個fake object,即便是幾百行代碼的fake object的實現(xiàn)也很容易。
如果要更高的可信度和更高的功能覆蓋水平,我們還可以借助docker來構(gòu)建“真實版/無閹割版”的fake object。
借助github上開源的testcontainers-go[4]可以更為簡便的構(gòu)建出一個fake object,并且testcontainer提供了常見的外部協(xié)作者的封裝實現(xiàn),比如: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)
}
}
運(yùn)行該測試將看到類似如下結(jié)果:
$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”的一大不足就是依賴網(wǎng)絡(luò)下載container image且耗時過長,在單元測試階段使用還是要謹(jǐn)慎一些。testcontainer更多也會被用在集成測試或冒煙測試上。
一些開源項目,比如etcd,也提供了用于測試的自身簡化版的實現(xiàn)(embed)[5]。這一點(diǎn)也值得我們效仿,在團(tuán)隊內(nèi)部每個服務(wù)的開發(fā)者如果都能提供一個服務(wù)的簡化版實現(xiàn),那么對于該服務(wù)調(diào)用者來說,它的單測就會變得十分容易。
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
本文轉(zhuǎn)載自微信公眾號「TonyBai 」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系
公眾號。