GoLang快速上手單元測試(思想、框架、實踐)

arxxin發表於2020-12-29

1、單元測試是什麼

單元測試可以檢查我們的程式碼能否按照預期進行,程式碼邏輯是否有問題,以此可以提升程式碼質量。
簡單來說單元測試就是針對某一個函式方法進行測試,我們要先測試正確的傳值與獲取正確的預期結果,然後再新增更多測試用例,得出多種預期結果。儘可能達到該方法邏輯沒有問題,或者問題都能被我們預知到。這就是單元測試的好處。

2、Golang是怎麼寫單元測試的?

很簡單!Golang本身對自動化測試非常友好,並且有許多優秀的測試框架支援,非常好上手。
文中將以新手的角度,快速上手一一實踐給大家感受Go單元測試的魅力。

3、寫一個最簡單的Test吧!

先來了解一下Go官方的testing

要編寫一個測試檔案,需要建立一個名稱以 _test.go 結尾的檔案,該檔案包含 TestXxx 函式,如上所述。 將該檔案放在與被測試檔案相同的包中。該檔案將被排除在正常的程式包之外,但在執行 go test 命令時將被包含。
測試函式的簽名必須接收一個指向testing.T型別的指標,並且不能返回任何值。函式名最好是Test+要測試的方法函式名。

記得一定要先看一下Testing的官方文件!

目錄結構
test_study
—–samples.go
—–samples_test.go

被測試程式碼

package test_study
import "fmt"

func Hello() string {
    return "Hello, world"
}

func main() {
    fmt.Println(Hello())
}

這個程式碼將輸出一句“Hello, world”

測試

package test_study
import "testing"

func TestHello(t *testing.T) {
    got := Hello()
    want := "Hello, world"

    if got != want {
        t.Errorf("got %q want %q", got, want)
    }
}

測試結果

D:\goproj\test_study>go test -v
=== RUN   TestHello
--- PASS: TestHello (0.00s)
PASS
ok      _/D_/goproj/test_study  0.030s

說明我們的測試通過了,返回的值與我們預期的值是相同的。
現在嘗試把預期結果修改一下

want := "xdcute.com"

測試結果

D:\goproj\test_study>go test -v
=== RUN   TestHello
    samples_test.go:9: got "Hello, world" want "xdcute.com"
--- FAIL: TestHello (0.00s)
FAIL
exit status 1
FAIL    _/D_/goproj/test_study  0.159s

此時提示測試不通過,得到的值與預期的值不相同。
這就是一個最簡單的測試寫法,我們可以進行正確或錯誤的測試。

這裡介紹幾個常用的引數:

  1. -bench regexp 執行相應的 benchmarks,例如 -bench= (基準測試)
  2. -cover 開啟測試覆蓋率
  3. -run regexp 只執行 regexp 匹配的函式,例如 -run=Array 那麼就執行包含有 Array 開頭的函式;
  4. -v 顯示測試的詳細命令

再新增一個被測試方法

func Add(a,b int) int{
    return a+b
}

測試程式碼

func TestAdd(t *testing.T) {
    sum := Add(5,5)
    if sum == 10 {
        t.Log("the result is ok")
    } else {
        t.Fatal("the result is wrong")
    }
}

使用-run來測試,發現只執行了TestAdd測試方法。

D:\goproj\test_study>go test -v -run TestAdd
=== RUN   TestAdd
    samples_test.go:16: the result is ok
--- PASS: TestAdd (0.00s)
PASS
ok      _/D_/goproj/test_study  0.176s

4.看看單元測試覆蓋率

寫好測試後,可以利用Go自帶的工具 test coverage 檢視一下單元測試覆蓋率
測試覆蓋率是一個術語,用於統計通過執行程式包的測試多少程式碼得到執行。 如果執行測試套件導致80%的語句得到了執行,則測試覆蓋率為80%。

來嘗試一下吧~

D:\goproj\test_study>go test -cover
PASS
coverage: 66.7% of statements
ok      _/D_/goproj/test_study  0.163s

可以看到剛才我們寫的測試,覆蓋率為66.7%。
再回過頭看一下我們的被測試程式碼,還有一個main()在測試中沒有執行到,只執行了Hello()和Add(),所以覆蓋率只有66.7%。試著在測試程式碼中直接加上“main()”的呼叫。

現在的測試覆蓋率就是100%了,並且在PASS之前輸出了一句“Hello, world”

D:\goproj\test_study>go test -cover
Hello, world
PASS
coverage: 100.0% of statements
ok      _/D_/goproj/test_study  0.162s

5.學習GoConvey測試框架

在前面,我們判斷預期結果都是用if…else之類的判斷語句,如果面對更龐大的測試,有更多的測試用例,可能需要思考更多邏輯判斷,加大程式碼長度與複雜度,不便於編寫與管理。所以我們需要用到更好的測試框架來增強測試編寫。
GoConvey是一款針對Golang的測試框架,它可以更好的管理和執行測試用例,而且又很豐富的斷言函式,能夠寫出更完善的測試用例,並且還有web介面,是極其常用的測試框架。

新增一個匹配url的方法

func CheckUrl(url string) bool {
    var urlList = [2]string{"learnku.com", "xdcute.com"}
    for v := range urlList {
        if urlList[v] == url {
            return true
        }
    }
    return false
}

測試程式碼
記得先import "github.com/smartystreets/goconvey/convey"
或者在命令列輸入go get github.com/smartystreets/goconvey

func TestCheckUrl(t *testing.T) {
    convey.Convey("TestCheckTeachUrl", t, func() {
        ok:=CheckUrl("learnku.com")
        convey.So(ok,convey.ShouldBeTrue)
    })
}

convey.Convey定義了測試用例名稱、t指標、測試程式碼。
convey.So用來判斷預期結果。
convey提供了大量的斷言函式,比如剛才使用的convey.ShouldBeTrue,就是判斷ok的值應該為true。
更多方法請前往GoConvey官方文件檢視。

測試結果

D:\goproj\test_study>go test -v -run TestCheckTeachUrl
=== RUN   TestCheckTeachUrl
  TestCheckTeachUrl .

1 total assertion

--- PASS: TestCheckTeachUrl (0.00s)
PASS
ok      _/D_/goproj/test_study  0.187s

因為傳入的是正確的url,可以匹配到,所以測試通過。

6.利用GoConvey定義多個測試用例

我們可以定義多個測試用例。
以下分別是輸入正確、錯誤、空值的Url。

func TestCheckUrl(t *testing.T) {
    convey.Convey("TestCheckTeachUrl true", t, func() {
        ok:=CheckUrl("learnku.com")
        convey.So(ok,convey.ShouldBeTrue)
    })

    convey.Convey("TestCheckTeachUrl false", t, func() {
        ok:=CheckUrl("xxxxxx.com")
        convey.So(ok,convey.ShouldBeFalse)
    })

    convey.Convey("TestCheckTeachUrl null", t, func() {
        ok:=CheckUrl("")
        convey.So(ok,convey.ShouldBeFalse)
    })
}

測試結果
三個測試用例都符合我們的預期結果,測試通過。
也可以嘗試修改一下錯誤的預期結果,測試用例就會失敗。

D:\goproj\test_study>go test -v -run TestCheckUrl
=== RUN   TestCheckUrl
  TestCheckTeachUrl true .
1 total assertion
  TestCheckTeachUrl false .
2 total assertions
  TestCheckTeachUrl null .
3 total assertions
--- PASS: TestCheckUrl (0.00s)
PASS
ok      _/D_/goproj/test_study  0.197s

以上三個測試用例都是分開執行的,convey是可以巢狀執行的,我們可以更好的將測試用例組織起來。
外層再套一個convey,需要傳t指標,裡面的convey都不需要t指標。

func TestCheckUrl(t *testing.T) {
    convey.Convey("TestCheckTeachUrl", t, func() {
        convey.Convey("TestCheckTeachUrl true",  func() {
            ok := CheckUrl("learnku.com")
            convey.So(ok, convey.ShouldBeTrue)
        })
        convey.Convey("TestCheckTeachUrl false", func() {
            ok := CheckUrl("xxxxxx.com")
            convey.So(ok, convey.ShouldBeFalse)
        })
        convey.Convey("TestCheckTeachUrl null",func() {
            ok := CheckUrl("")
            convey.So(ok, convey.ShouldBeFalse)
        })
    })
}

看看測試結果,變得非常簡短許多了,我比較喜歡這種方式。

D:\goproj\test_study>go test -v -run TestCheckUrl
=== RUN   TestCheckUrl
  TestCheckTeachUrl 
    TestCheckTeachUrl true .
    TestCheckTeachUrl false .
    TestCheckTeachUrl null .
3 total assertions
--- PASS: TestCheckUrl (0.00s)
PASS
ok      _/D_/goproj/test_study  0.191s

經過以上體驗,使用GoConvey確實可以更加快捷的編寫和管理測試用例,很棒吧。

7.學習Testify測試框架

Testify也是一個斷言庫,它的功能相對於GoConvey而言比較簡單,主要是在提供斷言功能之外,提供了mock的功能。

在使用前請記得import "github.com/stretchr/testify"
或者在命令列輸入go get -t github.com/stretchr/testify

用Testify下assert來寫測試程式碼

func TestCheckUrl2(t *testing.T) {
    ok := CheckUrl("learnku.com")
    assert.True(t, ok)
}

避免文章太長,測試結果就不貼出來了,這個測試用例肯定是PASS的。

8.結合表格驅動測試

結合表格測試可以寫多個測試用例。testing本身也可以寫表格測試,這裡使用Testify演示。

測試程式碼

func TestCheckUrl3(t *testing.T) {
    assert := assert.New(t)
    var tests = []struct {
        input    string
        expected bool
    }{
        {"xdcute.com", true},
        {"xxx.com", false},
    }
    for _, test := range tests {
        fmt.Println(test.input)
        assert.Equal(CheckUrl(test.input), test.expected)
    }
}

這就是關於Testify的快速上手,關於它的mock功能,將在後面引入mock概念後再介紹。

9.使用Gomock框架-模擬介面

特點:
1.基於介面
2.能夠與Golang內建的testing包良好整合

我覺得一開始不太好理解它的用法,先來看看這段程式碼。

目錄結構:
test_study
├── db
│ ├── db.go

package main

import (
    "fmt"
    "log"
)
//定義了一個訂單介面,有一個獲取名稱的方法
type OrderDBI interface {
    GetName(orderid int) (string)
}
//定義結構體
type OrderInfo struct {
    orderid int
}
//實現介面的方法
func (order OrderInfo) GetName(orderid int) string {
    log.Println("原本應該連線資料庫去取名稱")
    return "xdcute"
}

func main() {
    //建立介面例項
    var orderDBI OrderDBI
    orderDBI = new(OrderInfo)
    //呼叫方法,返回名稱
    ret := orderDBI.GetName(1)
    fmt.Println("取到的使用者名稱:",ret)
}

執行這段程式碼可以得到“取到的使用者名稱:xdcute”。
假設這個GetName是需要連線資料庫去取使用者名稱,那我們想針對GetName寫測試的話,就要真實連線一個資料庫才行,意味著在任何一臺電腦上想進行程式碼測試都必須要依賴資料庫。

mock就是在測試過程中,對於某些不容易構造或者不容易獲取的物件,用一個虛擬的物件來建立以便測試。
意味著我們可以利用gomock來模擬一個假的資料庫物件,提前定義好返回內容。

gomock 是官方提供的 mock 框架,同時還提供了 mockgen 工具用來輔助生成測試程式碼。

通過以下命令安裝:

go get -u github.com/golang/mock/gomock
go get -u github.com/golang/mock/mockgen

安裝好後,我們使用mockgen來mock前面的db.go

 mockgen -source=./db/db.go -destination=./db/db_mock.go -package=main

-source 需要mock的原始檔
-destination 生成的mock檔案存放目錄
-package 所屬包

(務必開啟Modules管理,不然使用mockgen可能會出現這樣的提示Loading input failed: Source directory is outside GOPATH

目錄結構:
test_study
├── db
│ ├── db.go
│ ├── db_mock.go

命令執行成功後會在目錄下生成對應mock檔案,點進去查閱一下,程式碼較長就不貼出來了。
然後會發現裡面確實已經自動生成了介面與方法,有一個EXPECT()重點留意一下。

接下來新建一個db_test.go

func TestGetName(t *testing.T) {
    //新建一個mockController
    ctrl := gomock.NewController(t)
    // 斷言 DB.GetName() 方法是否被呼叫
    defer ctrl.Finish()

    //mock介面
    mock := NewMockOrderDBI(ctrl)
    //模擬傳入值與預期的返回值
    mock.EXPECT().GetName(gomock.Eq(1225)).Return("xdcutecute")

    //前面定義了傳入值與返回值
    //在這裡
    if v := mock.GetName(1225); v != "xdcutecute"{
        t.Fatal("expected xdcute, but got", v)
    }else{
        log.Println("通過mock取到的name:",v)
    }
}

Eq(value) 表示與 value 等價的值。
Any() 可以用來表示任意的入參。
Not(value) 用來表示非 value 以外的值。
Nil() 表示 None 值

測試結果:

D:\goproj\test_study\db>go test -run TestGetName
2020/12/25 16:41:00 通過mock取到的name: xdcutecute
PASS
ok      test_study/db   0.174s

可以看到測試通過了,與我們預期的值相符。是不是突然Get到了GoMock的好處~

mock工具的作用是指定函式的行為(模擬函式的行為)。可以對入參進行校驗,對出參進行設定,還可以指定函式的返回值。

10.最簡單的打樁(stubs)

像第九節這種模擬介面呼叫方法,有明確的引數值與返回值,就是最簡單的打樁

,或稱樁程式碼,是指用來代替關聯程式碼或者未實現程式碼的程式碼。如果函式B用B1來代替,那麼,B稱為原函式,B1稱為樁函式。打樁就是編寫或生成樁程式碼。

下一節會展現更多場景的打樁。

11.使用GoStub框架-變數、函式、過程打樁

記得使用該命令安裝噢 go get github.com/prashantv/gostub

GoStub框架的使用場景很多,依次為:

  1. 基本場景:為一個全域性變數打樁
  2. 基本場景:為一個函式打樁
  3. 基本場景:為一個過程打樁
  4. 複合場景:由任意相同或不同的基本場景組合而成

全域性變數:

var str="xdcute.com"
func main() {
    stubs := Stub(&str, "learnku")
    defer stubs.Reset()
    fmt.Println(str)
    // 可以多次打樁
    stubs.Stub(&str, "xdcute")
    fmt.Println(str)
    }

    //輸出
    //learnku
    //xdcute

stubs是GoStub框架的函式介面Stub返回的物件,Reset方法將全域性變數的值恢復為原值。

不論是呼叫Stub函式還是StubFunc函式,都會生成一個Stubs物件,Stubs物件仍然有Stub方法和StubFunc方法,所以在一個測試用例中可以同時對多個全域性變數、函式或過程打樁。全域性變數、函式或過程會將初始值存在一個map中,並在延遲語句中通過Reset方法統一做回滾處理。

函式:(針對有引數,有返回值的寫法,使用Stub())

    var printStr = func(val string) string {
        return val
    }
    stubs := Stub(&printStr, func(val string) string {
        return "hello," + val
    })
        defer stubs.Reset()
    fmt.Println("After stub:", printStr("xdcute"))

//輸出
//After stub: hello,xdcute

針對無引數,有返回值的函式打樁,可以使用StubFunc()

    var printStr = func(val string) string {
        return val
    }
    // StubFunc 第一個引數必須是一個函式變數的指標,該指標指向的必須是一個函式變數,第二個引數為函式 mock 的返回值
    stubs := StubFunc(&printStr, "xdcute,萬生世代")
    defer stubs.Reset()
    fmt.Println("After stub:", printStr("lalala"))

    //輸出
    //After stub: xdcute,萬生世代

通過StubFunc()已經設定了mock固定返回值。

過程:

沒有返回值的函式稱為過程。

var PrintStr = printStr
var printStr = func(val string) {
    fmt.Println(val)
}

func main() {
stubs := StubFunc(&printStr)
PrintStr("xdcute")
defer stubs.Reset()
}

//輸出
//xdcute

12.HttpMock-模擬http請求

在web專案中,大多介面是處理http請求(post、get之類的),可以利用官方自帶的http包來進行模擬請求。
假如有一個HttpGetWithTimeOut方法,內部邏輯會有一個get請求,最後返回內容。我們在測試環境中,是訪問不到它發起的get請求的url的,此時就可以模擬http請求來寫測試。
程式碼示例:

func TestHttpGetWithTimeOut(t *testing.T) {

    Convey("TestHttpGetWithTimeOut", t, func() {
        Convey("TestHttpGetWithTimeOut normal", func() {
            ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
                w.Write([]byte("TestHttpGetWithTimeOut success!!"))
                if r.Method != "GET" {
                    t.Errorf("Except 'Get' got '%s'", r.Method)
                }
                if r.URL.EscapedPath() != "/要訪問的url" {
                    t.Errorf("Expected request to '/要訪問的url', got '%s'", r.URL.EscapedPath())
                }
            }))
            api := ts.URL
            defer ts.Close()
            var header = make(map[string]string)
            HttpGetWithTimeOut(api, header, 30)
        })

httptest.NewServer():建立一個http請求
http.ResponseWriter:響應體
http.Request:請求體

這段程式碼中,通過w來設定返回的頭內容與寫入內容,通過r來設定請求方法和請求的url。
最後將模擬好的請求,傳參對應方法。

13.SqlMock-模擬資料庫請求

引用命令 go get -t https://github.com/DATA-DOG/go-sqlmock
特點:
1.模擬任何實現了sql/driver介面的db驅動,無需關注db連線。

使用參考:
構建模擬sql

db, mock, err = sqlmock.New() // mock sql.DB
defer db.Close()

執行查詢語句

mock.ExpectQuery(sqlSelectAll).WillReturnRows(sqlmock.NewRows(nil))

有更多呼叫方法請檢視官方文件

14.GoMonkey-強大的打樁框架

特點:
1.直接在方法級別上進行mock
(在執行時通過彙編語句重寫可執行檔案,將待打樁函式或方法的實現跳轉到樁實現)
在編譯階段直接替換掉真的函式程式碼部分
2.非執行緒安全,請勿用於併發測試

使用:
PatchInstanceMethod() 對於方法
在使用前,先要定義一個目標類的指標變數x
第一個引數是reflect.TypeOf(x)
第二個引數是字串形式的函式名
返回值是一個PatchGuard物件指標,主要用於在測試結束時刪除當前的補丁

var e *Etcd
guard := PatchInstanceMethod(reflect.TypeOf(e), "Get", func(_ *Etcd, _ string) []string {
    return []string{"task1", "task5", "task8"}
})
defer guard.Unpatch()

Patch() 對於過程
當一個函式沒有返回值時,該函式我們一般稱為過程。很多時候,我們將資源清理類函式定義為過程。

guard := Patch(DestroyResource, func(_ string) {
})
defer guard.Unpatch()

對於函式

第一個引數是目標函式的函式名
第二個引數是樁函式的函式名,習慣用法是匿名函式或閉包
返回值是一個PatchGuard物件指標,主要用於在測試結束時刪除當前的補丁

func TestExec(t *testing.T) {
    Convey("test has digit", t, func() {
        Convey("for succ", func() {
            outputExpect := "xxx-vethName100-yyy"
            guard := Patch(osencap.Exec, func(_ string, _ ...string) (string, error) {
                return outputExpect, nil
            })
            defer guard.Unpatch()
            output, err := osencap.Exec(any, any)
            So(output, ShouldEqual, outputExpect)
            So(err, ShouldBeNil)
        })
    })
}

15.測試風格介紹

TDD(Test Drive Development)測試驅動開發

它的基本思想就是在開發功能程式碼之前,先編寫測試程式碼。再通過開發功能來滿足測試程式碼的通過,一旦出bug就需要修復重構。以此迴圈,來保證功能開發的完備性。
這樣的好處是能夠編寫出足夠健壯的程式碼,但前提是需要確保所有的需求在測試用例中都被照顧到,而且測試用例需要儘可能覆蓋分支和行。
tdd

簡單來說,不可執行/可執行/重構——這正是測試驅動開發的口號,也是 TDD 的核心。在這個閉環中,每一個階段的輸出都會成為下一階段的輸入。

  1. 不可執行——寫一個功能最小完備的單元測試,並使得該單元測試編譯失敗。
  2. 可執行——快速編寫剛剛好使測試通過的程式碼,不需要考慮太多,甚至可以使用一些不合理的方法。
  3. 重構——消除剛剛編碼過程引入的重複設計,優化設計結構。

參考文章:測試驅動開發(TDD)總結——原理篇

BDD(Behavior Driven Development)行為驅動開發
它是在軟體開發前,需要給出用例和預期,促使專案中人員溝通。
產品或者專案人員都參與進來,加強溝通,寫出更符合產品需求預期的用例。只不過用例不再侷限於一個函式或者型別,而是更高層面。
bdd
參考文章:Go專案中的BDD實踐

16.總結

關於單元測試,經過一段時間的理論結合實踐的學習,熟悉了Golang中單元測試的使用以及加深了單元測試對於專案的重要性。
在為不同的介面寫測試時,能夠快速熟悉專案,並且發現原有程式碼中可能存在的一些需要改進的地方。例如有 些方法,內部邏輯沒有問題,但是缺少對於空值、非法值的處理。此時可以在該方法中新增註釋,提醒開發人員可以進行改進,或者主動聯絡開發人員溝通,自己直接優化程式碼。
利用單元測試,可以很好的熟悉專案、發現可能存在的問題、發現可以優化的部分。bug發現的越晚,修改它所需要的成本就越高,所以應該儘可能早地查詢和修改bug

重視單元測試後你將會發現單元測試帶來的好處遠遠不止這些~

今後我也會繼續學習如何更好的寫測試,加強理論知識的實踐,繼續提升程式碼能力。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章