[譯]從正確的方式開始測試你的Go應用

從正確的方式開始測試你的Go應用

/**
 * 謹獻給Yoyo
 *
 * 原文出處:https://www.toptal.com/go/your-introductory-course-to-testing-with-go
 * @author dogstar.huang <chanzonghuang@gmail.com> 2016-08-11
 */

在學習任何新的東西時,具有清醒的頭腦是很重要的。html

若是你對Go至關陌生,並來自諸如JavaScript或Ruby這樣的語言,你極可能習慣於使用現成的框架來幫助你模擬、斷言以及作一些其餘測試的 您嘲笑,斷言,和作其餘測試巫術。git

如今,消除基於於外部依賴或框架的想法!幾年前在學習這門出衆的編程語言時,測試是我遇到的第一個障礙,那時只有至關少的一些資源可用。github

如今我知道了,在GO中測試成功,意味着對依賴輕裝上陣(如同和GO全部事情那樣),最少依賴於外部類庫,以及編寫更好、可重用的代碼。此Blake Mizerany的經驗介紹勇於向第三方測試庫嘗試,是一個調整你思想很好的開始。你將看到一些關於使用外部類庫以及「Go的方式」的框架的爭論。golang

想學Go嗎?來看看咱們Golang的入門教程吧。編程

構建本身的測試框架和模擬的概念彷佛違反直覺,但也能夠很容易就能想到,對於學習這門語言,這是一個良好的起點。另外,不像我當時學習那樣,在貫穿常見測試腳本以及介紹我認爲是有效測試和保持代碼整潔最佳實踐的過程當中,你都有這篇文章做爲引導。json

以「Go的方式」來作事,消除對外部框架的依賴。安全

Go中的表格測試

基本的測試單元 - 「單元測試」的名聲 - 能夠是一個程序的任何部分,它以最簡單的形式,只須要一個輸入並返回一個輸出。讓咱們來看一個將要爲其編寫測試的簡單函數。顯然,它遠不是完美和完成的,但出於演示目的是足夠好的了:
avg.go服務器

func Avg(nos ...int) int {  
    sum := 0
    for _, n := range nos {
        sum += n
    }
    if sum == 0 {
        return 0
    }
    return sum / len(nos)
}

上面函數,func Avg(nos ...int),返回零或給它一系列數字的整數平均值。如今讓咱們來給它寫一個測試吧。架構

在Go中,給測試文件命名和包含待測試代碼的文件相同名稱,並帶上附加的後綴_test被當爲最佳實踐。例如,上面代碼是一個名爲avg.go的文件中,因此咱們的測試文件將被命名爲avg_test.goapp

注意,這些示例只是實際文件的摘錄,由於包定義和導入出於簡化已刪去。

這是針對Avg函數的測試:

avg_test.go

func TestAvg(t *testing.T) {  
    for _, tt := range []struct {
        Nos    []int
        Result int
    }{
        {Nos: []int{2, 4}, Result: 3},
        {Nos: []int{1, 2, 5}, Result: 2},
        {Nos: []int{1}, Result: 1},
        {Nos: []int{}, Result: 0},
        {Nos: []int{2, -2}, Result: 0},
    } {
        if avg := Average(tt.Nos...); avg != tt.Result {
            t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg)
        }
    }
}

關於函數定義,有幾件事情須要注意的:

  • 首先,在測試函數名稱的「Test」前綴。這是必需的,以便工具把它做爲一種有效的測試檢測出來。
  • 測試函數名稱的後半部分一般是待測試函數或者方法的名稱,在這裏是Avg
  • 咱們還須要傳入稱爲testing.T的測試結構,其容許控制測試流。有關此API的更多詳細信息,請訪問此文檔

如今,讓咱們來聊聊這個例子編寫的格式。一個測試套件(一系列測試)正經過Agv()函數運行,而且每一個測試含一個特定的輸入和預期的輸出。在咱們的例子中,每次測試傳入一系列整數(Nos)和所指望的一個特定的返回值(Result)。

表格測試從它的結構得名,很容易被表示成一個有兩列的表格:輸入變量和預期的輸出變量。

Golang接口模擬

Go語言所提供的最偉大和最強大的功能稱爲接口。除了在進行程序架構設計時得到接口的強大功能和靈活性外,接口也爲咱們提供了使人驚訝的機會來解耦組件以及在交匯點全面測試他們。

接口是指定方法的集合,也是一個變量類型。

讓咱們看一個虛構的場景,假設須要從io.Reader讀取前N個字節,並把它們做爲一個字符串返回。它看起來像是這樣:
readn.go

// readN reads at most n bytes from r and returns them as a string.
func readN(r io.Reader, n int) (string, error) {  
    buf := make([]byte, n)
    m, err := r.Read(buf)
    if err != nil {
        return "", err
    }
    return string(buf[:m]), nil
}


顯然,主要要測試的是readN這個功能,當給定各類輸入時,返回正確的輸出。這能夠用表格測試來完成。但另外也有兩個特殊的場景該覆蓋到,那就是要檢查:

  • readN被一個大小爲n的緩衝調用
  • readN返回一個錯誤若是拋出異常

爲了知道傳遞給r.Read的緩衝區的大小,以及控制它返回的錯誤,咱們須要模擬傳遞給readNr。若是看一下Go文檔中的Reader類型,咱們看到io.Reader看起來像:

type Reader interface {  
       Read(p []byte) (n int, err error)
}

這彷佛至關容易。爲了知足io.Reader咱們所須要作是有自已模擬一個Read方法。因此,ReaderMock能夠是這樣:

type ReaderMock struct {  
    ReadMock func([]byte) (int, error)
}

func (m ReaderMock) Read(p []byte) (int, error) {  
    return m.ReadMock(p)
}

咱們稍微來分析一下上面的代碼。任何ReaderMock的實例明顯地知足了io.Reader接口,由於它實現了必要的Read方法。咱們的模擬還包含字段ReadMock,使咱們可以設置模擬方法確切的行爲,這使得動態實例任何須要的行爲很是容易。

爲確保接口在運行時能知足須要,一個偉大的不消耗內存的技巧是,把如下代碼插入到咱們的代碼中:

var _ io.Reader = (*MockReader)(nil)

這樣會檢查斷言,但不會分配任何東西,這讓咱們確保該接口在編譯時已被正確實現,即在該程序真正使用它運行到任何功能以前。可選的技巧,但很實用。

繼續往前,讓咱們來寫第一個測試:r.Read被大小爲n的緩衝區調用。爲了作到這點,咱們傅用了ReaderMock,以下:

func TestReadN_bufSize(t *testing.T) {  
    total := 0
    mr := &MockReader{func(b []byte) (int, error) {
        total = len(b)
        return 0, nil
    }}
    readN(mr, 5)
    if total != 5 {
        t.Fatalf("expected 5, got %d", total)
    }
}

正如你在上面看到的,咱們經過一個局部變量定義了「假的」io.Reader功能,這可用於後面斷言咱們的測試的有效性。至關容易。

再來看下須要測試的第二個場景,這要求咱們模擬Read以返回一個錯誤:

func TestReadN_error(t *testing.T) {  
    expect := errors.New("some non-nil error")
    mr := &MockReader{func(b []byte) (int, error) {
        return 0, expect
    }}
    _, err := readN(mr, 5)
    if err != expect {
        t.Fatal("expected error")
    }
}

在上面的測試,無論什麼調用了mr.Read(咱們模擬的Reader)都將返回既定義的錯誤,所以假設readN的正常運行也會這樣作是可靠的。

Golang方法模擬

一般咱們不須要模擬方法,由於取而代之,咱們傾向於使用結構和接口。這些更容易控制,但偶爾會碰到這種必要性,我常常看到圍繞這塊話題的困惑。甚至有人問怎麼模擬相似log.Println這樣的東西。雖然不多須要測試給log.Println的輸入的狀況,咱們將利用此次機會來證實。

考慮如下簡單的if語句,根據n的值輸出記錄:

func printSize(n int) {  
    if n < 10 {
        log.Println("SMALL")
    } else {
        log.Println("LARGE")
    }
}

在上面的例子中,咱們假設這樣一個好笑的場景:特定測試log.Println被正確的值調用。爲了模擬這個功能,首先須要把它包裝起來:

var show = func(v ...interface{}) {  
    log.Println(v...)
}

以這種方式聲方法 - 做爲一個變量 - 容許咱們在測試中覆蓋它,併爲其分配任何咱們所但願的行爲。間接地,把log.Println的代碼行替換成show,那麼咱們的程序將變成:

func printSize(n int) {  
    if n < 10 {
        show("SMALL")
    } else {
        show("LARGE")
    }
}

如今咱們能夠測試了:

func TestPrintSize(t *testing.T) {  
    var got string
    oldShow := show
    show = func(v ...interface{}) {
        if len(v) != 1 {
            t.Fatalf("expected show to be called with 1 param, got %d", len(v))
        }
        var ok bool
        got, ok = v[0].(string)
        if !ok {
            t.Fatal("expected show to be called with a string")
        }
    }

    for _, tt := range []struct{
        N int
        Out string
    }{
        {2, "SMALL"},
        {3, "SMALL"},
        {9, "SMALL"},
        {10, "LARGE"},
        {11, "LARGE"},
        {100, "LARGE"},
    } {
        got = ""
        printSize(tt.N)
        if got != tt.Out {
            t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got)
        }
    }

    // careful though, we must not forget to restore it to its original value
    // before finishing the test, or it might interfere with other tests in our
    // suite, giving us unexpected and hard to trace behavior.
    show = oldShow
}

咱們不該該「模擬log.Println」,但在那些很是偶然的狀況下,當咱們出於正當理由真的須要模擬一個包級的方法時,爲了作到這一點惟一的方法(據我所知)是把它聲明爲一個包級的變量,這樣咱們就能夠控制它的值。

然而,若是咱們確實須要模擬像log.Println這樣的東西,假如使用了自定義的記錄器,咱們能夠編寫一個更優雅的解決方案。

Golang模板渲染測試

另外一個至關常見的狀況是,根據預期測試某個渲染模板的輸出。讓咱們考慮一個對http://localhost:3999/welcome?name=Frank的GET請求,它會返回如下body:

<html>  
    <head><title>Welcome page</title></head>
    <body>
        <h1 class="header-name">
            Welcome <span class="name">Frank</span>!
        </h1>
    </body>
</html>

若是如今它明顯不夠,查詢參數name與類爲namespan標籤相匹配,這不是一個巧合。在這種狀況下,明顯的測試應該驗證每次跨越多層輸出時這種狀況都正確發生。在這裏我發現GoQuery類庫很是有用。

GoQuery使用相似jQuery的API查詢HTML結構,是用於測試程序標籤輸出的有效性是必不可少的。

如今用這種方式咱們能夠編寫咱們的測試了:
welcome__test.go

func TestWelcome_name(t *testing.T) {  
    resp, err := http.Get("http://localhost:3999/welcome?name=Frank")
    if err != nil {
        t.Fatal(err)
    }
    if resp.StatusCode != http.StatusOK {
        t.Fatalf("expected 200, got %d", resp.StatusCode)
    }
    doc, err := goquery.NewDocumentFromResponse(resp)
    if err != nil {
        t.Fatal(err)
    }
    if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" {
        t.Fatalf("expected markup to contain 'Frank', got '%s'", v)
    }
}

首先,在處理前咱們檢查響應狀態碼是否是200/OK。

我認爲,假設上面的代碼段的其他部分是不言自明不會太牽強:咱們使用http包來提取URL並根據響應建立一個新的goquery兼容文檔,隨後咱們會用它來查詢返回的DOM。咱們檢查了在h1.header-name裏面span.name封裝文本'弗蘭克'。

測試JSON接口

Golang常常用來寫某種API,因此最後但並不是最不重要的,讓咱們來看一些測試JSON API高級的方式。

試想,若是前面的終端返回的是JSON而不是HTML,那麼從http://localhost:3999/welcome.json?name=Frank咱們將期待響應的body看起來像這樣:

{"Salutation": "Hello Frank!"}

斷言JSON響應,正如你能想到那樣,相比於斷言模板響應來講並不會有太大的不一樣,有一點特殊就是咱們不須要任何外部庫或依賴。 Golang的標準庫就足夠了。下面是咱們的測試,以確認對於給定的參數返回正確的JSON:
welcome__test.go

func TestWelcome_name_JSON(t *testing.T) {  
    resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank")
    if err != nil {
        t.Fatal(err)
    }
    if resp.StatusCode != 200 {
        t.Fatalf("expected 200, got %d", resp.StatusCode)
    }
    var dst struct{ Salutation string }
    if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil {
        t.Fatal(err)
    }
    if dst.Salutation != "Hello Frank!" {
        t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation)
    }
}

若是返回瞭解碼之外的任何結構,json.NewDecoder反而會返回一個錯誤,測試將會失敗。考慮到成功對響應結構解碼後,咱們檢查字段的內容是否達到預期 - 在咱們的狀況下是「Hello Frank!」。

Setup 和 Teardown

Golang的測試是容易的,但有一個問題,在這以前的JSON測試和模板渲染測試。他們都假設服務器正在運行,而這建立了一個不可靠的依賴。而且,須要一個「活」的服務器不是一個很好的主意。

測試在一個「活」的生產服務器上的「實時」數據歷來都不是一個好主意;從本地自旋而上或開發副本,因此沒有事情作了後會有嚴重的損害。

幸運的是,Golang提供了httptest包來建立測試服務器。測試引起了本身獨立的服務器,獨立於咱們主要的服務器,所以測試不會干擾生產環境。

在這種狀況下,建立通用的setupteardown方法以便被所有須要運行服務器的測試調用是理想的。根據這一新的、更安全的模式,咱們的測試最終看起來像這樣:

func setup() *httptest.Server {  
    return httptest.NewServer(app.Handler())
}

func teardown(s *httptest.Server) {  
    s.Close()
}

func TestWelcome_name(t *testing.T) {  
    srv := setup()

    url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL)
    resp, err := http.Get(url)
    // verify errors & run assertions as usual

    teardown(srv)
}

注意app.Handler()的引用。這是一個最佳實踐的函數,它返回了應用程序的http Handler,它既能夠實例化生產服務器也能夠實例化測試服務器。

結論

Golang的測試是一個很好的機會,它假定了你程序的外部視野和承擔訪問者的腳步,或是在大多數狀況下,即你的API的用戶。它提供了巨大的機會,以確保你提供了良好的代碼和優質的體驗。

無論什麼時候當你不肯定代碼中更爲複雜的功能時,測試做爲一顆定心丸就會派上用場,同時也保證了當修改較大系統的組成部分時其餘部分仍能繼續一塊兒很好工做。

但願這篇文章能對你有用,若是您知道任何其餘測試技巧也歡迎來發表評論。


------------------------

相關文章
相關標籤/搜索