Go語言開發(fā)代碼自測絕佳go?fuzzing用法詳解
特別說明
這個(gè)真的不是標(biāo)題黨,我寫代碼20+年,真心認(rèn)為 go fuzzing 是我見過的最牛逼的代碼自測方法。我在用 AC自動(dòng)機(jī) 算法改進(jìn)關(guān)鍵字過濾效率(提升~50%),改進(jìn) mapreduce 對(duì) panic 的處理機(jī)制的時(shí)候,都通過 go fuzzing 發(fā)現(xiàn)了邊緣情況的 bug。所以深深的認(rèn)為,這是我見過最牛逼的代碼自測方法,沒有之一!
go fuzzing 至今已經(jīng)發(fā)現(xiàn)了代碼質(zhì)量極高的 Go 標(biāo)準(zhǔn)庫超過200個(gè)bug,見:github.com/dvyukov/go-…
春節(jié)程序員之間的祝福經(jīng)常是,祝你代碼永無 bug!雖然調(diào)侃,但對(duì)我們每個(gè)程序員來說,每天都在寫 bug,這是事實(shí)。代碼沒 bug 這事,只能證偽,不能證明。即將發(fā)布的 Go 1.18 官方提供了一個(gè)幫助我們證偽的絕佳工具 - go fuzzing。
Go 1.18 大家最關(guān)注的是泛型,然而我真的覺得 go fuzzing 真的是 Go 1.18 最有用的功能,沒有之一!
本文我們就來詳細(xì)看看 go fuzzing:
- 是什么?
- 怎么用?
- 有何最佳實(shí)踐?
首先,你需要升級(jí)到 Go 1.18
Go 1.18 雖然還未正式發(fā)布,但你可以下載 RC 版本,而且即使你生產(chǎn)用 Go 更早版本,你也可以開發(fā)環(huán)境使用 go fuzzing 尋找 bug
go fuzzing 是什么
根據(jù) 官方文檔 介紹,go fuzzing 是通過持續(xù)給一個(gè)程序不同的輸入來自動(dòng)化測試,并通過分析代碼覆蓋率來智能的尋找失敗的 case。這種方法可以盡可能的尋找到一些邊緣 case,親測確實(shí)發(fā)現(xiàn)的都是些平時(shí)很難發(fā)現(xiàn)的問題。
go fuzzing 怎么用
官方介紹寫 fuzz tests 的一些規(guī)則:
- 函數(shù)必須是 Fuzz開頭,唯一的參數(shù)是 *testing.F,沒有返回值
- Fuzz tests 必須在 *_test.go 的文件里
- 上圖中的 fuzz target 是個(gè)方法調(diào)用 (*testing.F).Fuzz,第一個(gè)參數(shù)是 *testing.T,然后就是稱之為 fuzzing arguments 的參數(shù),沒有返回值
- 每個(gè) fuzz test 里只能有一個(gè) fuzz target
- 調(diào)用 f.Add(…) 的時(shí)候需要參數(shù)類型跟 fuzzing arguments 順序和類型都一致
fuzzing arguments 只支持以下類型:
string, []byte
int, int8, int16, int32/rune, int64
uint, uint8/byte, uint16, uint32, uint64
float32, float64
bool
fuzz target 不要依賴全局狀態(tài),會(huì)并行跑。
運(yùn)行 fuzzing tests
如果我寫了一個(gè) fuzzing test,比如:
// 具體代碼見 https://github.com/zeromicro/go-zero/blob/master/core/mr/mapreduce_fuzz_test.go func FuzzMapReduce(f *testing.F) { ... }
那么我們可以這樣執(zhí)行:
go test -fuzz=MapReduce
我們會(huì)得到類似如下結(jié)果:
fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers fuzz: elapsed: 3s, execs: 3338 (1112/sec), new interesting: 56 (total: 57) fuzz: elapsed: 6s, execs: 6770 (1144/sec), new interesting: 62 (total: 63) fuzz: elapsed: 9s, execs: 10157 (1129/sec), new interesting: 69 (total: 70) fuzz: elapsed: 12s, execs: 13586 (1143/sec), new interesting: 72 (total: 73) ^Cfuzz: elapsed: 13s, execs: 14031 (1084/sec), new interesting: 72 (total: 73) PASS ok github.com/zeromicro/go-zero/core/mr 13.169s
其中的 ^C 是我按了 ctrl-C 終止了測試,詳細(xì)解釋參考官方文檔。
go-zero 的最佳實(shí)踐
按照我使用下來的經(jīng)驗(yàn)總結(jié),我把最佳實(shí)踐初步總結(jié)為以下四步:
- 定義 fuzzing arguments,首先要想明白怎么定義 fuzzing arguments,并通過給定的 fuzzing arguments 寫 fuzzing target
- 思考 fuzzing target 怎么寫,這里的重點(diǎn)是怎么驗(yàn)證結(jié)果的正確性,因?yàn)?fuzzing arguments 是“隨機(jī)”給的,所以要有個(gè)通用的結(jié)果驗(yàn)證方法
- 思考遇到失敗的 case 如何打印結(jié)果,便于生成新的 unit test
- 根據(jù)失敗的 fuzzing test 打印結(jié)果編寫新的 unit test,這個(gè)新的 unit test會(huì)被用來調(diào)試解決fuzzing test發(fā)現(xiàn)的問題,并固化下來留給CI 用
接下來我們以一個(gè)最簡單的數(shù)組求和函數(shù)來展示一下上述步驟,go-zero 的實(shí)際案例略顯復(fù)雜,文末我會(huì)給出 go-zero 內(nèi)部落地案例,供大家參考復(fù)雜場景寫法。
這是一個(gè)注入了 bug 的求和的代碼實(shí)現(xiàn):
func Sum(vals []int64) int64 { var total int64 for _, val := range vals { if val%1e5 != 0 { total += val } } return total }
1. 定義 fuzzing arguments
你至少需要給出一個(gè) fuzzing argument,不然 go fuzzing 沒法生成測試代碼,所以即使我們沒有很好的輸入,我們也需要定義一個(gè)對(duì)結(jié)果產(chǎn)生影響的 fuzzing argument,這里我們就用 slice 元素個(gè)數(shù)作為 fuzzing arguments,然后 Go fuzzing 會(huì)根據(jù)跑出來的 code coverage 自動(dòng)生成不同的參數(shù)來模擬測試。
func FuzzSum(f *testing.F) { f.Add(10) f.Fuzz(func(t *testing.T, n int) { n %= 20 ... }) }
這里的 n 就是讓 go fuzzing 來模擬 slice 元素個(gè)數(shù),為了保證元素個(gè)數(shù)不會(huì)太多,我們限制在20以內(nèi)(0個(gè)也沒問題),并且我們添加了一個(gè)值為10的語料(go fuzzing 里面稱之為 corpus),這個(gè)值就是讓 go fuzzing 冷啟動(dòng)的一個(gè)值,具體為多少不重要。
2. 怎么寫 fuzzing target
這一步的重點(diǎn)是如何編寫可驗(yàn)證的 fuzzing target,根據(jù)給定的 fuzzing arguments 寫出測試代碼的同時(shí),還需要生成驗(yàn)證結(jié)果正確性用的數(shù)據(jù)。
對(duì)我們這個(gè) Sum 函數(shù)來說,其實(shí)還是比較簡單的,就是隨機(jī)生成 n 個(gè)元素的 slice,然后求和算出期望的結(jié)果。如下:
func FuzzSum(f *testing.F) { rand.Seed(time.Now().UnixNano()) f.Add(10) f.Fuzz(func(t *testing.T, n int) { n %= 20 var vals []int64 var expect int64 for i := 0; i < n; i++ { val := rand.Int63() % 1e6 vals = append(vals, val) expect += val } assert.Equal(t, expect, Sum(vals)) }) }
這段代碼還是很容易理解的,自己求和和 Sum 求和做比較而已,就不詳細(xì)解釋了。但復(fù)雜場景你就需要仔細(xì)想想怎么寫驗(yàn)證代碼了,不過這不會(huì)太難,太難的話,可能是對(duì)測試函數(shù)沒有足夠理解或者簡化。
此時(shí)就可以用如下命令跑 fuzzing tests 了,結(jié)果類似如下:
$ go test -fuzz=Sum fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers fuzz: elapsed: 0s, execs: 6672 (33646/sec), new interesting: 7 (total: 6) --- FAIL: FuzzSum (0.21s) --- FAIL: FuzzSum (0.00s) sum_fuzz_test.go:34: Error Trace: sum_fuzz_test.go:34 value.go:556 value.go:339 fuzz.go:334 Error: Not equal: expected: 8736932 actual : 8636932 Test: FuzzSum Failing input written to testdata/fuzz/FuzzSum/739002313aceff0ff5ef993030bbde9115541cabee2554e6c9f3faaf581f2004 To re-run: go test -run=FuzzSum/739002313aceff0ff5ef993030bbde9115541cabee2554e6c9f3faaf581f2004 FAIL exit status 1 FAIL github.com/kevwan/fuzzing 0.614s
那么問題來了!我們看到了結(jié)果不對(duì),但是我們很難去分析為啥不對(duì),你仔細(xì)品品,上面這段輸出,你怎么分析?
3. 失敗 case 如何打印輸入
對(duì)于上面失敗的測試,我們?nèi)绻艽蛴〕鲚斎?,然后形成一個(gè)簡單的測試用例,那我們就可以直接調(diào)試了。打印出來的輸入最好能夠直接 copy/paste 到新的測試用例里,如果格式不對(duì),對(duì)于那么多行的輸入,你需要一行一行調(diào)格式就太累了,而且這未必就只有一個(gè)失敗的 case。
所以我們把代碼改成了下面這樣:
func FuzzSum(f *testing.F) { rand.Seed(time.Now().UnixNano()) f.Add(10) f.Fuzz(func(t *testing.T, n int) { n %= 20 var vals []int64 var expect int64 var buf strings.Builder buf.WriteString("\n") for i := 0; i < n; i++ { val := rand.Int63() % 1e6 vals = append(vals, val) expect += val buf.WriteString(fmt.Sprintf("%d,\n", val)) } assert.Equal(t, expect, Sum(vals), buf.String()) }) }
再跑命令,得到如下結(jié)果:
$ go test -fuzz=Sum fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers fuzz: elapsed: 0s, execs: 1402 (10028/sec), new interesting: 10 (total: 8) --- FAIL: FuzzSum (0.16s) --- FAIL: FuzzSum (0.00s) sum_fuzz_test.go:34: Error Trace: sum_fuzz_test.go:34 value.go:556 value.go:339 fuzz.go:334 Error: Not equal: expected: 5823336 actual : 5623336 Test: FuzzSum Messages: 799023, 110387, 811082, 115543, 859422, 997646, 200000, 399008, 7905, 931332, 591988, Failing input written to testdata/fuzz/FuzzSum/26d024acf85aae88f3291bf7e1c6f473eab8b051f2adb1bf05d4491bc49f5767 To re-run: go test -run=FuzzSum/26d024acf85aae88f3291bf7e1c6f473eab8b051f2adb1bf05d4491bc49f5767 FAIL exit status 1 FAIL github.com/kevwan/fuzzing 0.602s
4. 編寫新的測試用例
根據(jù)上面的失敗 case 的輸出,我們可以 copy/paste 生成如下代碼,當(dāng)然框架是自己寫的,輸入?yún)?shù)可以直接拷貝進(jìn)去。
func TestSumFuzzCase1(t *testing.T) { vals := []int64{ 799023, 110387, 811082, 115543, 859422, 997646, 200000, 399008, 7905, 931332, 591988, } assert.Equal(t, int64(5823336), Sum(vals)) }
這樣我們就可以很方便的調(diào)試了,并且能夠增加一個(gè)有效 unit test,確保這個(gè) bug 再也不會(huì)出現(xiàn)了。
go fuzzing 更多經(jīng)驗(yàn)
Go 版本問題
我相信,Go 1.18 發(fā)布了,大多數(shù)項(xiàng)目線上代碼不會(huì)立馬升級(jí)到 1.18 的,那么 go fuzzing 引入的 testing.F 不能使用怎么辦?
線上(go.mod)不升級(jí)到 Go 1.18,但是我們本機(jī)是完全推薦升級(jí)的,那么這時(shí)我們只需要把上面的 FuzzSum 放到一個(gè)文件名類似 sum_fuzz_test.go 的文件里,然后在文件頭加上如下指令即可:
// go:build go1.18 // +build go1.18
注意:第三行必須是一個(gè)空行,否則就會(huì)變成 package 的注釋了。
這樣我們?cè)诰€上不管用哪個(gè)版本就不會(huì)報(bào)錯(cuò)了,而我們跑 fuzz testing 一般都是本機(jī)跑的,不受影響。
go fuzzing 不能復(fù)現(xiàn)的失敗
上面講的步驟是針對(duì)簡單情況的,但有時(shí)根據(jù)失敗 case 得到的輸入形成新的 unit test 并不能復(fù)現(xiàn)問題時(shí)(特別是有 goroutine 死鎖問題),問題就變得復(fù)雜起來了,如下輸出你感受一下:
go test -fuzz=MapReduce fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 10 workers fuzz: elapsed: 3s, execs: 3681 (1227/sec), new interesting: 54 (total: 55) ... fuzz: elapsed: 1m21s, execs: 92705 (1101/sec), new interesting: 85 (total: 86) --- FAIL: FuzzMapReduce (80.96s) fuzzing process hung or terminated unexpectedly: exit status 2 Failing input written to testdata/fuzz/FuzzMapReduce/ee6a61e8c968adad2e629fba11984532cac5d177c4899d3e0b7c2949a0a3d840 To re-run: go test -run=FuzzMapReduce/ee6a61e8c968adad2e629fba11984532cac5d177c4899d3e0b7c2949a0a3d840 FAIL exit status 1 FAIL github.com/zeromicro/go-zero/core/mr 81.471s
這種情況下,只是告訴我們 fuzzing process 卡住了或者不正常結(jié)束了,狀態(tài)碼是2。這種情況下,一般 re-run 是不會(huì)復(fù)現(xiàn)的。為什么只是簡單的返回錯(cuò)誤碼2呢?我仔細(xì)去看了 go fuzzing 的源碼,每個(gè) fuzzing test 都是一個(gè)單獨(dú)的進(jìn)程跑的,然后 go fuzzing 把模糊測試的進(jìn)程輸出扔掉了,只是顯示了狀態(tài)碼。那么我們?nèi)绾谓鉀Q這個(gè)問題呢?
我仔細(xì)分析了之后,決定自己來寫一個(gè)類似 fuzzing test 的常規(guī)單元測試代碼,這樣就可以保證失敗是在同一個(gè)進(jìn)程內(nèi),并且會(huì)把錯(cuò)誤信息打印到標(biāo)準(zhǔn)輸出,代碼大致如下:
func TestSumFuzzRandom(t *testing.T) { const times = 100000 rand.Seed(time.Now().UnixNano()) for i := 0; i < times; i++ { n := rand.Intn(20) var vals []int64 var expect int64 var buf strings.Builder buf.WriteString("\n") for i := 0; i < n; i++ { val := rand.Int63() % 1e6 vals = append(vals, val) expect += val buf.WriteString(fmt.Sprintf("%d,\n", val)) } assert.Equal(t, expect, Sum(vals), buf.String()) } }
這樣我們就可以自己來簡單模擬一下 go fuzzing,但是任何錯(cuò)誤我們可以得到清晰的輸出。這里或許我沒研究透 go fuzzing,或者還有其它方法可以控制,如果你知道,感謝告訴我一聲。
但這種需要跑很長時(shí)間的模擬 case,我們不會(huì)希望它在 CI 時(shí)每次都被執(zhí)行,所以我把它放在一個(gè)單獨(dú)的文件里,文件名類似 sum_fuzzcase_test.go,并在文件頭加上了如下指令:
// go:build fuzz // +build fuzz
這樣我們需要跑這個(gè)模擬 case 的時(shí)候加上 -tags fuzz 即可,比如:
go test -tags fuzz ./...
復(fù)雜用法示例
上面介紹的是一個(gè)示例,還是比較簡單的,如果遇到復(fù)雜場景不知道怎么寫,可以先看看 go-zero 是如何落地 go fuzzing 的,如下所示:
MapReduce - github.com/zeromicro/g…
模糊測試了 死鎖 和 goroutine leak,特別是 chan + goroutine 的復(fù)雜場景可以借鑒
stringx - github.com/zeromicro/g…
模糊測試了常規(guī)的算法實(shí)現(xiàn),對(duì)于算法類場景可以借鑒
項(xiàng)目地址 github.com/zeromicro/g…
以上就是Go語言開發(fā)代碼自測絕佳go fuzzing用法詳解的詳細(xì)內(nèi)容,更多關(guān)于Go開發(fā)go fuzzing代碼自測的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Golang JSON的進(jìn)階用法實(shí)例講解
這篇文章主要給大家介紹了關(guān)于Golang JSON進(jìn)階用法的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家學(xué)習(xí)或者使用golang具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2018-09-09golang使用泛型結(jié)構(gòu)體實(shí)現(xiàn)封裝切片
這篇文章主要為大家詳細(xì)介紹了golang使用泛型結(jié)構(gòu)體實(shí)現(xiàn)封裝切片,即封裝切片的增、刪、改、查、長度大小、ForEach(遍歷切片),感興趣的小伙伴可以學(xué)習(xí)一下2023-10-10Go語言實(shí)現(xiàn)字符串切片賦值的方法小結(jié)
這篇文章主要給大家介紹了Go語言實(shí)現(xiàn)字符串切片賦值的兩種方法,分別是在for循環(huán)的range中以及在函數(shù)的參數(shù)傳遞中實(shí)現(xiàn),有需要的朋友們可以根據(jù)自己的需要選擇使用。下面來一起看看吧。2016-10-10Golang 函數(shù)執(zhí)行時(shí)間統(tǒng)計(jì)裝飾器的一個(gè)實(shí)現(xiàn)詳解
這篇文章主要介紹了Golang 函數(shù)執(zhí)行時(shí)間統(tǒng)計(jì)裝飾器的一個(gè)實(shí)現(xiàn)詳解,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2019-03-03