測試您的 Go 應用程序:以正確的方式開始

已發表: 2022-03-11

在學習任何新事物時,保持新鮮的心態很重要。

如果您是 Go 的新手並且來自 JavaScript 或 Ruby 等語言,那麼您可能習慣於使用現有的框架來幫助您模擬、斷言和執行其他測試魔法。

現在消除依賴外部依賴或框架的想法! 幾年前,當我學習這種非凡的編程語言時,測試是我偶然發現的第一個障礙,當時可用的資源要少得多。

我現在知道,在 Go 中測試成功意味著輕視依賴(就像所有 Go 一樣),對外部庫的依賴最少,並編寫良好的可重用代碼。 這篇關於 Blake Mizerany 與第三方測試庫冒險的經歷的介紹是調整你的心態的一個很好的開始。 你會看到一些關於使用外部庫和框架與使用“Go 方式”的爭論。

想學圍棋? 查看我們的 Golang 入門教程。

構建自己的測試框架和模擬概念似乎違反直覺,但比人們想像的要容易,並且是學習語言的良好起點。 另外,與我學習時不同,本文將指導您完成常見的測試場景,並介紹我認為有效測試和保持代碼清潔的最佳實踐的技術。

做事“走的路”,消除對外部框架的依賴。
鳴叫

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.go

請注意,這些示例只是實際文件的摘錄,因為為簡單起見,已省略包定義和導入。

這是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 的更多詳細信息,請訪問文檔頁面。

現在我們來談談編寫示例的形式。 一個測試套件(一系列測試)正在通過函數Avg()運行,每個測試都包含一個特定的輸入和預期的輸出。 在我們的例子中,每個測試都發送一個整數切片( 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在給定各種輸入時返回正確的輸出。 這可以通過表格測試來完成。 但是我們還應該涵蓋另外兩個重要的方面,即檢查:

  • r.Read使用大小為 n 的緩衝區調用。
  • 如果拋出一個錯誤, r.Read返回一個錯誤。

為了知道傳遞給r.Read的緩衝區的大小,以及控制它返回的錯誤,我們需要模擬傳遞給readNr 。 如果我們查看有關 Reader 類型的 Go 文檔,我們會看到io.Reader樣子:

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

這似乎相當容易。 為了滿足io.Reader我們所要做的就是讓我們的 mock 擁有一個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.ReaderRead函數定義了一個範圍變量的行為,以後可以使用它來斷言我們測試的有效性。 很容易。

再來看看我們需要測試的第二種場景,它需要我們mock 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的正確功能也會這樣做。

Go 中的函數模擬

我們並不經常需要模擬一個函數,因為我們傾向於使用結構和接口。 這些更容易控制,但有時我們會遇到這種必要性,我經常看到圍繞該主題的混淆。 有些人甚至問如何模擬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類的東西,如果我們要使用自定義記錄器,可以編寫一個更優雅的解決方案。

Go 模板渲染測試

另一個相當常見的場景是測試渲染模板的輸出是否符合預期。 讓我們考慮一個對http://localhost:3999/welcome?name=Frank的 GET 請求,它返回以下正文:

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

如果現在還不夠明顯,那麼查詢參數name與分類為“名稱”的span的內容相匹配並不是巧合。 在這種情況下,顯而易見的測試將是驗證這在每次跨多個輸出時都正確發生。 我發現 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是否封裝了文本“Frank”。

測試 JSON API

Go 經常用於編寫某種 API,所以最後但並非最不重要的一點是,讓我們研究一些測試 JSON API 的高級方法。

考慮端點之前是否返回了 JSON 而不是 HTML,所以從http://localhost:3999/welcome.json?name=Frank我們期望響應體看起來像:

 {"Salutation": "Hello Frank!"}

正如人們可能已經猜到的那樣,斷言 JSON 響應與斷言模板響應沒有太大區別,只是我們不需要任何外部庫或依賴項。 Go 的標準庫就足夠了。 這是我們的測試,確認為給定參數返回了正確的 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!”。

安裝和拆卸

使用 Go 進行測試很容易,但是上面的 JSON 測試和之前的模板渲染測試都存在一個問題。 他們都假設服務器正在運行,這會產生不可靠的依賴關係。 此外,反對“實時”服務器也不是一個好主意。

在“實時”生產服務器上測試“實時”數據絕不是一個好主意。 啟動本地或開發副本,以免出現可怕的錯誤。

幸運的是,Go 提供了 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,它可以實例化您的生產服務器或測試服務器。

結論

在 Go 中進行測試是一個很好的機會,可以假設您的程序的外部視角,並以您的訪問者的立場,或者在大多數情況下,您的 API 的用戶。 它提供了一個很好的機會來確保您提供良好的代碼和高質量的體驗。

每當您不確定代碼中更複雜的功能時,測試會派上用場,作為一種保證,並且還可以保證在修改較大系統的部分時,這些部分將繼續很好地協同工作。

希望這篇文章對你有用,如果你知道任何其他測試技巧,歡迎發表評論。