<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
這是Go單測從入門到放棄系列教學的第0篇,主要講解在Go語言中如何做單元測試以及介紹了表格驅動測試、迴歸測試,並且介紹了常用的斷言工具。
Go語言中的測試依賴go test
命令。編寫測試程式碼和編寫普通的Go程式碼過程是類似的,並不需要學習新的語法、規則或工具。
go test命令是一個按照一定約定和組織的測試程式碼的驅動程式。在包目錄內,所有以_test.go
為字尾名的原始碼檔案都是go test
測試的一部分,不會被go build
編譯到最終的可執行檔案中。
在*_test.go
檔案中有三種型別的函數,單元測試函數、基準測試函數和範例函數。
型別 | 格式 | 作用 |
---|---|---|
測試函數 | 函數名字首為Test | 測試程式的一些邏輯行為是否正確 |
基準函數 | 函數名字首為Benchmark | 測試函數的效能 |
範例函數 | 函數名字首為Example | 為檔案提供範例檔案 |
go test
命令會遍歷所有的*_test.go
檔案中符合上述命名規則的函數,然後生成一個臨時的main包用於呼叫相應的測試函數,然後構建並執行、報告測試結果,最後清理測試中生成的臨時檔案。
每個測試函數必須匯入testing
包,測試函數的基本格式(簽名)如下:
func TestName(t *testing.T){ // ... }
測試函數的名字必須以Test
開頭,可選的字尾名必須以大寫字母開頭,舉幾個例子:
func TestAdd(t *testing.T){ ... } func TestSum(t *testing.T){ ... } func TestLog(t *testing.T){ ... }
其中引數t
用於報告測試失敗和附加的紀錄檔資訊。testing.T
的擁有的方法如下:
func (c *T) Cleanup(func()) func (c *T) Error(args ...interface{}) func (c *T) Errorf(format string, args ...interface{}) func (c *T) Fail() func (c *T) FailNow() func (c *T) Failed() bool func (c *T) Fatal(args ...interface{}) func (c *T) Fatalf(format string, args ...interface{}) func (c *T) Helper() func (c *T) Log(args ...interface{}) func (c *T) Logf(format string, args ...interface{}) func (c *T) Name() string func (c *T) Skip(args ...interface{}) func (c *T) SkipNow() func (c *T) Skipf(format string, args ...interface{}) func (c *T) Skipped() bool func (c *T) TempDir() string
就像細胞是構成我們身體的基本單位,一個軟體程式也是由很多單元元件構成的。單元元件可以是函數、結構體、方法和終端使用者可能依賴的任意東西。總之我們需要確保這些元件是能夠正常執行的。單元測試是一些利用各種方法測試單元元件的程式,它會將結果與預期輸出進行比較。
接下來,我們在base_demo
包中定義了一個Split
函數,具體實現如下:
// base_demo/split.go package base_demo import "strings" // Split 把字串s按照給定的分隔符sep進行分割返回字串切片 func Split(s, sep string) (result []string) { i := strings.Index(s, sep) for i > -1 { result = append(result, s[:i]) s = s[i+1:] i = strings.Index(s, sep) } result = append(result, s) return }
在當前目錄下,我們建立一個split_test.go
的測試檔案,並定義一個測試函數如下:
// split/split_test.go package split import ( "reflect" "testing" ) func TestSplit(t *testing.T) { // 測試函數名必須以Test開頭,必須接收一個*testing.T型別引數 got := Split("a:b:c", ":") // 程式輸出的結果 want := []string{"a", "b", "c"} // 期望的結果 if !reflect.DeepEqual(want, got) { // 因為slice不能比較直接,藉助反射包中的方法比較 t.Errorf("expected:%v, got:%v", want, got) // 測試失敗輸出錯誤提示 } }
此時split
這個包中的檔案如下:
❯ ls -l total 16 -rw-r--r-- 1 liwenzhou staff 408 4 29 15:50 split.go -rw-r--r-- 1 liwenzhou staff 466 4 29 16:04 split_test.go
在當前路徑下執行go test
命令,可以看到輸出結果如下:
❯ go test
PASS
ok golang-unit-test-demo/base_demo 0.005s
一個測試用例有點單薄,我們再編寫一個測試使用多個字元切割字串的例子,在split_test.go
中新增如下測試函數:
func TestSplitWithComplexSep(t *testing.T) { got := Split("abcd", "bc") want := []string{"a", "d"} if !reflect.DeepEqual(want, got) { t.Errorf("expected:%v, got:%v", want, got) } }
現在我們有多個測試用例了,為了能更好的在輸出結果中看到每個測試用例的執行情況,我們可以為go test
命令新增-v
引數,讓它輸出完整的測試結果。
❯ go test -v
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
=== RUN TestSplitWithComplexSep
split_test.go:20: expected:[a d], got:[a cd]
--- FAIL: TestSplitWithComplexSep (0.00s)
FAIL
exit status 1
FAIL golang-unit-test-demo/base_demo 0.009s
從上面的輸出結果我們能清楚的看到是TestSplitWithComplexSep
這個測試用例沒有測試通過。
單元測試的結果表明split
函數的實現並不可靠,沒有考慮到傳入的sep引數是多個字元的情況,下面我們來修復下這個Bug:
package base_demo import "strings" // Split 把字串s按照給定的分隔符sep進行分割返回字串切片 func Split(s, sep string) (result []string) { i := strings.Index(s, sep) for i > -1 { result = append(result, s[:i]) s = s[i+len(sep):] // 這裡使用len(sep)獲取sep的長度 i = strings.Index(s, sep) } result = append(result, s) return }
在執行go test
命令的時候可以新增-run
引數,它對應一個正規表示式,只有函數名匹配上的測試函數才會被go test
命令執行。
例如通過給go test
新增-run=Sep
引數來告訴它本次測試只執行TestSplitWithComplexSep
這個測試用例:
❯ go test -run=Sep -v === RUN TestSplitWithComplexSep --- PASS: TestSplitWithComplexSep (0.00s) PASS ok golang-unit-test-demo/base_demo 0.010s
最終的測試結果表情我們成功修復了之前的Bug。
我們修改了程式碼之後僅僅執行那些失敗的測試用例或新引入的測試用例是錯誤且危險的,正確的做法應該是完整執行所有的測試用例,保證不會因為修改程式碼而引入新的問題。
❯ go test -v === RUN TestSplit --- PASS: TestSplit (0.00s) === RUN TestSplitWithComplexSep --- PASS: TestSplitWithComplexSep (0.00s) PASS ok golang-unit-test-demo/base_demo 0.011s
測試結果表明我們的單元測試全部通過。
通過這個範例我們可以看到,有了單元測試就能夠在程式碼改動後快速進行迴歸測試,極大地提高開發效率並保證程式碼的質量。
為了節省時間支援在單元測試時跳過某些耗時的測試用例。
func TestTimeConsuming(t *testing.T) { if testing.Short() { t.Skip("short模式下會跳過該測試用例") } ... }
當執行go test -short
時就不會執行上面的TestTimeConsuming
測試用例。
在上面的範例中我們為每一個測試資料編寫了一個測試函數,而通常單元測試中需要多組測試資料保證測試的效果。Go1.7+ 中新增了子測試,支援在測試函數中使用t.Run
執行一組測試用例,這樣就不需要為不同的測試資料定義多個測試函數了。
func TestXXX(t *testing.T){ t.Run("case1", func(t *testing.T){...}) t.Run("case2", func(t *testing.T){...}) t.Run("case3", func(t *testing.T){...}) }
編寫好的測試並非易事,但在許多情況下,表格驅動測試可以涵蓋很多方面:表格裡的每一個條目都是一個完整的測試用例,包含輸入和預期結果,有時還包含測試名稱等附加資訊,以使測試輸出易於閱讀。
使用表格驅動測試能夠很方便的維護多個測試用例,避免在編寫單元測試時頻繁的複製貼上。
表格驅動測試的步驟通常是定義一個測試用例表格,然後遍歷表格,並使用t.Run
對每個條目執行必要的測試。
表格驅動測試不是工具、包或其他任何東西,它只是編寫更清晰測試的一種方式和視角。
官方標準庫中有很多表格驅動測試的範例,例如fmt包中的測試程式碼:
var flagtests = []struct { in string out string }{ {"%a", "[%a]"}, {"%-a", "[%-a]"}, {"%+a", "[%+a]"}, {"%#a", "[%#a]"}, {"% a", "[% a]"}, {"%0a", "[%0a]"}, {"%1.2a", "[%1.2a]"}, {"%-1.2a", "[%-1.2a]"}, {"%+1.2a", "[%+1.2a]"}, {"%-+1.2a", "[%+-1.2a]"}, {"%-+1.2abc", "[%+-1.2a]bc"}, {"%-1.2abc", "[%-1.2a]bc"}, } func TestFlagParser(t *testing.T) { var flagprinter flagPrinter for _, tt := range flagtests { t.Run(tt.in, func(t *testing.T) { s := Sprintf(tt.in, &flagprinter) if s != tt.out { t.Errorf("got %q, want %q", s, tt.out) } }) } }
通常表格是匿名結構體陣列切片,可以定義結構體或使用已經存在的結構進行結構體陣列宣告。name屬性用來描述特定的測試用例。
接下來讓我們試著自己編寫表格驅動測試:
func TestSplitAll(t *testing.T) { // 定義測試表格 // 這裡使用匿名結構體定義了若干個測試用例 // 並且為每個測試用例設定了一個名稱 tests := []struct { name string input string sep string want []string }{ {"base case", "a:b:c", ":", []string{"a", "b", "c"}}, {"wrong sep", "a:b:c", ",", []string{"a:b:c"}}, {"more sep", "abcd", "bc", []string{"a", "d"}}, {"leading sep", "沙河有沙又有河", "沙", []string{"", "河有", "又有河"}}, } // 遍歷測試用例 for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 使用t.Run()執行子測試 got := Split(tt.input, tt.sep) if !reflect.DeepEqual(got, tt.want) { t.Errorf("expected:%#v, got:%#v", tt.want, got) } }) } }
在終端執行go test -v
,會得到如下測試輸出結果:
❯ go test -v
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
=== RUN TestSplitWithComplexSep
--- PASS: TestSplitWithComplexSep (0.00s)
=== RUN TestSplitAll
=== RUN TestSplitAll/base_case
=== RUN TestSplitAll/wrong_sep
=== RUN TestSplitAll/more_sep
=== RUN TestSplitAll/leading_sep
--- PASS: TestSplitAll (0.00s)
--- PASS: TestSplitAll/base_case (0.00s)
--- PASS: TestSplitAll/wrong_sep (0.00s)
--- PASS: TestSplitAll/more_sep (0.00s)
--- PASS: TestSplitAll/leading_sep (0.00s)
PASS
ok golang-unit-test-demo/base_demo 0.010s
表格驅動測試中通常會定義比較多的測試case,在Go語言中很容易發揮自身並行優勢將表格驅動測試並行化,可以檢視下面的程式碼範例。
func TestSplitAll(t *testing.T) { t.Parallel() // 將 TLog 標記為能夠與其他測試並行執行 // 定義測試表格 // 這裡使用匿名結構體定義了若干個測試用例 // 並且為每個測試用例設定了一個名稱 tests := []struct { name string input string sep string want []string }{ {"base case", "a:b:c", ":", []string{"a", "b", "c"}}, {"wrong sep", "a:b:c", ",", []string{"a:b:c"}}, {"more sep", "abcd", "bc", []string{"a", "d"}}, {"leading sep", "沙河有沙又有河", "沙", []string{"", "河有", "又有河"}}, } // 遍歷測試用例 for _, tt := range tests { tt := tt // 注意這裡重新宣告tt變數(避免多個goroutine中使用了相同的變數) t.Run(tt.name, func(t *testing.T) { // 使用t.Run()執行子測試 t.Parallel() // 將每個測試用例標記為能夠彼此並行執行 got := Split(tt.input, tt.sep) if !reflect.DeepEqual(got, tt.want) { t.Errorf("expected:%#v, got:%#v", tt.want, got) } }) } }
社群裡有很多自動生成表格驅動測試函數的工具,比如gotests等,很多編輯器如Goland也支援快速生成測試檔案。這裡簡單演示一下gotests
的使用。
安裝
go get -u github.com/cweill/gotests/...
執行
gotests -all -w split.go
上面的命令表示,為split.go
檔案的所有函數生成測試程式碼至split_test.go
檔案(目錄下如果事先存在這個檔案就不再生成)。
生成的測試程式碼大致如下:
package base_demo import ( "reflect" "testing" ) func TestSplit(t *testing.T) { type args struct { s string sep string } tests := []struct { name string args args wantResult []string }{ // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if gotResult := Split(tt.args.s, tt.args.sep); !reflect.DeepEqual(gotResult, tt.wantResult) { t.Errorf("Split() = %v, want %v", gotResult, tt.wantResult) } }) } }
程式碼格式與我們上面的類似,只需要在TODO位置新增我們的測試邏輯就可以了。
測試覆蓋率是指程式碼被測試套件覆蓋的百分比。通常我們使用的都是語句的覆蓋率,也就是在測試中至少被執行一次的程式碼佔總程式碼的比例。在公司內部一般會要求測試覆蓋率達到80%左右。
Go提供內建功能來檢查你的程式碼覆蓋率。我們可以使用go test -cover
來檢視測試覆蓋率。例如:
❯ go test -cover PASS coverage: 100.0% of statements ok golang-unit-test-demo/base_demo 0.009s
從上面的結果可以看到我們的測試用例覆蓋了100%的程式碼。
Go還提供了一個額外的-coverprofile
引數,用來將覆蓋率相關的記錄資訊輸出到一個檔案。例如:
❯ go test -cover -coverprofile=c.out PASS coverage: 100.0% of statements ok golang-unit-test-demo/base_demo 0.009s
上面的命令會將覆蓋率相關的資訊輸出到當前資料夾下面的c.out
檔案中。
❯ tree . . ├── c.out ├── split.go └── split_test.go
然後我們執行go tool cover -html=c.out
,使用cover
工具來處理生成的記錄資訊,該命令會開啟原生的瀏覽器視窗生成一個HTML報告。
上圖中每個用綠色標記的語句塊表示被覆蓋了,而紅色的表示沒有被覆蓋。
testify是一個社群非常流行的Go單元測試工具包,其中使用最多的功能就是它提供的斷言工具——testify/assert
或testify/require
。
go get github.com/stretchr/testify
我們在寫單元測試的時候,通常需要使用斷言來校驗測試結果,但是由於Go語言中沒有提供斷言,所以我們會寫出很多的if...else...
語句。而testify/assert
為我們提供了很多常用的斷言函數,並且能夠輸出友好、易於閱讀的錯誤描述資訊。
比如我們之前在TestSplit
測試函數中就使用了reflect.DeepEqual
來判斷期望結果與實際結果是否一致。
t.Run(tt.name, func(t *testing.T) { // 使用t.Run()執行子測試 got := Split(tt.input, tt.sep) if !reflect.DeepEqual(got, tt.want) { t.Errorf("expected:%#v, got:%#v", tt.want, got) } })
使用testify/assert
之後就能將上述判斷過程簡化如下:
t.Run(tt.name, func(t *testing.T) { // 使用t.Run()執行子測試 got := Split(tt.input, tt.sep) assert.Equal(t, got, tt.want) // 使用assert提供的斷言函數 })
當我們有多個斷言語句時,還可以使用assert := assert.New(t)建立一個assert物件,它擁有前面所有的斷言方法,只是不需要再傳入Testing.T
引數了。
func TestSomething(t *testing.T) { assert := assert.New(t) // assert equality assert.Equal(123, 123, "they should be equal") // assert inequality assert.NotEqual(123, 456, "they should not be equal") // assert for nil (good for errors) assert.Nil(object) // assert for not nil (good when you expect something) if assert.NotNil(object) { // now we know that object isn't nil, we are safe to make // further assertions without causing any errors assert.Equal("Something", object.Value) } }
testify/assert
提供了非常多的斷言函數,這裡沒辦法一一列舉出來,大家可以檢視官方檔案瞭解。
testify/require
擁有testify/assert
所有斷言函數,它們的唯一區別就是——testify/require
遇到失敗的用例會立即終止本次測試。
此外,testify
包還提供了mock、http等其他測試工具,篇幅所限這裡就不詳細介紹了,有興趣的同學可以自己瞭解一下。
本文介紹了Go語言單元測試的基本用法,通過為Split函數編寫單元測試的真實案例,模擬了日常開發過程中的場景,一步一步詳細介紹了表格驅動測試、迴歸測試和常用的斷言工具testify/assert的使用。在下一篇中,我們將更進一步,詳細介紹如何使用httptest和gock工具進行網路測試,更多關於Go語言單元測試基礎的資料請關注it145.com其它相關文章!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45