善用 Go Fuzzing,幫助你寫出更完整的單元測試

Larry Lu
Starbugs Weekly 星巴哥技術專欄
10 min readOct 31, 2022

--

前陣子小弟我有幸收到 MOPCON 的邀約,去擔任今年的年會講者,分享的題目就是 Go 在今年三月推出的新 feature — Fuzzing Test(模糊測試),是一種跟單元測試截然不同的測試方式。

為了讓更多人知道 Fuzzing 這個有趣的新功能(以及拯救我快要沒靈感的專欄XD),今天這篇文章會用在 MOPCON 上講到的幾個例子,一步一步帶大家認識 Go Fuzzing

Unit Testing

首先介紹一下今天要講的例子:就只是一個非常簡單的 pow(x, y) 函數,內部實作是用遞迴來計算 x 的 y 次方,就算沒寫過 Go 應該也看得懂~

那如果我們要幫這個 pow(x, y) 寫單元測試,可能會怎麼寫呢?

無非就是找幾個簡單的例子測試看看(下圖),譬如說「1⁵ 是 1」、「2⁰ 是 1」、「2¹⁰ 是 1024」等等。如果這幾個例子都沒問題,那我們就相信這個 pow(x, y) 應該沒有寫錯。

但我們重新思考一下,像這樣的單元測試可能有怎麼樣的問題:

首先是「需要幾個 test case 才能夠確保正確性?」,像上圖的單元測試只測了四組 input/output,如果這樣就說 pow(x, y) 的實作是完全正確的,顯然有點沒說服力XD。

再來是,通常我們在幫自己的程式寫單元測試時,並不容易想到各種 edge case,所以很常會發生「我之前測都沒問題」但是到使用者那邊就因為奇怪的輸入跟行為而炸掉。

Fuzzing Test

而 Go 在版本 1.18 推出的 Fuzzing 正好彌補了單元測試不足的部分。Fuzzing 是一種自動化的測試技術,他會不斷不斷的丟各種隨機生成的 input 給你,讓你拿去做測試。

下面這段程式碼,就是請 Go 的 Fuzzing engine 每次都幫我們隨機生成一個 unsigned int 叫做 x(第二行),接著用這個隨機生成的 x 去做 assert。但這邊有個跟單元測試很不一樣的地方:因為我們並不知道 x 是多少(畢竟是隨機生的嘛),所以我們必須在不知道 x 是多少的情況下去寫 assert

雖然聽起來有點荒謬,連輸入都不知道那我是要測個鬼啊!?但其實是可以的哦,只是要用一些旁敲側擊的方式去寫 assert。譬如說我們知道「不管 x 是多少,pow(x, 0) 一定等於 1(第五行)」、「不管 x 是多少,pow(x, 1) 一定等於 x 本身」。

用這樣的方式,我們就可以在「不知道 x 是多少」的前提下,去驗證 pow(x, y) 有沒有寫錯,如果在某些特殊情況下 pow(x, 0) 算出來不等於 1,那就代表 pow(x, y) 鐵定是寫錯了嘛!

寫完 Fuzzing Test 後馬上到終端機下 go test -fuzz=Fuzz -fuzztime 20s 跑跑看,因為 Fuzzing engine 會不斷生成隨機的輸入,所以要限制他跑 20 秒就好,不然他就會一直跑一直跑直到找到錯為止~

仔細看一下跑的結果,這 20 秒的時間他執行了 1044100 次的測試(倒數第三行),也就是 Fuzzing engine 總共生成了一百多萬個隨機的 x,然後丟進去我們寫的三個 assert 做驗證,最後都順利 PASS 了 🎉 🎉 🎉

More Fuzzing Test

但光是測這三個簡單的 assert 好像沒什麼說服力,所以接著我們來加一些更複雜的條件。我們想要測試的是:當隨機的 x 跟 y 都大於零時,「x 的 y 次方再除以 x」一定要等於「x 的 y-1 次方」。這應該滿好理解的,就像 2¹⁰/2 就是 2⁹ 也就是 512。

這個 assert 聽起來聽經地義,而且也是會寫在國中課本上的東西,實際跑跑看卻不知為何 FAIL 了,難道是以前數學課都教錯了嗎?

在做 Fuzzing Test 的時候如果跑一跑 FAIL 了,Go 會幫忙把那組 input 記在 testcase/ 裡面,所以先別急著下定論,我們來看一下到底是怎麼樣的 x 跟 y 會讓這個 assert 失敗。

看了之後會發現在 x=6、y=30 時 assert 會失敗,也就是說 pow(6, 30)/6 不會等於 pow(6, 29)。但這也太奇怪了吧?仔細實驗之後才發現是因為在計算 pow(6, 30) 的時候會發生 overflow。

因為 Go 定義的 max.MaxUint 大約是 18 * 10¹⁸,但 6²⁹ 大概是 7 * 10¹⁸。如果把 6²⁹ 再乘上 6,就會發生 overflow 得到 8 * 10¹⁸,很像繞了操場兩圈結果在跟原本差不多的位置。

所以如果把 overflow 過的 6³⁰ 拿去除以 6,就會跟 6²⁹ 不一樣。這就是 Fuzzing 幫我們找到的 edge case,也是我們當初沒想到的。

Improved Pow

仔細想想,pow(x, y) 在計算的過程中會不會發生 overflow 完全取決於使用者的輸入是多少。因此我們不可能避免 overflow,但至少我們能在 overflow 發生時讓使用者知道,而不是已經乘到 overflow 還繼續裝沒事,這樣可能有天會導致我們想都沒想過的 bug。

依循這個想法,我們可以幫 pow 多加一個 error type 的回傳值,如果在計算的過程中不幸發生 overflow,就可以回傳 ErrOverflow,讓使用者自己決定要怎麼處理。

既然 pow(x, y) 的實作改了,那測試當然也要改一下:單元測試的部分除了驗證結果之外,還要檢查看看有沒有錯誤,像 pow(2, 10) 就不應該有錯誤、而 pow(6, 30) 則會得到 ErrOverflow

下面的 Fuzzing Test 也是,如果在計算過程中已經發生 overflow,算出來的結果自然是錯誤的,所以我們就不去跑 assert 了。但如果沒發生 overflow,那該做的 assert 還是要做。

處理完 overflow 的問題之後再跑一次 Fuzzing Test,馬上就過了~

這邊來回顧一下,在整個測試 pow(x, y) 的過程中,Fuzzing 最大的貢獻就是幫我們找出「pow(6, 30) / 6 不等於 pow(6, 29)」這個 edge case,從而讓我們去思考當 overflow 發生時,是不是應該要 return 一個 ErrOverflow 讓使用者去處理。真的很不錯~

Comparison

到這邊如果都有看懂的話,不難發現 Unit Test 跟 Fuzzing 在測試的方式很不一樣:首先是 Unit Test 就是固定那幾個 test case,而 Fuzzing 是每次會不斷給你隨機生成的 input 去測試,因此更容易找到 edge case,這是 Fuzzing 的優點。

再來是當我們在做 Unit Test 時,我們都是直接驗證答案是多少,譬如說我們知道「pow(2, 10) 就是 1024」,所以就直接 assert(pow(2, 10), 1024)

但在做 Fuzzing Test 的時候,我們根本不知道輸入的 x、y 是多少,所以我們只能在不知道輸入的情況下,用答案的性質(property)或是數學定理來 assert,譬如說「任何數的 0 次方都是 1」、「x 的 y 次方再除上 x 會變成 x 的 (y-1) 次方」這樣拐彎抹角的方式,所以寫起來會比較困難、迂迴一點。

Trophies

因為今天講的 pow 滿簡單的,大家可能還感覺不到 Fuzzing Test 的威力,這邊帶大家來看一下 Fuzzing 曾經發現過怎麼樣的 bug。

compile: hangs converting int const to complex64

首先這個例子是,Go 1.6 的編譯器在編譯下面這個程式時(沒錯就只有兩行)會直接卡住。通常編譯器的工作就是要把正確的程式碼編譯成執行檔、若是程式碼有錯則是要顯示編譯失敗

但上面這個程式一編譯下去就「卡住了」,沒錯就是卡住XD,可能在什麼地方進入了無窮迴圈或是 deadlock,所以一動也不動。

constant: hang evaluating “-6e-1886451601”

而另外下面這個例子是在 Go 1.9 中,雖然通過編譯了,但程式只要一跑下去就會直接當掉,永遠沒有結束的一天。但我不過是想宣告一個常數而已…怎麼就掛了QQ

而這兩個例子的共通點是:雖然他們的程式碼很短,但裡面都有一些怪怪的數字。因此如果不利用 Fuzzing 丟隨機的 input 進去跑,光靠人為方式是很難找到這種 bug 的,也再次驗證了 Fuzzing 在找 edge case 上非常厲害。

如果有興趣的話,go-fuzz#trophies 這邊也可以看到更多 Go Fuzz 曾經發現過的問題,到目前為止已經在 Go compiler 跟內建 library 發現超過四百個 bug,真的很厲害哦~

Summary

雖然平常不容易遇到,但當很多 edge case 層層疊加起來是有可能造成安全漏洞的,像幾年前的新聞〈果粉快注意!一封簡訊就可搞掛你的 iPhone〉就是在說只要用簡訊傳一段怪怪的英文 + 阿拉伯文(?) + 中文給那些愛炫耀新 iPhone 的朋友,對方的手機就會馬上關機而且簡訊 App 再也打不開。

像這類出現在 Application Level 的攻擊很多都是透過觸發底層 lib 的 edge case 來達到的,因此平常在寫這些 library 時也必須格外小心。

最後想補充一下,Fuzzing Test 雖然是比較新的東西,但他並不是用來取代 Unit Test 的,他只是利用大量隨機的輸入來幫你找到可能有問題的 edge case。

找到 edge case 後你還是要把他們加進單元測試裡面,讓你的單元測試變得更健壯,就如同這篇的標題說的「善用 Go Fuzzing,幫助你寫出更完整的單元測試」,因此兩者沒有誰強誰弱,是相輔相成的關係哦~

--

--

Larry Lu
Starbugs Weekly 星巴哥技術專欄

我是 Larry 盧承億,傳說中的 0.1 倍工程師。我熱愛技術、喜歡與人分享,專長是 JS 跟 Go,平常會寫寫技術文章還有參加各種技術活動,歡迎大家來找我聊聊~