Go 語言基礎 - 編寫單元測試
在上一篇文章“Grab JSON from an API” 中,我們探索瞭如何使用 HTTP 客戶端以及如何解析 JSON 資料。本篇文章是 Go 語言主題的續篇,講述如何編寫單元測試。
1. Go 語言中的測試
Go 語言有一個自帶的測試命令go test
,還有一個標準testing
測試包,它能夠為你提供一個小卻完整的測試體驗。
這套標準工具鏈還包括了基準測試以及基於語句的程式碼覆蓋率測試,類似與 NCover(.Net) 或者 Istanbul(Node.js)。
1.2 編寫測試程式碼
和 Go 語言其它方面如格式化、命名規則一樣,Go 語言的單元測試也顯得個性十足。它的語法刻意規避了使用斷言模式,並將值驗證和行為檢測的工作留給了開發人員。
這兒有一個例子,我們要對main
包裡的一個方法進行測試。我們已定義了一個名為Sum
的出口函式,它接收兩個整數引數,並將它們相加。
package main func Sum(xint, yint)int { return x + y } func main() { Sum(5, 5) }
我們在另一個單獨的檔案中編寫測試程式碼。這個測試檔案可以在其它的包(目錄)中,或者在相同的包中(main
)。以下是一個檢測相加結果的單元測試:
package main import "testing" func TestSum(t *testing.T) { total := Sum(5, 5) if total != 10 { t.Errorf("Sum was incorrect, got: %d, want: %d.", total, 10) } }
Go 語言的測試函式有以下特徵:
-
只有唯一的引數,必須是
t *testing.T
型別 -
必須以單詞
Test
開頭,再組合上首字母大寫的單詞或片語(一般是被測試的方法名稱,如TestValidateClient
) -
呼叫
t.Error
或者t.Fail
方法指明測試失敗(這裡我使用了t.Errorf
來提供更多的細節) -
t.Log
可以用來提供一些失敗資訊以外的除錯資訊 -
測試程式碼檔名必須是
_test
結尾的形式something_test.go
,例如:addtion_test.go
如果你在同一個目錄下既有程式碼也有測試程式碼,那麼你就無法使用go run *.go
的方式執行你的程式了。我一般會使用go build
編譯出可執行程式,再執行它。
你可能更習慣於使用Assert
關鍵字進行驗證工作,不過The Go Programming Language
的作者們對於 Go 的斷言方式做了許多很好的辯解。
當使用斷言時:
- 測試程式碼往往會讓人覺得他們正在使用另一種語言(比如 RSpec/Mocha)
- 錯誤輸出看起來令人費解 “assert: 0 == 1”
- 可能會產生大量的呼叫棧資訊
- 第一個斷言失敗後,測試程式碼會終止執行 - 會掩蓋其它的失敗可能
有一些類似 RSpec 或者 Assert 的 Go 語言第三方測試庫。比如stretchr/testify 。
測試表
“測試表”的概念是一組測試輸入和輸出值的對映。這是一個針對Sum
函式的例子:
package main import "testing" func TestSum(t *testing.T) { tables := []struct { x int y int n int }{ {1, 1, 2}, {1, 2, 3}, {2, 2, 4}, {5, 2, 7}, } for _, table := range tables { total := Sum(table.x, table.y) if total != table.n { t.Errorf("Sum of (%d+%d) was incorrect, got: %d, want: %d.", table.x, table.y, total, table.n) } } }
如果你想要製造一些錯誤使得測試無法通過,那麼將Sum
函式的返回部分改為x * y
即可。
$ go test -v === RUNTestSum --- FAIL: TestSum (0.00s) table_test.go:19: Sum of (1+1) was incorrect, got: 1, want: 2. table_test.go:19: Sum of (1+2) was incorrect, got: 2, want: 3. table_test.go:19: Sum of (5+2) was incorrect, got: 10, want: 7. FAIL exit status 1 FAILgithub.com/alexellis/t60.013s
啟動測試
有兩種方式可以用來啟動一個包內的測試程式碼。這些方法對於單元測試和整合測試是相同的。
-
在和測試檔案相同的目錄中:
go test
這會執行包內所有匹配 _test.go 名稱的測試程式碼
或者
-
採用完整的包名
go test github.com/alexellis/golangbasics1
現在你可以執行 Go 語言單元測試了,可以使用go test -v
獲得更詳細的輸出,你能看到每條測試的 PASS/FAIL 資訊,以及所有t.Log
打印出的額外日誌資訊。
單元測試和整合測試的區別在於,單元測試通常獨立於外部依賴,不會與網路、磁碟等產生互動。單元測試一般只關注函式的功能。
1.3go test
的更多用法
語句(statement)覆蓋率
go test
工具自帶內建的程式碼語句覆蓋率測試功能。想要用之前的程式碼例子嘗試一下,輸入以下命令即可:
$ go test -cover PASS coverage: 50.0% of statements okgithub.com/alexellis/golangbasics10.009s
較高的語句覆蓋率比低覆蓋率或者零覆蓋率要好,不過這樣量化也可能會產生誤導。我們想保證我們不只是在執行語句,而且我們還驗證了程式碼的行為和輸出,而且在不符合邏輯的地方報錯。如果你刪除了之前例子程式碼中的 “if” 語句,它仍然會保持 50% 的測試覆蓋率,卻喪失了驗證 “Sum” 方法行為的用處。
生成 HTML 格式的覆蓋率測試報告
如果你使用接下來的兩條命令,你就可以直觀地看到你的程式哪些部分被覆蓋到了,而哪些語句沒有被覆蓋到:
go test -cover -coverprofile=c.out go tool cover -html=c.out -o coverage.html
然後用瀏覽器開啟 coverage.html 檔案。
Go 編譯時不會引入你的測試程式碼
還有一點,將addition_test.go
這樣的測試檔案留在你的包目錄中雖然略有些不自然。不過 Go 語言的編譯器和連結器保證不會將你的測試檔案編入任何它生成的二進位制檔案中。
下面有個例子,可以找出 net/http 包中的生成程式碼和測試程式碼。
$ go list -f={{.GoFiles}} net/http [client.go cookie.go doc.go filetransport.go fs.go h2_bundle.go header.go http.go jar.go method.go request.go response.go server.go sniff.go status.go transfer.go transport.go] $ go list -f={{.TestGoFiles}} net/http [cookie_test.go export_test.go filetransport_test.go header_test.go http_test.go proxy_test.go range_test.go readrequest_test.go requestwrite_test.go response_test.go responsewrite_test.go transfer_test.go transport_internal_test.go]
想要了解更多的基礎內容可以閱讀Golang testing docs 。
1.4 脫離依賴
定義單元測試概念的關鍵點就是,它能夠脫離執行時的依賴項或合作者。
這在 Go 語言中是通過介面來實現的,不過如果你有 C# 或者 Java 的背景,它們的介面看起來和 Go 會有些許不同。Go 語言中介面是隱含的,而不是一種強制措施。意味著實際的類並不需要知道介面的存在。
這意味著我們可以定義非常多的小介面,如io.ReadCloser 它只包含兩個方法分別來自於 Reader 和 Closer 介面:
Read(p []byte) (n int, err error)
Reader 介面
Close() error
Closer 介面
如果你在設計一個會被第三方使用的包,那麼定義適當的介面就會顯得非常有意義,因為其他人需要時,可以利用這些介面讓單元測試程式碼能夠不依賴於你的程式碼包。
介面的具體實現在函式呼叫時可以被替換。如果我們想要測試這個方法,我們可以提供一個實現了 Reader 介面的偽造類。
package main import ( "fmt" "io" ) type FakeReader struct { } func (FakeReader)Read(p []byte)(nint, err error) { // return an integer and error or nil } func ReadAllTheBytes(reader io.Reader)[]byte { // read from the reader.. } func main() { fakeReader := FakeReader{} // You could create a method called SetFakeBytes which initialises canned data. fakeReader.SetFakeBytes([]byte("when called, return this data")) bytes := ReadAllTheBytes(fakeReader) fmt.Printf("%d bytes read.\n", len(bytes)) }
在實現你自己的抽象前,去 Golang 文件中搜索一下是否已有現成可用的東西,總會是個不錯的主意。對於上面的例子我們也可以使用標準庫中的bytes 包:
func NewReader(b []byte)*Reader
Go 語言的testing/iotest
包提供了一些 Reader 的實現類,有些執行起來比較慢,有些會在讀資料的中途產生錯誤。這些實現對於適應性測試都非常好用。
- Golang 文件:testing/iotest
1.5 工作示例
接下來我要重構上一篇文章 中尋找宇宙中有多少宇航員的示例程式碼。
讓我們從測試程式碼開始:
package main import "testing" type testWebRequest struct { } func (testWebRequest)FetchBytes(urlstring)[]byte { return []byte(`{"number": 2}`) } func TestGetAstronauts(t *testing.T) { amount := GetAstronauts(testWebRequest{}) if amount != 1 { t.Errorf("People in space, got: %d, want: %d.", amount, 1) } }
這裡我有一個名為 GetAstronauts 的外部方法,它從一個 HTTP 終端讀取位元組資訊,解析成一個結構體,最後返回 “number” 屬性的整數值。
我偽造的方法只返回了能滿足測試的最小化的 JSON 資訊,我先讓它返回與判斷不符的數字,以便我能確定測試程式碼工作正常。因為很難保證第一次執行就通過的測試程式碼,是否真起了作用。
這是程式程式碼中的main
方法。GetAstronauts
方法用一個介面作為它的第一個引數,使得我們能夠獨立於此程式碼中的任何 HTTP 邏輯,以及它的引入依賴項。
package main import ( "encoding/json" "fmt" "log" ) func GetAstronauts(getWebRequest GetWebRequest)int { url := "http://api.open-notify.org/astros.json" bodyBytes := getWebRequest.FetchBytes(url) peopleResult := people{} jsonErr := json.Unmarshal(bodyBytes, &peopleResult) if jsonErr != nil { log.Fatal(jsonErr) } return peopleResult.Number } func main() { liveClient := LiveGetWebRequest{} number := GetAstronauts(liveClient) fmt.Println(number) }
GetWebRequest 介面包含了以下方法:
type GetWebRequest interface { FetchBytes(url string) []byte }
介面是推斷出來的,而不是顯示宣告的。這一點與 C# 和 Java 語言不同。
完整的 types.go 檔案程式碼如下,它是從前一篇博文中截取出來的:
package main import ( "io/ioutil" "log" "net/http" "time" ) type people struct { Number int `json:"number"` } type GetWebRequest interface { FetchBytes(url string) []byte } type LiveGetWebRequest struct { } func (LiveGetWebRequest)FetchBytes(urlstring)[]byte { spaceClient := http.Client{ Timeout: time.Second * 2, // Maximum of 2 secs } req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { log.Fatal(err) } req.Header.Set("User-Agent", "spacecount-tutorial") res, getErr := spaceClient.Do(req) if getErr != nil { log.Fatal(getErr) } body, readErr := ioutil.ReadAll(res.Body) if readErr != nil { log.Fatal(readErr) } return body }
選擇抽象的物件
上面的單元測試僅有效地測試了json.Unmarshal
方法以及我們假想的正常 HTTP 響應結果。這種抽象對於我們的例子來說沒有問題,不過程式碼測試覆蓋率比較低。
我們還可以再做一些底層測試,來保證 HTTP 請求的強制2秒超時是否正確,或者我們建立一個 GET 請求而不是 POST 請求。
令人高興的是,Go 語言自帶了一組用來偽造 HTTP 服務端和客戶端的工具方法。