Go語言核心36講(Go語言實戰與應用一)–學習筆記

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/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發布。