【譯】Go 中如何進行單元測試
在寫《Go語言標準庫》的第九章 —— 測試 時,看到了此文,講解挺細致,于是翻譯為中文,作為學習《Go語言標準庫》的第九章的補充材料。
如果你花過一些時間學習如何編程,你很可能見過許多地方提過測試。似乎每個人都在談論測試,似乎都同意你應該進行測試,但這到底需要什么呢?
在這篇文章中,我將嘗試回答這個問題,首先解釋什么是測試,然后我會用 Go 去深入實際編寫測試。在編寫測試時,我將通過編寫自定義 main 包,使用 testing 包以及更復雜的功能(如自定義 setup 和 teardown)以及創建可以用作測試用例的示例代碼來覆蓋所有內容。
什么是測試?
讓我們從最簡單的問題開始 — 什么是測試?
簡單地說,測試是一個可重復的過程,它驗證某個東西是否按預期工作。雖然您通常會聽到在軟件世界中的測試,但它們并不限于軟件。
如果您購買和轉售二手電視機,您可能會有一個測試過程,包括將電視插入筆記本電腦的 HDMI 端口,并驗證顯示器和音頻是否在電視上工作。這也是測試。
雖然測試似乎需要一些復雜和自動化的過程,但事實是測試可以從手動鍵入 www.yoursite.com 到您的瀏覽器,以驗證您的部署是否有效,或者它們可能與 Google’s DiRT 一樣復雜 — 該公司試圖測試他們的系統如何在僵尸啟示的情況下自動響應。測試只是一種幫助確定某件事情在特定情況下是否按預期工作的方法。
在前面的電視示例中,您的測試用于確保在插入標準輸入時電視工作正常;而在軟件世界中, 您的測試可能用于確定某個函數是否按您的預期運行。
編寫一個程序測試
雖然測試不需要,但在編程世界中,測試通常通過編寫更多代碼來自動化。它們的目的與任何手動執行的測試相同,但由于它們是用代碼編寫的,所以這些測試具有更多的優勢,可以更快地執行,并且您可以與其他開發人員共享它們。
例如,假設我們需要編寫一個函數 Sum 來計算 slice 中提供的所有整數的和,并返回它,我們想出了下面的代碼。
func Sum(numbers []int) int {
sum := 0
// 這個 bug 是故意的
for n := range numbers {
sum += n
}
return sum
}
現在,假設您想為這個函數編寫一些測試,以確保它按您的預期運行。如果您對測試工具不熟悉 (如果您正在閱讀這篇文章,我假設您確實不熟悉),一個方法是創建一個使用 Sum() 函數的 main 包,如果結果不是我們所期望的,則顯示一條錯誤消息。
package main
import (
"fmt"
"calhoun.io/testing101"
)
func main() {
testSum([]int{2, 2, 2, 4}, 10)
testSum([]int{-1, -2, -3, -4, 5}, -5)
}
func testSum(numbers []int, expected int) {
sum := testing101.Sum(numbers)
if sum != expected {
message := fmt.Sprintf("Expected the sum of %v to be %d but instead got %d!", numbers, expected, sum)
panic(message)
}
}
注意:這里假定您的 Sum() 函數在一個名為 testing101 的包中。如果您在本地自己編寫代碼,記得導入正確的包。
如果我們運行此代碼,會注意到 Sum() 函數實際上沒有按預期的方式工作,我們沒有得到期望的值:10,而是得到 6。進一步排查后,我們可能會意識到,我們使用的是在切片中的索引,而不是切片中每個項的實際值。為了解決這個問題,我們需要更新該行:
for n := range numbers {
改為
for _, n := range numbers {
在進行更改后, 我們可以重新運行 main() 函數,這次不會得到任何輸出,說明測試用例失敗。這就是測試的威力 — 在幾分鐘內,我們就我們的代碼是否正常工作提供了反饋意見。我們可以快速驗證代碼是否如我們修改的方式工作。另外,如果我們將此代碼發送給其他開發人員,他們還可以繼續運行相同的測試,并驗證它們沒有破壞您的代碼。
通過 go test 進行測試
雖然上面所示的方法可能適用于小型項目,但要編寫一個 main 包來測試所有我們想要檢測的內容會變得非常麻煩。幸運的是,在 testing 包,Go 為我們提供一些很好的功能,我們可以在不需要太多學習的情況下使用它們。
若要在 Go 中開始使用測試,首先需要定義要測試的包。如果還沒有,請創建一個名為 testing101 的包,并創建文件 sum.go,添加上下面的代碼:(代碼跟上面的一樣)
package testing101
func Sum(numbers []int) int {
sum := 0
// 這個 bug 是故意的
for _, n := range numbers {
sum += n
}
return sum
}
接下來在同一個包中,創建一個名為 sum_test.go 的文件,并將下面的代碼添加到其中。
package testing101
import (
"fmt"
"testing"
)
func TestSum(t *testing.T) {
numbers := []int{1, 2, 3, 4, 5}
expected := 15
actual := Sum(numbers)
if actual != expected {
t.Errorf("Expected the sum of %v to be %d but instead got %d!", numbers, expected, actual)
}
}
現在我們要運行我們的測試,所以在終端中切換到 testing101 包所在目錄,并使用下面的命令運行測試。
go test -v
你應該看到像這樣的輸出:
=== RUN TestSum
— PASS: TestSum (0.00s)
PASS
ok calhoun.io/testing101 0.005s
恭喜!您剛剛使用 Go 內置的 testing 編寫了第一個測試。現在,讓我們深入了解實際發生的事情。
首先,是我們的文件名。Go 要求所有的測試都在以 _test.go 結尾的文件中。這使得我們在檢查另一個 package 包的源代碼時,確定哪些文件是測試和哪些文件實現功能非常容易。
在看了文件名之后,我們可以直接跳轉到代碼中,將測試包導入。它為我們提供了一些類型 (如testing.T) ,這些類型提供常見功能,比如在測試失敗時設置錯誤消息。
接下來,是函數 TestSum()。所有的測試都應該以 func TestXxx(*testing.T) 的格式來編寫。其中 Xxx 可以是任何字符或數字,而第一個字符需要是大寫字符或數字。(譯注:一般,Xxx 就是被測試的函數名)
最后,如上所述,我們使用了 TestSum 函數中的參數 *tesing.T 。如果我們沒有得到預期的結果,我們使用它來設置一個錯誤,當我們運行測試時,該錯誤將顯示在終端上。若要查看此操作,請將測試代碼中的 expected 更新為 18,而不更新 numbers 變量,然后使用 go test -v 運行測試。您應該會看到顯示如下所示錯誤信息的輸出:
=== RUN TestSum
— FAIL: TestSum (0.00s)
sum_test.go:14: Expected the sum of [1 2 3 4 5] to be 18 but instead got 15!
FAIL
exit status 1
FAIL calhoun.io/testing101 0.005s
在本節的所有內容中,您應該能夠開始對所有代碼進行一些基本測試,但如果需要為同一函數添加更多測試用例,或者需要構造自己的類型來測試代碼,會發生什么情況?
每個函數多個測試用例
在上面的 case 中,我們的 Sum() 函數的代碼非常簡單,但是當您編寫自己的代碼時,您可能會發現自己想要為每個函數添加更多的測試用例而不僅僅是一個。例如,我們可能希望驗證 Sum() 是否能正確處理負數。
在 Go 中運行多個測試用例有幾種選擇。一個選擇是簡單地在我們的 sum_test.go 中創建另一個函數。例如,我們可以添加函數 TestSumWithNegatives() 。這是迄今為止最簡單的方法,但它可能導致某些代碼重復,而且我們的測試輸出中沒有很好的嵌套測試用例。
另一種選擇,不是創建多個 TestXxx() 函數,而是使用 testing.T 的 Run 方法,它允許我們傳遞一個要運行的子測試的名稱,以及一個用于測試的函數。打開 sum_test.go 并更新為如下代碼:(參考: 【譯】子測試和子基準測試的使用 )
package testing101
import (
"fmt"
"testing"
)
func TestSum(t *testing.T) {
t.Run("[1,2,3,4,5]", testSumFunc([]int{1, 2, 3, 4, 5}, 15))
t.Run("[1,2,3,4,-5]", testSumFunc([]int{1, 2, 3, 4, -5}, 5))
}
func testSumFunc(numbers []int, expected int) func(*testing.T) {
return func(t *testing.T) {
actual := Sum(numbers)
if actual != expected {
t.Error(fmt.Sprintf("Expected the sum of %v to be %d but instead got %d!", numbers, expected, actual))
}
}
}
此示例使用了一個閉包,它是一個函數,它使用了沒有直接傳遞給它的變量(外部函數的變量)。這對于創建一個只接受 testing.T 變量的函數很有用,而且也可以訪問我們要為每個測試用例動態定義的變量。如果你不知道什么是閉包,我建議你看看 stack overflow 上的這個問題 ,如果這還幫助不了你,你可以發送電子郵件給我(jon@calhoun.io),我會嘗試寫一篇關于閉包的文章。
通過使用閉包,我們可以在測試中動態地設置變量,而不需要一次又一次地編寫相同的代碼。現在,如果我們使用go test -v 運行我們的測試,我們將得到以下輸出:
=== RUN TestSum
=== RUN TestSum/[1,2,3,4,5]
=== RUN TestSum/[1,2,3,4,-5]
— PASS: TestSum (0.00s)
— PASS: TestSum/[1,2,3,4,5] (0.00s)
— PASS: TestSum/[1,2,3,4,-5] (0.00s)
PASS
ok calhoun.io/testing101 0.005s
這些測試現在用它們的輸入做了標記,并且嵌套在 TestSum 測試用例下,使得調試任何問題都非常容易做到。
譯注:還有一種選擇其實更常用,那就是表格測試。
示例作為測試
幾乎所有開發人員的目標之一就是編寫易于使用和維護的代碼。為了實現這一點,包含如何使用代碼的示例通常會有所幫助。Go 的 testing 包提供了幫助定義示例源代碼的功能。作為附加的用途,testing 包還可以測試您的示例,以確保它們在測試過程中輸出您期望的內容。
打開 sum_test.go,在文件末尾增加如下代碼:
func ExampleSum() {
numbers := []int{5, 5, 5}
fmt.Println(Sum(numbers))
// Output:
// 15
}
然后使用 go test -v 運行測試。您現在應該在結果中看到此示例函數,但它是如何被測試的呢?
Go 使用在 ExampleXxx() 函數底部的 “Output 注釋” 部分來確定預期的輸出是什么,然后在運行測試時,它將實際輸出與注釋中的預期輸出進行比較,如果不匹配,將觸發失敗的測試。這樣,我們可以同時編寫測試和示例代碼。
除了創建用于測試的示例外,示例還用于顯示在生成的文檔中。例如,上面的例子可以用來為我們的 testing101 包生成文檔,類似下面的截圖。
更復雜的例子
在測試足夠的代碼和編寫足夠的示例之后,您最終會發現某些測試在單個函數中不容易編寫。發生這種情況的一個常見原因是,您需要在多次測試之前或之后設置(setup)或拆卸(teardown)東西。例如,您可能希望從環境變量獲取數據庫 URL,并在運行多個測試之前設置到數據庫的連接,而不是單獨為每個測試重新連接到數據庫。
為支持該功能,Go 提供了 TestMain(*testing.M) 的函數,它在需要的時候代替運行所有的測試。使用 TestMain() 函數時,您有機會在測試運行之前或之后插入所需的任何自定義代碼,但唯一需要注意的是必須處理 flag 解析并使用測試結果調用 os.Exit()。這聽起來可能很復雜,但實際上只有兩行代碼。
flag.Parse()
os.Exit(m.Run())
讓我們看一個更完整的例子。在我們的 testing101 包中,創建一個名為 db_test.go 的文件,并將下面的代碼添加到其中。
package testing101
import (
"flag"
"fmt"
"os"
"testing"
)
var db struct {
Url string
}
func TestMain(m *testing.M) {
// Pretend to open our DB connection
db.Url = os.Getenv("DATABASE_URL")
if db.Url == "" {
db.Url = "localhost:5432"
}
flag.Parse()
exitCode := m.Run()
// Pretend to close our DB connection
db.Url = ""
// Exit
os.Exit(exitCode)
}
func TestDatabase(t *testing.T) {
// Pretend to use the db
fmt.Println(db.Url)
}
在這段代碼中,我們首先創建一個名為 db 的全局變量,它是一個包含 Url 的結構體。通常,這將是一個實際的數據庫連接,但對于這個例子,我們只是示例,只設置了 Url。
接下來在 TestMain() 中,我們假裝通過分析環境變量 DATABASE_URL 并將其設置為 db.Url 屬性來打開數據庫連接。如果這是一個空字符串,則默認為 localhost:5432,Postgres 使用的默認端口。
之后我們解析標志 (這樣我們的 go test -v 中的 -v 選項可以工作),調用 m.Run() 并將結果狀態碼存儲在 exitCode 中,以便在結束測試時可以引用它。如果你對退出狀態代碼不太了解,現在就不重要了。只需記住,我們需要存儲從 m.Run() 返回的狀態碼,以后再使用它。
在運行測試后,我們假裝通過將 db.Url 屬性設置為空字符串來關閉數據庫連接。
最后,我們使用 os.Exit(exitCode) 退出。這將導致當前程序 (我們正在運行的測試) 使用我們提供的狀態代碼退出。通常,除零之外的任何內容都將被視為錯誤。
總結
看完這里的所有內容,您可能準備好了要開始為 Go 編寫的代碼編寫測試。但請記住,這只是表明您可以編寫測試并不意味著您應該。過度測試和沒有測試幾乎一樣糟糕,因為它可能導致需要維護的大量測試代碼。
英文原文: https://www.calhoun.io/how-to-test-with-go/ 或
https://semaphoreci.com/community/tutorials/how-to-test-in-go
來自:http://blog.studygolang.com/2017/10/how-to-test-with-go/