🙆

GoのFuzzingテストやってみる

2023/03/21に公開

最近出たこちらの本を読んでいたら、Fuzzingテストという機能を知ったので、やってみました
https://amzn.asia/d/7qUcj3N

以下のチュートリアルを見ながら触ってみます
https://go.dev/doc/tutorial/fuzz

実際に触ってみたリポジトリはこちら
https://github.com/10inoino/go-fuzzing-test-demo

通常のユニットテスト

チュートリアルでは、文字列を逆転させて吐き出す関数を例に出しています

main.go
package main

import "fmt"

func main() {
	input := "The quick brown fox jumped over the lazy dog"
	rev := Reverse(input)
	doubleRev := Reverse(rev)
	fmt.Printf("original: %q\n", input)
	fmt.Printf("reversed: %q\n", rev)
	fmt.Printf("reversed again: %q\n", doubleRev)
}

func Reverse(s string) string {
	b := []byte(s)
	for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
		b[i], b[j] = b[j], b[i]
	}
	return string(b)
}

実行してみると、アルファベットでは問題なく動いているようです。

 $ go run .
original: "The quick brown fox jumped over the lazy dog"
reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"
reversed again: "The quick brown fox jumped over the lazy dog"

ユニットテストを書いてみます。

main_test.go
package main

import (
	"testing"
)

func TestReverse(t *testing.T) {
	testcases := []struct {
		in, want string
	}{
		{"Hello, world", "dlrow ,olleH"},
		{" ", " "},
		{"!12345", "54321!"},
	}
	for _, tc := range testcases {
		rev := Reverse(tc.in)
		if rev != tc.want {
			t.Errorf("Reverse: %q, want %q", rev, tc.want)
		}
	}
}

自前で用意したテストコードは、全てクリアしているようです

 $ go test -run TestReverse
PASS
ok      10inoino/fizzing-test-demo      0.106s

Fuzzingテストを実装

しかし、文字列はアルファベットだけではありません。
本来様々な文字列で検証しなければいけませんが、他の文字列のパターンを調べてテストケースとして追加するのも難しい。

そんなときにはFuzzingテストを使ってみましょう。

Fuzzingテストを実装します。

main_test.go
package main

import (
	"testing"
	"unicode/utf8"
)

func TestReverse(t *testing.T) {
  // ・・・
}

func FuzzReverse(f *testing.F) {
	testcases := []string{"Hello, world", " ", "!12345"}
	for _, tc := range testcases {
		f.Add(tc) // Use f.Add to provide a seed corpus
	}
	f.Fuzz(func(t *testing.T, orig string) {
		rev := Reverse(orig)
		doubleRev := Reverse(rev)
		if orig != doubleRev {
			t.Errorf("Before: %q, after: %q", orig, doubleRev)
		}
		if utf8.ValidString(orig) && !utf8.ValidString(rev) {
			t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
		}
	})
}

Fuzzingテストのルールは以下のとおりです。
(https://go.dev/security/fuzz/ より引用)

  • ファズテストはFuzzXxxのような名前の関数でなければならず、*testing.Fだけを受け取り、戻り値はない。
  • Fuzzテストは、*_test.goファイルでないと実行できません。
  • ファズターゲットは、最初のパラメータとして*testing.Tを受け入れ、ファジング引数が続く(*testing.F).Fuzzへのメソッド呼び出しでなければなりません。戻り値はありません。
  • ファズテストにつき、ファズターゲットは正確に1つでなければならない。
  • これは、(*testing.F).Addの呼び出しと、ファズテストのtestdata/fuzzディレクトリ内のすべてのコーパスファイルに対して当てはまります。
  • ファジングの引数は、以下の型のみです。
    • string,[]byte
    • int,int8,int16,int32/rune,int64
    • uint、uint8/ byte、uint16、uint32、uint64
    • float32、float64
    • bool

Fuzzingテストを実行してみます。
-fuzz=のあとに、正規表現でテストする関数が指定できるようです。

 $ go test -fuzz=Fuzz      
fuzz: elapsed: 0s, gathering baseline coverage: 0/14 completed
failure while testing seed corpus entry: FuzzReverse/a7e3a6205b275a47
fuzz: elapsed: 0s, gathering baseline coverage: 5/14 completed
--- FAIL: FuzzReverse (0.03s)
    --- FAIL: FuzzReverse (0.00s)
        main_test.go:36: Reverse produced invalid UTF-8 string "\x91\xdd"
    
FAIL
exit status 1
FAIL    10inoino/fizzing-test-demo      0.303s

テストが失敗しました。
エラーメッセージに失敗した文言を出力することもできますが、testdata配下に失敗したテストデータが格納されているので、こちらを参照しても良いでしょう。

testdata/fuzz/FuzzReverse/a7e3a6205b275a47
go test fuzz v1
string("ݑ")

今回は、ݑでテストが実行されていたようです。
試しに、FuzzReverseディレクトリごと削除して、再度Fuzzingテストを実行すると、他のテストデータでもテストが失敗することがわかります。

testdata/fuzz/FuzzReverse/d8b27a9830e81c8b
go test fuzz v1
string("\ue104")

1つ注意点として、Fuzzingテストはテストが失敗するまで実行し続けるので、タイムアウトの時間を設定して実行するのが良いでしょう。

go test -fuzz=Fuzz -fuzztime 10s

バリデーションの確認などで、とにかく様々な種類の引数をテストしてみたいという場合に、すごく便利ですね👏

Discussion