自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

單測時盡量用Fake Object,你學(xué)會了嗎?

開發(fā) 前端
一些開源項目,比如etcd,也提供了用于測試的自身簡化版的實現(xiàn)(embed)[5]。這一點(diǎn)也值得我們效仿,在團(tuán)隊內(nèi)部每個服務(wù)的開發(fā)者如果都能提供一個服務(wù)的簡化版實現(xiàn),那么對于該服務(wù)調(diào)用者來說,它的單測就會變得十分容易。

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)系

公眾號。

責(zé)任編輯:武曉燕 來源: TonyBai
相關(guān)推薦

2024-01-02 12:05:26

Java并發(fā)編程

2023-08-01 12:51:18

WebGPT機(jī)器學(xué)習(xí)模型

2023-01-10 08:43:15

定義DDD架構(gòu)

2024-02-04 00:00:00

Effect數(shù)據(jù)組件

2023-07-26 13:11:21

ChatGPT平臺工具

2024-01-19 08:25:38

死鎖Java通信

2022-06-16 07:50:35

數(shù)據(jù)結(jié)構(gòu)鏈表

2022-12-06 07:53:33

MySQL索引B+樹

2023-07-30 22:29:51

BDDMockitoAssert測試

2023-10-06 14:49:21

SentinelHystrixtimeout

2024-02-02 11:03:11

React數(shù)據(jù)Ref

2024-03-06 08:28:16

設(shè)計模式Java

2022-07-13 08:16:49

RocketMQRPC日志

2023-01-31 08:02:18

2023-05-05 06:54:07

MySQL數(shù)據(jù)查詢

2023-08-26 21:34:28

Spring源碼自定義

2023-06-26 13:08:52

GraphQL服務(wù)數(shù)據(jù)

2023-03-26 22:31:29

2024-05-06 00:00:00

InnoDBView隔離

2024-08-06 09:47:57

點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號