Go語言核心36講(Go語言實戰與應用一)–學習筆記
- 2021 年 11 月 10 日
- 筆記
- 【016】Go語言核心36講
23 | 測試的基本規則和流程 (上)
在接下來的日子裡,我將帶你去學習在 Go 語言編程進階的道路上,必須掌握的附加知識,比如:Go 程式測試、程式監測,以及 Go 語言標準庫中各種常用程式碼包的正確用法。
從上個世紀到今日今時,程式設計師們,尤其是中國的程式設計師們,都對編寫程式樂此不疲,甚至廢寢忘食(比如我自己就是一個例子)。
因為這是我們普通人訓練自我、改變生活、甚至改變世界的一種特有的途徑。不過,同樣是程式,我們卻往往對編寫用於測試的程式敬而遠之。這是為什麼呢?
我個人感覺,從人的本性來講,我們都或多或少會否定「對自我的否定」。我們不願意看到我們編寫的程式有 Bug(即程式錯誤或缺陷),尤其是剛剛傾注心血編寫的,並且信心滿滿交付的程式。
不過,我想說的是,人是否會進步以及進步得有多快,依賴的恰恰就是對自我的否定,這包括否定的深刻與否,以及否定自我的頻率如何。這其實就是「不破不立」這個詞表達的含義。
對於程式和軟體來講,儘早發現問題、修正問題其實非常重要。在這個網路互聯的大背景下,我們所做的程式、工具或者軟體產品往往可以被散布得更快、更遠。但是,與此同時,它們的錯誤和缺陷也會是這樣,並且可能在短時間內就會影響到成千上萬甚至更多的用戶。
你可能會說:「在開源模式下這就是優勢啊,我就是要讓更多的人幫我發現錯誤甚至修正錯誤,我們還可以一起協作、共同維護程式。」但這其實是兩碼事,協作者往往是由早期或核心的用戶轉換過來的,但絕對不能說程式的用戶就肯定會成為協作者。
當有很多用戶開始對程式抱怨的時候,很可能就預示著你對此的人設要崩塌了。你會發現,或者總有一天會發現,越是人們關注和喜愛的程式,它的測試(尤其是自動化的測試)做得就越充分,測試流程就越規範。
即使你想眾人拾柴火焰高,那也得先讓別人喜歡上你的程式。況且,對於優良的程式和軟體來說,測試必然是非常受重視的一個環節。所以,儘快用測試為你的程式建起堡壘吧!
對於程式或軟體的測試也分很多種,比如:單元測試、API 測試、集成測試、灰度測試,等等。我在本模組會主要針對單元測試進行講解。
前導內容:go 程式測試基礎知識
我們來說一下單元測試,它又稱程式設計師測試。顧名思義,這就是程式設計師們本該做的自我檢查工作之一。
Go 語言的締造者們從一開始就非常重視程式測試,並且為 Go 程式的開發者們提供了豐富的 API 和工具。利用這些 API 和工具,我們可以創建測試源碼文件,並為命令源碼文件和庫源碼文件中的程式實體,編寫測試用例。
在 Go 語言中,一個測試用例往往會由一個或多個測試函數來代表,不過在大多數情況下,每個測試用例僅用一個測試函數就足夠了。測試函數往往用於描述和保障某個程式實體的某方面功能,比如,該功能在正常情況下會因什麼樣的輸入,產生什麼樣的輸出,又比如,該功能會在什麼情況下報錯或表現異常,等等。
我們可以為 Go 程式編寫三類測試,即:功能測試(test)、基準測試(benchmark,也稱性能測試),以及示例測試(example)。
對於前兩類測試,從名稱上你就應該可以猜到它們的用途。而示例測試嚴格來講也是一種功能測試,只不過它更關注程式列印出來的內容。
一般情況下,一個測試源碼文件只會針對於某個命令源碼文件,或庫源碼文件(以下簡稱被測源碼文件)做測試,所以我們總會(並且應該)把它們放在同一個程式碼包內。
測試源碼文件的主名稱應該以被測源碼文件的主名稱為前導,並且必須以「_test」為後綴。例如,如果被測源碼文件的名稱為 demo52.go,那麼針對它的測試源碼文件的名稱就應該是 demo52_test.go。
每個測試源碼文件都必須至少包含一個測試函數。並且,從語法上講,每個測試源碼文件中,都可以包含用來做任何一類測試的測試函數,即使把這三類測試函數都塞進去也沒有問題。我通常就是這麼做的,只要把控好測試函數的分組和數量就可以了。
我們可以依據這些測試函數針對的不同程式實體,把它們分成不同的邏輯組,並且,利用注釋以及幫助類的變數或函數來做分割。同時,我們還可以依據被測源碼文件中程式實體的先後順序,來安排測試源碼文件中測試函數的順序。
此外,不僅僅對測試源碼文件的名稱,對於測試函數的名稱和簽名,Go 語言也是有明文規定的。你知道這個規定的內容嗎?
所以,我們今天的問題就是:Go 語言對測試函數的名稱和簽名都有哪些規定?
這裡我給出的典型回答是下面三個內容。
- 對於功能測試函數來說,其名稱必須以Test為前綴,並且參數列表中只應有一個*testing.T類型的參數聲明。
- 對於性能測試函數來說,其名稱必須以Benchmark為前綴,並且唯一參數的類型必須是*testing.B類型的。
- 對於示例測試函數來說,其名稱必須以Example為前綴,但對函數的參數列表沒有強制規定。
問題解析
我問這個問題的目的一般有兩個。
- 第一個目的當然是考察 Go 程式測試的基本規則。如果你經常編寫測試源碼文件,那麼這道題應該是很容易回答的。
- 第二個目的是作為一個引子,引出第二個問題,即:go test命令執行的主要測試流程是什麼?不過在這裡我就不問你了,我直接說一下答案。
我們首先需要記住一點,只有測試源碼文件的名稱對了,測試函數的名稱和簽名也對了,當我們運行go test命令的時候,其中的測試程式碼才有可能被運行。
go test命令在開始運行時,會先做一些準備工作,比如,確定內部需要用到的命令,檢查我們指定的程式碼包或源碼文件的有效性,以及判斷我們給予的標記是否合法,等等。
在準備工作順利完成之後,go test命令就會針對每個被測程式碼包,依次地進行構建、執行包中符合要求的測試函數,清理臨時文件,列印測試結果。這就是通常情況下的主要測試流程。
請注意上述的「依次」二字。對於每個被測程式碼包,go test命令會串列地執行測試流程中的每個步驟。
但是,為了加快測試速度,它通常會並發地對多個被測程式碼包進行功能測試,只不過,在最後列印測試結果的時候,它會依照我們給定的順序逐個進行,這會讓我們感覺到它是在完全串列地執行測試流程。
另一方面,由於並發的測試會讓性能測試的結果存在偏差,所以性能測試一般都是串列進行的。更具體地說,只有在所有構建步驟都做完之後,go test命令才會真正地開始進行性能測試。
並且,下一個程式碼包性能測試的進行,總會等到上一個程式碼包性能測試的結果列印完成才會開始,而且性能測試函數的執行也都會是串列的。
一旦清楚了 Go 程式測試的具體過程,我們的一些疑惑就自然有了答案。比如,那個名叫testIntroduce的測試函數為什麼沒執行,又比如,為什麼即使是簡單的性能測試執行起來也會比功能測試慢,等等。
demo52.go
package main
import (
"errors"
"flag"
"fmt"
)
var name string
func init() {
flag.StringVar(&name, "name", "everyone", "The greeting object.")
}
func main() {
flag.Parse()
greeting, err := hello(name)
if err != nil {
fmt.Printf("error: %s\n", err)
return
}
fmt.Println(greeting, introduce())
}
// hello 用於生成問候內容。
func hello(name string) (string, error) {
if name == "" {
return "", errors.New("empty name")
}
return fmt.Sprintf("Hello, %s!", name), nil
}
// introduce 用於生成介紹內容。
func introduce() string {
return "Welcome to my Golang column."
}
demo52_test.go
package main
import (
"fmt"
"testing"
)
func TestHello(t *testing.T) {
var name string
greeting, err := hello(name)
if err == nil {
t.Errorf("The error is nil, but it should not be. (name=%q)",
name)
}
if greeting != "" {
t.Errorf("Nonempty greeting, but it should not be. (name=%q)",
name)
}
name = "Robert"
greeting, err = hello(name)
if err != nil {
t.Errorf("The error is not nil, but it should be. (name=%q)",
name)
}
if greeting == "" {
t.Errorf("Empty greeting, but it should not be. (name=%q)",
name)
}
expected := fmt.Sprintf("Hello, %s!", name)
if greeting != expected {
t.Errorf("The actual greeting %q is not the expected. (name=%q)",
greeting, name)
}
t.Logf("The expected greeting is %q.\n", expected)
}
func testIntroduce(t *testing.T) { // 請注意這個測試函數的名稱。
intro := introduce()
expected := "Welcome to my Golang column."
if intro != expected {
t.Errorf("The actual introduce %q is not the expected.",
intro)
}
t.Logf("The expected introduce is %q.\n", expected)
}
總結
在本篇文章的一開始,我就試圖向你闡釋程式測試的重要性。在我經歷的公司中起碼有一半都不重視程式測試,或者說沒有精力去做程式測試。
尤其是中小型的公司,他們往往完全依靠軟體品質保障團隊,甚至真正的用戶去幫他們測試。在這些情況下,軟體錯誤或缺陷的發現、回饋和修復的周期通常會很長,成本也會很大,也許還會造成很不好的影響。
Go 語言是一門很重視程式測試的程式語言,它不但自帶了testing包,還有專用於程式測試的命令go test。我們要想真正用好一個工具,就需要先了解它的核心邏輯。所以,我今天問你的第一個問題就是關於go test命令的基本規則和主要流程的。在知道這些之後,也許你對 Go 程式測試就會進入更深層次的了解。
思考題
除了本文中提到的,你還知道或用過testing.T類型和testing.B類型的哪些方法?它們都是做什麼用的?你可以給我留言,我們一起討論。
筆記源碼
//github.com/MingsonZheng/go-core-demo
本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: //www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發布。