Go Fuzzingによるファジングテスト/ランダムテスト

Goは標準のテストフレームワークでファジングをサポートしています。今回はそのファジング機能について、テスト条件の網羅をどうするかを中心に使い方をメモします。

基本的なテストコードの書き方

Fuzzingを使ったテストコードの簡単な例を以下に示します。
テスト対象Targetを実行し、エラーが発生しないことを確認しています。Seed Corpus(ユーザ指定の入力のセット)は未指定です。

import (
	"testing"
)

func FuzzTarget(f *testing.F) {
	f.Fuzz(func(t *testing.T, i int8) {
		_, err1 := Target(i)
		if err1 != nil {
			return
		}
	})
}

大雑把に基本ルールのみに絞って説明すると、Go Testingのテストコードの記法や命名(*_test.goのファイル名にするなど)を踏襲した上で、以下のような記述を行います。

  • テストメソッドは「Fuzz」から始める
  • 実行時はgo tesetに「-fuzz」オプションを付与する
  • (*testing.F).Fuzzでテストメソッドをラップする

既に以下のような明快な解説が複数存在するため、詳細な説明は割愛します。

Go Fuzzing - The Go Programming Language
Tutorial: Getting started with fuzzing - The Go Programming Language
Go1.18から追加されたFuzzingとは | フューチャー技術ブログ

デフォルトでFuzzingが生成する入力値データ

試しに上記のコード(int8を入力)を対象に、Seed Corpus未指定のまま、2秒間Fuzzingを実行(1491回テスト実行)して得られた値の出現数を示します。

この試行を行った場合では、入力値の全パターンに対する網羅率は98%でした。割と広く網羅していますが、今回は一般的に品質リスクの高いと見なされる下限の「-128」の境界値を網羅できていませんでした。

ファジングツールはランダムデータに加えて、一般的に品質リスクの高いパターンを反映したファズデータを入力に用いることが多いです。
一方、Go Fuzzingは、テスト対象のASTを網羅するように探索するものの、ブラックボックス観点や外部の知見を使用しません。その点でいうと、Seed Corpus未指定では、Go FuzzingはHaskellのQuickCheckと同種の、ランダムテストのツールと見なしても良いと思います。

なおSeed Corpusを指定すると、Go Fuzzingはその入力セットとそれを変異させたデータを入力として生成するようになります。すなわち、ブラックボックス観点での品質リスクを使ってファジングテストとしての有効性をより高めるためには、テストコード実装者がテスト条件を分析して追加指定する必要があります。

Fuzzingで特定のテスト条件を生成させる

Fuzzingの入力値生成エンジンに特定のテスト条件を生成させるためには、そのテスト条件をデータファイルあるいは(*testing.F).Addの手段を使って、Seed Corpusとして指定します。
たとえばファジングでint8の境界値を網羅するように指定する場合、次のように記述します。

func FuzzTarget(f *testing.F) {
	condisions := []int8{127, -128}
	for _, cd := range condisions {
		f.Add(cd)
	}
	f.Fuzz(func(t *testing.T, i int8) {
		_, err1 := Target(i)
		if err1 != nil {
			return
		}
	})
}

上記のようにSeed Corpusでテスト条件を指定すると、そのテスト条件と、その一部を変異させたテスト条件を網羅するようにFuzzingが動作します。サンプルコードの場合、127、-128とその周辺値をテスト条件に用いるようになります。

テスト失敗時のテスト条件に基づいたテスト条件の生成

ファジングでテストが失敗した場合、実行ディレクトリの「testdata/fuzz/テスト名/自動生成したファイル名」に失敗時のテスト条件をSeed Corpusとして記録します。
例えば以下のテスト対象を対象にファジングを実行し、前述のファジングのテストコードでエラーを発生させてテスト失敗させてみます。

func IntTarget(i1 int, i2 int) (int, error) {
	if i2 == 1 {
		return 1, errors.New("")
	}
	return 0, nil
}

すると前述のディレクトリパスに以下のSeed Corpusファイルを生成します。

go test fuzz v1
int(-65)
int(1)

※-65はテスト失敗時のi1の入力値

以降、同じファジングテストを実行する際は、Fuzzingは記録されたテスト条件i1=-65、i2=1を網羅・頻出させるように動作します。

コードカバレッジに基づいたテスト条件の生成

Fuzzingはテスト対象のコードカバレッジが変化するテスト条件をinteresting pointとして抽出し、そのデータを用いてよりコードカバレッジを網羅するように入力値を生成させます。
例えば以下のようなテスト対象・テストコードでファジングを実行します。

//テスト対象
func IntTarget(i1 int, i2 int) (int, error) {
	if i1 == 100 && i2 == -100 {
		fmt.Print("hogefuga")
	}
	if i1 == 200 {
		fmt.Print("piyo")
	}
	return 0, nil
}
//テストコード
func FuzzIntTarget(f *testing.F) {
	f.Fuzz(func(t *testing.T, i1 int, i2 int) {
		_, err1 := IntTarget(i1, i2)
		if err1 != nil {
			t.Error("error")
			return
		}
	})
}

するとFuzzingは、i1については100と200およびその周辺値、i2については-100およびその周辺値を頻出させるように入力値を生成します。