Fuzzing で Go の関数に想定外の入力が渡された際のバグを見つける

golang

Fuzzing はランダムなデータを入力とするテスト手法で、Go では 1.18 からサポートされている。

次の FizzBuzz 関数をテストする。

func FizzBuzz(n uint64) string {
	if n%3 == 0 {
		if n%5 == 0 {
			return "FizzBuzz"
		}
		return "Fizz"
	}

	if n%5 == 0 {
		return "Buzz"
	}

	return strconv.Itoa(int(n))
}

まずはテーブルドリブンテストを行う。

func TestFizzBuzz(t *testing.T) {
	testcases := []struct {
		title string
		in    uint64
		out   string
	}{
		{"A multiple of 3 returns Fizz", 3, "Fizz"},
		{"A multiple of 5 returns Buzz", 5, "Buzz"},
		{"A multiple of 3 and 5 returns FizzBuzz", 15, "FizzBuzz"},
		{"Otherwise returns number string", 1, "1"},
	}

	for _, testcase := range testcases {
		t.Run(testcase.title, func(t *testing.T) {
			ret := FizzBuzz(testcase.in)
			if ret != testcase.out {
				t.Errorf("FizzBuzz(%d) = %s, want %s", testcase.in, ret, testcase.out)
			}
		})
	}
}

実行すると全てのテストケースを通過するので問題ないと言いたいところだが、実はこの関数にはバグがある。

$ go test
PASS
ok      github.com/sambaiz/gofuzztest   0.093s

そもそも想定外の入力に由来するバグの場合テストケースからも漏れてしまうことが多く、また、入力のパターンが多いと網羅し切れないこともある。 そのような場合、Fuzzing がバグ発見の助けになるかもしれない。なお今のところ map や struct といった型には対応していない。

func FuzzFizzBuzz(f *testing.F) {
	// f.Add(9223372036854775807)
	f.Fuzz(func(t *testing.T, n uint64) {
		ret := FizzBuzz(n)
		if ret != strconv.FormatUint(n, 10) &&
			ret != "Fizz" &&
			ret != "Buzz" &&
			ret != "FizzBuzz" {
			t.Errorf("FizzBuzz(%d) = %s, want %s, Fizz, Buzz, or FizzBuzz",
				n, ret, strconv.FormatUint(n, 10))
		}
	})
}

実行するとカバレッジを上げる interesting な入力を探索し始める。結果、入力がとても大きいときに期待しない値が返ることがわかった。

$ go test -fuzz=FuzzFizzBuzz -fuzztime 10s
fuzz: elapsed: 0s, gathering baseline coverage: 0/8 completed
fuzz: elapsed: 0s, gathering baseline coverage: 8/8 completed, now fuzzing with 8 workers
fuzz: elapsed: 0s, execs: 16 (143/sec), new interesting: 0 (total: 8)
--- FAIL: FuzzFizzBuzz (0.11s)
    --- FAIL: FuzzFizzBuzz (0.00s)
        main_test.go:39: FizzBuzz(9223372036854775873) = -9223372036854775743, want 9223372036854775873, Fizz, Buzz, or FizzBuzz
    
    Failing input written to testdata/fuzz/FuzzFizzBuzz/b0764ff8575cbc59
    To re-run:
    go test -run=FuzzFizzBuzz/b0764ff8575cbc59
FAIL
exit status 1
FAIL    github.com/sambaiz/gofuzztest   0.323s

これは uint64 を int(64) に変換しているのが原因だ。修正した後、失敗した入力の corpus ファイルで再度テストすることができる。

$ cat testdata/fuzz/FuzzFizzBuzz/b0764ff8575cbc59
go test fuzz v1
uint64(9223372036854775873)

$ go test -run=FuzzFizzBuzz/b0764ff8575cbc59

この corpus ファイルや f.Add() によって seed を与えることで baseline coverage が上がり効率的な探索が行われるようになるようだ。