特別說明:這個真的不是標題黨,我寫程式碼20+年,真心認為
go fuzzing
是我見過的最牛逼的程式碼自測方法。我在用AC自動機
演算法改進關鍵字過濾效率(提升~50%),改進mapreduce
對panic
的處理機制的時候,都通過go fuzzing
發現了邊緣情況的 bug。所以深深的認為,這是我見過最牛逼的程式碼自測方法,沒有之一!
go fuzzing
至今已經發現了程式碼質量極高的Go
標準庫超過200個bug,見:https://github.com/dvyukov/go-fuzz#trophies
春節程式設計師之間的祝福經常是,祝你程式碼永無 bug!雖然調侃,但對我們每個程式設計師來說,每天都在寫 bug,這是事實。程式碼沒 bug 這事,只能證偽,不能證明。即將釋出的 Go 1.18 官方提供了一個幫助我們證偽的絕佳工具 - go fuzzing
。
Go 1.18 大家最關注的是泛型,然而我真的覺得 go fuzzing
真的是 Go 1.18 最有用的功能,沒有之一!
本文我們就來詳細看看 go fuzzing:
- 是什麼?
- 怎麼用?
- 有何最佳實踐?
首先,你需要升級到 Go 1.18
Go 1.18 雖然還未正式釋出,但你可以下載 RC 版本,而且即使你生產用 Go 更早版本,你也可以開發環境使用 go fuzzing 尋找 bug
go fuzzing 是什麼
根據 官方文件 介紹,go fuzzing
是通過持續給一個程式不同的輸入來自動化測試,並通過分析程式碼覆蓋率來智慧的尋找失敗的 case。這種方法可以儘可能的尋找到一些邊緣 case,親測確實發現的都是些平時很難發現的問題。
go fuzzing 怎麼用
官方介紹寫 fuzz tests 的一些規則:
-
函式必須是 Fuzz開頭,唯一的引數是
*testing.F
,沒有返回值 -
Fuzz tests 必須在
*_test.go
的檔案裡 -
上圖中的
fuzz target
是個方法呼叫(*testing.F).Fuzz
,第一個引數是*testing.T
,然後就是稱之為fuzzing arguments
的引數,沒有返回值 -
每個
fuzz test
裡只能有一個fuzz target
-
呼叫
f.Add(…)
的時候需要引數型別跟fuzzing arguments
順序和型別都一致 -
fuzzing arguments
只支援以下型別:string
,[]byte
int
,int8
,int16
,int32
/rune
,int64
uint
,uint8
/byte
,uint16
,uint32
,uint64
float32
,float64
bool
-
fuzz target
不要依賴全域性狀態,會並行跑。
執行 fuzzing tests
如果我寫了一個 fuzzing test
,比如:
// 具體程式碼見 https://github.com/zeromicro/go-zero/blob/master/core/mr/mapreduce_fuzz_test.go
func FuzzMapReduce(f *testing.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: 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
終止了測試,詳細解釋參考官方文件。
go-zero 的最佳實踐
按照我使用下來的經驗總結,我把最佳實踐初步總結為以下四步:
- 定義
fuzzing arguments
,首先要想明白怎麼定義fuzzing arguments
,並通過給定的fuzzing arguments
寫fuzzing target
- 思考
fuzzing target
怎麼寫,這裡的重點是怎麼驗證結果的正確性,因為fuzzing arguments
是“隨機”給的,所以要有個通用的結果驗證方法 - 思考遇到失敗的 case 如何列印結果,便於生成新的
unit test
- 根據失敗的
fuzzing test
列印結果編寫新的unit test,這個新的
unit test會被用來除錯解決
fuzzing test發現的問題,並固化下來留給
CI用
接下來我們以一個最簡單的陣列求和函式來展示一下上述步驟,go-zero 的實際案例略顯複雜,文末我會給出 go-zero 內部落地案例,供大家參考複雜場景寫法。
這是一個注入了 bug 的求和的程式碼實現:
func Sum(vals []int64) int64 {
var total int64
for _, val := range vals {
if val%1e5 != 0 {
total += val
}
}
return total
}
1. 定義 fuzzing arguments
你至少需要給出一個 fuzzing argument
,不然 go fuzzing
沒法生成測試程式碼,所以即使我們沒有很好的輸入,我們也需要定義一個對結果產生影響的 fuzzing argument
,這裡我們就用 slice 元素個數作為 fuzzing arguments
,然後 Go fuzzing
會根據跑出來的 code coverage
自動生成不同的引數來模擬測試。
func FuzzSum(f *testing.F) {
f.Add(10)
f.Fuzz(func(t *testing.T, n int) {
n %= 20
...
})
}
這裡的 n
就是讓 go fuzzing
來模擬 slice 元素個數,為了保證元素個數不會太多,我們限制在20以內(0個也沒問題),並且我們新增了一個值為10的語料(go fuzzing
裡面稱之為 corpus
),這個值就是讓 go fuzzing
冷啟動的一個值,具體為多少不重要。
2. 怎麼寫 fuzzing target
這一步的重點是如何編寫可驗證的 fuzzing target
,根據給定的 fuzzing arguments
寫出測試程式碼的同時,還需要生成驗證結果正確性用的資料。
對我們這個 Sum
函式來說,其實還是比較簡單的,就是隨機生成 n
個元素的 slice,然後求和算出期望的結果。如下:
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
求和做比較而已,就不詳細解釋了。但複雜場景你就需要仔細想想怎麼寫驗證程式碼了,不過這不會太難,太難的話,可能是對測試函式沒有足夠理解或者簡化。
此時就可以用如下命令跑 fuzzing tests
了,結果類似如下:
$ 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
那麼問題來了!我們看到了結果不對,但是我們很難去分析為啥不對,你仔細品品,上面這段輸出,你怎麼分析?
3. 失敗 case 如何列印輸入
對於上面失敗的測試,我們如果能列印出輸入,然後形成一個簡單的測試用例,那我們就可以直接除錯了。列印出來的輸入最好能夠直接 copy/paste
到新的測試用例裡,如果格式不對,對於那麼多行的輸入,你需要一行一行調格式就太累了,而且這未必就只有一個失敗的 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())
})
}
再跑命令,得到如下結果:
$ 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. 編寫新的測試用例
根據上面的失敗 case 的輸出,我們可以 copy/paste
生成如下程式碼,當然框架是自己寫的,輸入引數可以直接拷貝進去。
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))
}
這樣我們就可以很方便的除錯了,並且能夠增加一個有效 unit test
,確保這個 bug 再也不會出現了。
go fuzzing
更多經驗
Go 版本問題
我相信,Go 1.18 釋出了,大多數專案線上程式碼不會立馬升級到 1.18 的,那麼 go fuzzing
引入的 testing.F
不能使用怎麼辦?
線上(go.mod)不升級到 Go 1.18,但是我們本機是完全推薦升級的,那麼這時我們只需要把上面的 FuzzSum
放到一個檔名類似 sum_fuzz_test.go
的檔案裡,然後在檔案頭加上如下指令即可:
//go:build go1.18
// +build go1.18
注意:第三行必須是一個空行,否則就會變成
package
的註釋了。
這樣我們線上上不管用哪個版本就不會報錯了,而我們跑 fuzz testing
一般都是本機跑的,不受影響。
go fuzzing 不能復現的失敗
上面講的步驟是針對簡單情況的,但有時根據失敗 case 得到的輸入形成新的 unit test
並不能復現問題時(特別是有 goroutine 死鎖問題),問題就變得複雜起來了,如下輸出你感受一下:
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
卡住了或者不正常結束了,狀態碼是2。這種情況下,一般 re-run
是不會復現的。為什麼只是簡單的返回錯誤碼2呢?我仔細去看了 go fuzzing
的原始碼,每個 fuzzing test
都是一個單獨的程式跑的,然後 go fuzzing
把模糊測試的程式輸出扔掉了,只是顯示了狀態碼。那麼我們如何解決這個問題呢?
我仔細分析了之後,決定自己來寫一個類似 fuzzing test
的常規單元測試程式碼,這樣就可以保證失敗是在同一個程式內,並且會把錯誤資訊列印到標準輸出,程式碼大致如下:
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
,但是任何錯誤我們可以得到清晰的輸出。這裡或許我沒研究透 go fuzzing
,或者還有其它方法可以控制,如果你知道,感謝告訴我一聲。
但這種需要跑很長時間的模擬 case,我們不會希望它在 CI 時每次都被執行,所以我把它放在一個單獨的檔案裡,檔名類似 sum_fuzzcase_test.go
,並在檔案頭加上了如下指令:
//go:build fuzz
// +build fuzz
這樣我們需要跑這個模擬 case 的時候加上 -tags fuzz
即可,比如:
go test -tags fuzz ./...
複雜用法示例
上面介紹的是一個示例,還是比較簡單的,如果遇到複雜場景不知道怎麼寫,可以先看看 go-zero 是如何落地 go fuzzing
的,如下所示:
- MapReduce - https://github.com/zeromicro/go-zero/tree/master/core/mr
- 模糊測試了 死鎖 和 goroutine leak,特別是
chan + goroutine
的複雜場景可以借鑑
- 模糊測試了 死鎖 和 goroutine leak,特別是
- stringx - https://github.com/zeromicro/go-zero/tree/master/core/stringx
- 模糊測試了常規的演算法實現,對於演算法類場景可以借鑑
專案地址
https://github.com/zeromicro/go-zero
歡迎使用 go-zero
並 star 支援我們!
微信交流群
關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維碼。