😎

Gopher塾でGoのテストに入門した【パート3 Fuzzing編】

2022/11/21に公開

おことわり

この記事は Go Advent Calendar 2022 5日目の記事です.

また,この記事は Gopher塾でGoのテストに入門した【パート2 テーブル駆動テスト編】 の続きになっています.記事としては続いていますが,内容はこの記事の中で完結するように書いているのでこのまま読み進めていただいて大丈夫です.

そもそもFuzzing ってなに??

Fuzzing(ファジング)はあまり聞きなれない言葉ではないでしょうか.自分も1ヶ月ほど前に初めて知った手法だったのですが,めちゃくちゃざっくり説明すると 「ランダムな大量のデータでテストしてやばいケース見つけようね」 という手法です.

きちんとした定義を確認するために,GitHubのissues testing: add fuzz test supportDesign Draft: First Class Fuzzing を読んでみると,
(Design Draft: First Class Fuzzing より引用)

Fuzzing is a type of automated testing which continuously manipulates inputs to a program to find issues such as panics, bugs, or data races to which the code may be susceptible. These semi-random data mutations can discover new code coverage that existing unit tests may miss, and uncover edge-case bugs which would otherwise go unnoticed. This type of testing works best when able to run more mutations quickly, rather than fewer mutations intelligently.

と書かれており,かいつまんで日本語訳してみると・・

ファジングは自動テストの一種で・・・
(中略)
無作為に生成されたテストデータによって,既存のケースでは発見できないバグを見つけることができます.

と書いてあります.要するに質で量を担保するという考え方です.

GoでFuzzingをやってみる

簡単な例として,四則演算を評価して計算する関数 Exec を実装してみます.Exec は引数を3つとり,オペランド(演算子)と2つの整数値を与えることで式を評価し,その結果を返します.(本筋とは関係ないですが,これはスタックマシンの入門でよく実装するやつですね)実際にGoで実装してみると以下のようになります.

exec.go
package exec

func Exec(op string, a, b int) int {
    switch op {
    case "+":
        return a + b
    case "-":
        return a - b
    case "*":
        return a * b
    case "/":
        return a / b
    default:
        panic("unsupported operation: " + op)
    }
}

パターンマッチを使って簡単に実装できました.が,この関数,重大なバグが潜んでいます.簡単な例なのでこの時点でバグが発生する箇所の検討がついてしまうかもしれませんが,とりあえず先へ進みます.

Fuzzingを実装する前に,テーブル駆動テストの復習がてらこの関数に対するテーブル駆動テストを実装してみましょう.こんな感じで実装できますね.(詳細は割愛します.詳しくは パート2 の記事を参照してください)

exec_test.go
func TestExec(t *testing.T) {
    type Case struct {
        op   string
        a    int
        b    int
        want int
    }
    cases := map[string]Case{
        "add": {"+", 1, 2, 3},
        "sub": {"-", 3, 2, 1},
        "mul": {"*", 3, 2, 6},
        "div": {"/", 6, 2, 3},
    }
    for name, tt := range cases {
        t.Run(name, func(t *testing.T) {
            t.Parallel()
            if got := exec.Exec(tt.op, tt.a, tt.b); got != tt.want {
                t.Errorf("got %v, want %v", got, tt.want)
            }
        })
    }
}

さて,実際にテストを行うと確かにPASSしますね.ですが,この Exec 関数,本当に正しいのでしょうか・・・??ということで,Fuzzingを使ってエッジケースを検出してみたいと思います.

Fuzzingを実行する関数の名前は Fuzz から始める必要があります.今回の Exec 関数であれば例えば次のようにFuzzingを行う関数を実装できます.

exec_test.go
func FuzzExec(f *testing.F) {
    f.Fuzz(func(t *testing.T, op string, a, b int) {
        t.Parallel()
        switch op {
        case "+", "-", "*", "/":
            _ = exec.Exec(op, a, b)
        default:
            t.Skip("unsupported operation: " + op)
        }
    })
}

では実際に Fuzzing を実行してみましょう.ターミナルで

% go test -fuzz .

を実行して数秒〜数十秒置いてみると・・

FAIL
exit status 1
FAIL    handson/gopher_juku1/exec    0.772s

と異常終了が起こっています.さらに,このときに testdata/fuzz/関数名 というディレクトリ名が作成され,その中に異常終了したテストケースが保存されています.見てみると・・👀

go test fuzz v1
string("/")
int(89)
int(0)

はい.89 / 0 で0除算のPanicが起こっていますね.ということで今回の Exec におけるバグの発生箇所は,0除算を考慮できていなかったことに起因しています.これで元の関数をリファクタリングすることができますね.

補足/tips

fuzzing のオプション

fuzzing にはいくつかのオプションがあります.例えば,

  • -keepfuzzing : クラッシュしても継続してテストを実行するか
  • parallel : 並列でテストを実行する(許可を与える)か
  • fuzztime : テストを実行する時間(デフォルトは無限に実行する)

などがあります.CIでテストを実行する際などはこれらのオプションを指定した方が良いと思います.(keepfuzzing などは指定しないととんでもないことになりますね.)

実行時の注意

Fuzzingを長時間実行すると,CPUへの負荷が大きくなり(使用率が100%近くになります)PC内の温度がすんごい上昇します.[1] クラウド上で実行する際はあまり意識することもないでしょうが,自分のPC上で実行する際は特に気をつけてください.

まとめ

今回はFuzzingについてまとめさせて頂きました.Go1.18になって正式にサポートされた機能ということで,まだまだ知見も少ないですが,今後も色々なところで使われていくと思います(実際にいくつかのGoの標準ライブラリのテストでも既に使われているらしいです).今回は紹介しきれませんでしたが,他にも json のエンコーディング周りのバグを検出する際などにはとても便利な機能だと思います.

最後に・感想

ということで3本の記事にわたってGoのテストについてまとめてみました.ボリュームたっぷりで総字数も20000字近くとなりました.長くなってしまいましたが,最後まで読んでいただきありがとうございました!!

記事を書いていく中でも,Goならではのテスト手法やテクニックについて色々学ぶことができました.今まで技術記事はほとんど書いてこなかったけど,これからはチョコチョコ書きたいと思います!! (多分)

【追記】
この記事を公開する直前に主催のtenntennさんが Goを学ぶ有料講義を始めた話 という記事を書いていました.開催にあたっての裏話的な話が気になる方はこちらもどうぞ.

【2022/12/07追記】一部の表現が誤っていたので修正しました.

参考資料・各種リンク

脚注
  1. 自分は M1 MacBook Pro を使っていますがそうなりました ↩︎

Discussion