如何測試你的 Go 代碼

本文首發於個人博客,若是以爲有用,歡迎點贊收藏。html

不管是開源項目,仍是平常程序的開發,測試都是必不可少的一個環節。今天咱們開始進入 Go 測試模塊 testing 的介紹。git

差很少兩週沒有更新 Go 文章了,最近狀態不是太好。這篇文章原本準備寫的內容很是豐富,結果有點力不從心,移除了好幾個小節。思考下來,仍是決定拆成幾篇。github

另外,參考資料中有幾篇很精彩的文章,有興趣也能夠讀一下。golang

簡單概述

咱們選擇開源項目,一般會比較關注這個項目的測試用例編寫的是否完善,一個優秀項目的測試通常寫的不會差。爲了往後本身能寫出一個好的項目,測試這塊仍是要好好學習下。web

常接觸的測試主要是單元測試和性能測試。毫無心外,go 的 testing 也支持這兩種測試。單元測試用於模塊測試,而性能則是由基準測試完成,即 benchmark。正則表達式

Go 測試模塊除了上面提到的功能,還有一項能力,支持編寫案例,經過與 godoc 的結合,能夠很是快捷地生成庫文檔。bash

最易想到的方法

談到如何測試一個函數的功能,對開發來講,最容易想到的方法就是在 main 中直接調用函數判斷結果。微信

舉個例子,測試 math 方法下的絕對值函數 Abs,示例代碼以下:markdown

package main

import (
	"fmt"
	"math"
)

func main() {
	v := math.Abs(-10)
	if v != 10 {
		fmt.Println("測試失敗")
		return
	}

	fmt.Println("測試成功")
}
複製代碼

更常見的多是,if 判斷都沒有,直接 Print 輸出結果,咱們觀察結果確認問題。特別對於習慣使用 Python、PHP 腳本語言的開發, 建一個腳本測試是很是快速的,由於曾經很長一段時間,我就是如此。app

這種方式有什麼缺點?個人理解,主要幾點,如main 中的測試不容易複用,經常是建了就刪;測試用例變多時,靈活性不夠,常會有修改代碼的需求;自動化測試也不是很是方便等等問題。

我對測試的瞭解不是很深,上面這些僅僅個人一些體驗吧。

遇到了問題就得解決,下面正式開始進入 go testing 中單元測試的介紹。

一個快速體驗案例

單元測試用於在指定場景下,測試功能模塊在指定的輸入狀況下,肯定有沒有定期望結果輸出結果。

咱們直接看個例子,簡單直觀。測試 math 下的 Abs 絕對值函數。首先,在某個目錄建立測試文件 math_test.go,代碼以下:

package math

import (
	"math"
	"testing"
)

func TestAbs(t *testing.T) {
	var a, expect float64 = -10, 10

	actual := math.Abs(a)
	if actual != expect {
		t.Fatalf("a = %f, actual = %f, expected = %f", a, actual, expect)
	}
}
複製代碼

程序很是簡潔,a 是 Abs 函數的輸入參數,expect 是指望獲得的執行結果,actual 是函數執行的實際結果,測試結果由 actual 和 expect 比較結果肯定。

完成用例編寫,go test 命令執行測試,咱們會看到以下輸出。

$ go test
PASS
ok      study/test/math 0.004s
複製代碼

輸出爲 PASS,表示測試用例成功執行。0.004s 表示用例執行時間。

學會使用 go testing

從前面例子中能夠了解到,Go 的測試寫起來仍是很是方便的。關於它的使用方式,主要有兩點,一是測試代碼的編寫規則,二是 API 的使用。

測試的編寫規則

Go 的測試必須按規則方式編寫,否則 go test 將沒法正肯定位測試代碼的位置,主要三點規則。

首先,測試代碼文件的命名必須是以 _test.go 結尾,好比上節中的文件名 math_tesh.go 並不是隨意取的。

還有,代碼中的用例函數必須知足匹配 TestXxx,好比 TestAbs。

關於 Xxx,簡單解釋一下,它主要傳達兩點含義,一是 Xxx 表示首個字符必須大寫或數字,簡單而言就是可肯定單詞分隔,二是首字母后的字符能夠是任意 Go 關鍵詞合法字符,如大小寫字母、下劃線、數字。

第三,關於用例函數類型定義,定義以下。

func TestXxx(*testing.T) 複製代碼

測試函數必須按這個固定格式編寫,不然 go test 將執行報錯。函數中有一個輸入參數 t, 類型是 *testing.T,它很是重要,單元測試需經過它反饋測試結果,具體後面再介紹。

靈活記憶 API 的使用

按規則編寫測試用例只能保證 go test 的正肯定位執行。但爲了能夠分析測試結果,咱們還須要與測試框架進行交互,這就須要測試函數輸入參數 t 的參與了。

在 TestAbs 中,咱們用到了 t.Fatalf,它的做用就是反饋測試結果。假設沒有這段代碼,發生錯誤也會反饋測試成功,這顯然不是咱們想要的。

咱們能夠經過官方文檔,看下 testing.T 中支持的可導出方法,以下:

// 獲取測試名稱
method (*T) Name() string
// 打印日誌
method (*T) Log(args ...interface{})
// 打印日誌,支持 Printf 格式化打印
method (*T) Logf(format string, args ...interface{})
// 反饋測試失敗,但不退出測試,繼續執行
method (*T) Fail()
// 反饋測試成功,馬上退出測試
method (*T) FailNow()
// 反饋測試失敗,打印錯誤
method (*T) Error(args ...interface{})
// 反饋測試失敗,打印錯誤,支持 Printf 的格式化規則
method (*T) Errorf(format string, args ...interface{})
// 檢測是否已經發生過錯誤
method (*T) Failed() bool
// 至關於 Error + FailNow,表示這是很是嚴重的錯誤,打印信息結束需馬上退出。
method (*T) Fatal(args ...interface{})
// 至關於 Errorf + FailNow,與 Fatal 相似,區別在於支持 Printf 格式化打印信息;
method (*T) Fatalf(format string, args ...interface{})
// 跳出測試,從調用 SkipNow 退出,若是以前有錯誤依然提示測試報錯
method (*T) SkipNow()
// 至關於 Log 和 SkipNow 的組合
method (*T) Skip(args ...interface{})
// 與Skip,至關於 Logf 和 SkipNow 的組合,區別在於支持 Printf 格式化打印
method (*T) Skipf(format string, args ...interface{})
// 用於標記調用函數爲 helper 函數,打印文件信息或日誌,不會追溯該函數。
method (*T) Helper()
// 標記測試函數可並行執行,這個並行執行僅僅指的是與其餘測試函數並行,相同測試不會並行。
method (*T) Parallel()
// 可用於執行子測試
method (*T) Run(name string, f func(t *T)) bool 複製代碼

上面列出了單元測試 testing.T 中全部的公開方法,我我的思路,把它們大概分爲三類,分別是底層方法、測試反饋,還有一些其餘運行控制的輔助方法。

基礎信息的 API 只有 1 個,Name() 方法,用於獲取測試名稱。運行控制的輔助方法主要指的是 Helper、t.Parallel 和 Run,上面的註釋對它們已經作了簡單介紹。

咱們這裏重點說說測試反饋的 API,畢竟它用的最多。前面用到的 Fatalf 方法就是其中之一,它的效果是打印錯誤日誌並馬上退出測試。但願速記這類 API 嗎?咱們或許能夠按幾個層級進行記憶。

首先,咱們記住一些相關的基礎方法,它們是其它方法的核心組成,以下:

  • 日誌打印,Log 與 Logf,Log 和 Logf 區別可對比 Println 和 Printf,即 Logf 支持 Printf 格式化打印,而 Log 不支持。
  • 失敗標記,Fail 和 FailNow,Fail 與 FailNow 都是用於標記測試失敗的方法,它們的區別在於 Fail 標記失敗後還會繼續執行執行接下來的測試,而 FailNow 在標記失敗後會馬上退出。
  • 測試忽略,SkipNow 方法退出測試,但並不會標記測試失敗,可與 FailNow 對比記憶。

咱們再看看剩餘的那些方法,基本都是由基礎方法組合而來。咱們可根據場景,選擇不一樣的組合。好比:

  • 普通日誌,只是打印一些日誌,能夠直接使用 Log 或 Logf 便可;
  • 普通錯誤,若是不退出測試,只是打印一些錯誤提示信息,使用 Error 或 Errorf,這兩個方法是 log 或 logf 和 Fail 的組合;
  • 嚴重錯誤,須要退出測試,並打印一些錯誤提示信息,使用 Fatal (log + FailNow) 或 Fatalf (logf + FailNow);
  • 忽略錯誤,並退出測試,可使用 Skip (log + SkipNow) 和 Skipf (logf + SkipNow);

若是支持 Printf 的格式化信息打印,方法後面都會有一個 f 字符。如此一總結,咱們發現 testing.T 中的方法的記憶很是簡單。

忽然想到,不知是否有人會問什麼狀況下算是測試成功。其實,只要沒有標記失敗,測試就是成功的。

實踐一個案例

講了那麼多基礎知識,我都有點口感舌燥了。如今,開始嘗試使用一下它吧!

舉一個簡單的例子,測試一個除法函數。首先,建立一個 math.go 文件。函數代碼以下:

package math

import "errors"

func Division(a, b float64) (float64, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}

	return a / b, nil
}
複製代碼

Division 很是簡單,輸入參數 a、b 分別是被除數和除數,輸出參數是計算結果和錯誤提示。若是除數是 0,將會給出相應的錯誤提示。

在正式測試 Division 函數前,咱們先要梳理下什麼樣的輸入與指望結果表示測試成功。輸入不一樣,指望結果也就不一樣,多是正確結果,亦或者是期待的錯誤結果。什麼意思?以這裏的 Division 爲例,兩種場景須要考慮:

  • 正常調用返回結果,好比當被除數爲 10,除數爲 5,指望獲得的結果爲 2,即指望獲得正確的結果;
  • 指望錯誤返回結果,當被除數爲 10,除數爲 0,指望返回除數不能爲 0 的錯誤,即指望返回錯誤提示;

若是是測試驅動開發,在咱們正式寫實現代碼前,就須要把這些先定義好,而且寫好測試代碼。

分析完用例就能夠開始寫代碼啦。

先是正常調用的測試,以下:

func TestDivision(t *testing.T) {
	var a, b, expect float64 = 10, 5, 2

	actual, err := Division(a, b)
	if err != nil {
		t.Errorf("a = %f, b = %f, expect = %f, err %v", a, b, expect, err)
		return
	}

	if actual != expect {
		t.Errorf("a = %f, b = %f, expect = %f, actual = %f", a, b, expect, actual)
	}
}
複製代碼

定義了三個變量,分別是 a、b、expect,對應被除數、除數和指望結果。用例經過對比 Division 的實際結果 actual 與指望結果 expect 確認測試是否成功。還有就是,Division 返回的 error 也要檢查,由於這裏期待的正常運行結果,只要有錯便可認定測試失敗。

再看指望錯誤結果,以下:

func TestDivisionZero(t *testing.T) {
	var a, b float64 = 10, 0
	var expectedErrString = "division by zero"

	_, err := Division(a, b)
	if err.Error() != expectedErrString {
		t.Errorf("a = %f, b = %f, err %v, expect err %s", a, b, err, expectedErrString)
		return
	}
}
複製代碼

一樣是首先定義了三個變量,a、b 和 expectErrString,a、b 含義與以前相同,expectErrString 爲預期提示的錯誤信息。除數 b 設置爲 0 ,主要是爲了測試 Division 函數是否能按預期返回錯誤,因此咱們並不關心計算結果。測試成功與否,經過比較實際的返回 error 與 expectErrString 肯定。

經過 go test 執行測試,以下:

$ go test -v
=== RUN   TestDivision
--- PASS: TestDivision (0.00s)
=== RUN   TestDivisionZero
--- PASS: TestDivisionZero (0.00s)
PASS
ok      study/test/math 0.005s
複製代碼

結果顯示,測試成功!

這個案例的演示中,咱們在 go test 上加入 -v 選項,這樣就能夠清晰地看到每一個測試用例的執行狀況。

簡潔緊湊的表組測試

經過上面的例子,不知道有沒有發現一個問題?

若是將要測試的某個功能函數的用例很是多,咱們將會須要寫不少代碼重複度很是高的測試函數,由於對於單元測試而言,基本都是圍繞一個簡單模式:

指定輸入參數 -> 調用要測試的函數 -> 獲取返回結果 -> 比較實際返回與指望結果 -> 確認測試失敗提示

基於此,Go 提倡咱們使用一種稱爲 "Table Driven" 的測試方式,中文翻譯,可稱爲表組測試。它可讓咱們以一種短小緊密的方式編寫測試。具體如何作呢?

首先,咱們要定義一個用於表組測試的結構體,其中要包含測試所需的輸入與指望的輸出。以 Division 函數測試爲例,能夠定義以下的結構體:

type DivisionTable struct {
	a         float64 // 被除數
	b         float64 // 除數
	expect    float64 // 期待計算值
	expectErr error   // 期待錯誤字符串
}
複製代碼

各字段的含義在註釋部分已經作了相關說明,和咱們以前作的單個場景的測試涉及字段差很少。區別在於 expectErr 再也不是 string 類型。

接下來,將下面咱們須要測試的用例經過該結構體字面量表示出來。

var table = []DivisionTable{
	{1., 1., 1., nil},
	{-4., -2., 2., nil},
	{2., 0., 7., errors.New("division by zero")},
}
複製代碼

簡單列舉了三種場景,分別正數之間的除法、負數之間的除數以及除數爲 0 的狀況下的除法。

接下來的目標就是實現一個通用 Division 測試函數。直接看代碼吧!

func TestDivisionTable(t *testing.T) {
	for _, v := range divisionTable {
		actual, err := Division(v.a, v.b)
		if err == nil {
			if v.expectErr != nil {
				t.Errorf(
					"a = %f, b = %f, actual err not nil, expect err is nil", v.a, v.b)
			}
		} else if err != nil {
			if v.expectErr == nil {
				t.Errorf(
					"a = %f, b = %f, actual err not nil, expect err is nil", v.a, v.b)
			} else if !strings.Contains(err.Error(), v.expectErr.Error()) {
				t.Errorf(
					"a = %f, b = %f, actual err = %v, expect err = %v", v.a, v.b, err, v.expectErr)
			}
		} else if actual != v.expect {
			t.Errorf(
				"a = %f, b = %f, actual = %f, expect = %f", v.a, v.b, actual, v.expect)
		}
	}
}
複製代碼

代碼看起來比較亂,這主要是由於 error 接口內部實際類型是指針,不能直接使用比較操做符對比 error,因此要作一些處理。若是沒有錯誤的比較,這個例子就容易理解的多了。

能夠梳理一下,邏輯其實很是簡單。主要由幾個步驟組成:

  • 首先遍歷 divisionTable,獲取到輸入參數與指望結果;
  • 使用從 divisionTable 獲取到輸入參數調用功能函數;
  • 獲取功能函數的執行結果,包括計算結果與可能的錯誤;
  • 對比返回 err 與 expectErr,須要處理 err == nil 和 != nil 兩種狀況;
    • 實際 err 爲 nil, expectErr 也須要爲 nil;
    • 實際 err 不爲 nil,expectErr 不可爲 nil,且錯誤信息包含在 err 中。
  • 最後一步,比較實際計算結果與指望結果;

若是發生錯誤,咱們使用 t.Errorf 打印錯誤日誌並告知測試失敗。用 Errorf 的緣由是咱們不能只是一個用例失敗就退出整個測試。固然,這個要視狀況而定吧,沒有固定規則。

介紹到這,核心部分就講的差很少了。

詳細的日誌輸出

對於一些剛接觸 Go testing 的朋友,可能碰到過接下來要講的這個奇怪的問題。

例子說明最直觀,以下:

func TestDivision(t *testing.T) {
	var a, b, expect float64 = 10, 5, 2

	actual, err := Division(a, b)
	if err != nil {
		t.Errorf("a = %f, b = %f, expect = %f, err %v", a, b, expect, err)
		return
	}

	if actual != expect {
		t.Errorf("a = %f, b = %f, expect = %f, actual = %f", a, b, expect, actual)
	}

	t.Log("end")
}
複製代碼

仍是以前的例子,相比之下,最後增長了一段日誌打印 t.Log("end")。不加任何選項的 go test 的執行效果以下:

$ go test
PASS
ok      study/test/math 0.004s
複製代碼

輸出日誌中並沒看到增長那行 end 日誌。

前面的演示中,咱們用到了 go test 的 -v 選項,經過它,能夠查看很是詳細的輸出信息。咱們加上 -v 選項,再執行看效果:

$ go test -v
=== RUN   TestDivision
--- PASS: TestDivision (0.00s)
    math_test.go:36: end
PASS
ok      study/test/math 0.005s
複製代碼

多出了不少信息,而且打出了那行 end 日誌,並給出代碼的位置 math_test.go:36: end。除此之外,還具體到了每個測試的執行狀況,好比測試執行開始和測試結果。

靈活控制運行哪些測試

假設,咱們把前面演示用到的那些測試函數所有放在 math_test.go 中。此時,使用默認 go test 測試會遇到一個問題,那就是每次都將包中的測試函數都執行一遍。有什麼辦法能靈活控制呢?

能夠先來看看此類問題,常見的使用場景有哪些!我想到的幾點,以下:

  • 執行 package 下全部測試函數,go test 默認就是如此,不用多說;
  • 執行其中的某一個測試函數,好比當咱們把前面寫的全部測試函數都放在了 math_test.go 文件中,如何選擇其中一個執行;
  • 按某一類匹配規則執行測試函數,好比執行名稱知足以 Division 開頭的測試函數;
  • 執行項目下的全部測試函數,一個項目一般不止一個包,如何要將全部包的測試函數都執行一遍,該如何作呢;

第一個本不怎麼用介紹了。但有一點仍是要介紹下,那就是除默認執行當前路徑的包,咱們也能夠具體指定執行哪一個 package 的測試函數,指定方式支持純粹的文件路徑方式以及包路徑方式。

假設,咱們包的導入路徑爲 example/math,而咱們當前位置在 example 目錄下,就有兩種方式執行 math 下的測試。

$ go test # 目錄路徑執行
$ go test example/math # GOPATH 包導入路徑
複製代碼

第2、三場景,執行其中的某個或某類測試,主要與 go test 的 -run 選項有關,-run 選項接收參數是正則表達式。

執行某一個具體的函數,如 TestDivision,命令執行效果以下:

$ go test -run "^TestDivision$" -v
=== RUN   TestDivision
--- PASS: TestDivision (0.00s)
    math_test.go:36: end
PASS
ok      study/test/math 0.004s
複製代碼

從輸出中可瞭解到,確實只執行了 TestDivision。這裏要記住加上 -v 選項,使輸出信息具體到某一個測試。

執行具體的某一個類的函數,如除法相關測試 Division,命令執行效果以下:

$ go test -run "Division" -v
=== RUN   TestDivision
--- PASS: TestDivision (0.00s)
    math_test.go:36: end
=== RUN   TestDivisionZero
--- PASS: TestDivisionZero (0.00s)
=== RUN   TestDivisionTable
--- PASS: TestDivisionTable (0.00s)
PASS
ok  	_/Users/polo/Public/Work/go/src/study/test/math	0.005s
複製代碼

將前面寫過的函數名中包含 Division 所有執行一遍。

第四個場景,執行整個項目下的測試。在項目的頂層目錄,直接執行 go test ./... 便可,具體就不演示了。

總結

本文主要介紹了 Go 中測試模塊使用的一些基礎方法。從最容易想到的經過 main 測試方法到使用 go testing 中編寫單元測試,接着介紹了一個測試案例,由此引入了 Go 中推薦 "Table Driven" 的測試方式。最後,文章還介紹了一些對咱們平時工做比較有實際意義的技巧。

參考

Unit Testing make easy in Go
gotests
go test 單元測試
testing - 單元測試
Writing table driven tests in Go
How to write benchmarks in Go
Go 如何寫測試用例


附錄說明

平時我除了寫技術博客,還常常在各問答平臺回答技術問題,這些內容我都會聚合到本身的微信公衆號中。

歡迎你們掃碼關注。

相關文章
相關標籤/搜索