従来のユニットテストに潜む盲点
数年前、私は毎秒約15,000イベントを処理する社内テレメトリパイプライン向けに、カスタムペイロードパーサーをデプロイしました。テストスイートのカバレッジは94%を誇り、一般的なJSONバリアント、空のペイロード、既知の不正な入力を網羅する28個のテーブル駆動テストを含んでいました。すべてのテストをパスし、リリース準備は万全に見えました。しかしリリースからわずか40分後、runtime error: index out of range [0] パニックが大量発生し、3台のワーカーノードがクラッシュしてしまったのです。
原因は何だったのでしょうか? アップストリームのクライアントが、エスケープされていない制御バイトや末尾の区切り文字を含む不正なペイロードを時折送信していたことでした。手動で作成したテストケースでは、そのようなバイト配列をまったく想定できていなかったのです。ユニットテストは「開発者の前提」を検証してくれますが、「想像すらしていなかったエッジケース」までは検知できません。
そこで真価を発揮するのが、カバレッジ誘導型のファズテスト(Coverage-guided fuzz testing)です。ランダム化された入力を自動生成し、毎秒数千ものエッジケースでコードのストレステストを実行します。
Goネイティブファズテストの仕組み
Go 1.18以降、testing.F 型と go test -fuzz コマンドによって、ネイティブファジングが標準ツールチェーンに直接組み込まれました。go-fuzz のようなサードパーティ製ラッパーや複雑なCGOバインディングはもう不要です。
内部では、Goがコードをカバレッジ計測用のインストルメンテーション付きでコンパイルします。ファズエンジンは、開発者が提供する代表的な入力値のセットである「シードコーパス(Seed corpus)」から開始します。そして複数のワーカーゴルーチンにわたってそれらの入力を変異(ミューテーション)させ、ビット反転、バイトスライスの挿入、長さの変更などを連続して行います。
変異によって新しいコードブランチに到達するたびに、エンジンはその変異値を今後のイテレーションの新たなベースとして保持します。変異によってパニックが発生したり、テストのアサーション違反が起きたりすると、実行は即座に停止します。その後、エンジンは失敗したペイロードを再現可能な最小限の形式に縮小(最小化)し、ディスクに書き込みます。
Goファズテストの主要な概念
- ターゲット関数:
FuzzXxx(f *testing.F)というシグネチャを持つエクスポートされたテストヘルパー関数。 - シードコーパス:
f.Add(...)経由で提供される、またはtestdata/fuzz/<TargetName>ディレクトリから読み込まれる初期入力値。 - ファズターゲットループ:
f.Fuzz(func(t *testing.T, data []byte) { ... })や、string、int、float64などのプリミティブ型を受け取る内部関数。
実践: go test -fuzz でパニックを検出する
具体的な例を見ていきましょう。key=value 形式の設定行からキーと値を抽出し、[value] のように囲まれた角括弧を取り除く設定パーサーを作成します。
1. バグを含んだパーサーの実装
一見すると、この実装は問題なさそうに見え、基本的なテストもパスします:
package config
import (
"errors"
"strings"
)
// ParseOption はキーと値を抽出し、角括弧で囲まれている場合はそれを削除します。
func ParseOption(raw string) (string, string, error) {
if len(raw) == 0 {
return "", "", errors.New("入力が空です")
}
parts := strings.SplitN(raw, "=", 2)
if len(parts) != 2 {
return "", "", errors.New("代入演算子がありません")
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if len(key) == 0 {
return "", "", errors.New("キーが空です")
}
// 囲んでいる角括弧を削除: [my-val] -> my-val
if val[0] == '[' && val[len(val)-1] == ']' {
val = val[1 : len(val)-1]
}
return key, strings.TrimSpace(val), nil
}
2. ファズターゲットの作成
同じディレクトリに config_test.go を作成します。すべてのテストシナリオを手動でハードコードする代わりに、いくつかの有効・無効な文字列をシードとしてファザーに登録します:
package config
import (
"testing"
)
func FuzzParseOption(f *testing.F) {
// シードコーパスに代表的なエントリを登録
f.Add("timeout=[30s]")
f.Add("retries=3")
f.Add("cluster=[us-east-1]")
f.Add("invalid_row_without_equals")
f.Add("=")
f.Fuzz(func(t *testing.T, input string) {
key, val, err := ParseOption(input)
// パースに成功した場合、基本的な不変条件を満たす必要がある
if err == nil {
if len(key) == 0 {
t.Fatalf("入力 %q に対してエラーがnilのまま空のキーが返されました", input)
}
_ = val
}
})
}
3. ファザーの実行
ターミナルから30秒の上限を設定してファジングセッションを開始します:
go test -fuzz=FuzzParseOption -fuzztime=30s
数ミリ秒のうちにGoはワーカープロセスを立ち上げ、クラッシュを検出します:
fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 8 workers
fuzz: minimizing 2-byte failing input file...
--- FAIL: FuzzParseOption (0.02s)
--- FAIL: FuzzParseOption (0.00s)
testing.go:1450: panic: runtime error: index out of range [0] with length 0
goroutine 18 [running]:
config.ParseOption({0xc000018080, 0x1})
/workspace/config/config.go:23 +0x118
config.FuzzParseOption.func1(0xc000082360, {0xc000018080, 0x1})
/workspace/config/config_test.go:16 +0x42
Failing input written to testdata/fuzz/FuzzParseOption/7d8a9e2f...
FAIL
exit status 1
なぜパニックが発生したのでしょうか? ファザーが "a=" を渡した際、strings.SplitN は parts = ["a", ""] を返しました。val は長さ0の空文字列だったため、val[0] を参照した瞬間にランタイムパニックが引き起こされたのです。
4. リグレッション防止用成果物の理解
クラッシュが発生すると、Goはテスト成果物を testdata/fuzz/FuzzParseOption/<hash> に書き込みます:
go test fuzz v1
string("a=")
このファイルはGitリポジトリにコミットしておく必要があります。そうすることで、以降の通常の go test ./... 実行時にこのファイルが自動的に読み込まれ、標準のユニットテストとして検証されるため、ファジングを実行しなくてもリグレッション(先祖返り)を確実に防止できます。
5. ロジックの修正
直接的なインデックス参照をやめ、明示的な長さチェックと安全なヘルパー関数を使用することでパニックを解消できます:
// 安全な実装
func ParseOption(raw string) (string, string, error) {
if len(raw) == 0 {
return "", "", errors.New("入力が空です")
}
parts := strings.SplitN(raw, "=", 2)
if len(parts) != 2 {
return "", "", errors.New("代入演算子がありません")
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if len(key) == 0 {
return "", "", errors.New("キーが空です")
}
// 長さと角括弧の区切り文字を安全にチェック
if len(val) >= 2 && strings.HasPrefix(val, "[") && strings.HasSuffix(val, "]") {
val = val[1 : len(val)-1]
}
return key, strings.TrimSpace(val), nil
}
ファズターゲットを1分間再実行し、安定性を確認します:
go test -fuzz=FuzzParseOption -fuzztime=60s
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 8 workers
fuzz: elapsed: 30s, execs: 412950 (13764/sec), new interesting: 12 (total: 18)
fuzz: elapsed: 1m0s, execs: 825102 (13752/sec), new interesting: 15 (total: 21)
PASS
ok config 60.082s
本番運用チームのためのファジング戦略
1. 高リスクな境界領域を優先する
データベースクエリや単純なHTTPルーティングの登録にファジングの時間を費やす必要はありません。信頼できない外部データを取り込むコンポーネントを重点的にテストしましょう:
- バイナリおよびテキストのデシリアライザー(Protobuf、CSV、カスタムフレーミングプロトコルなど)
- セキュリティパーサー(JWTアンパッカー、ヘッダーデコーダー、API署名バリデータなど)
- 文字列変換処理、正規表現、Markdownレンダラー
- ゼロ除算や数値オーバーフローが発生しやすい数値計算処理
2. 2段階のCI/CDパイプラインを構築する
コミットごとに何時間もファジングを実行すると、プルリクエストのレビューサイクルが停滞してしまいます。テストステージを以下のように分離することをおすすめします:
- プルリクエストステージ: 通常通り
go test ./...を実行します。testdata/内にある既存のコーパスファイルが2秒未満で高速に検証されます。 - 夜間バッチステージ: オフピーク時に定期実行パイプラインで
go test -fuzz=Fuzz -fuzztime=15m ./...を実行し、パッケージ全体の新たな境界パスを探索します。
3. 静的な出力値ではなく「不変条件」を検証する
ファズ入力は予測不可能なため、特定の固定出力文字列と一致するかをチェックするのは実用的ではありません。代わりに、ソフトウェアの根本的な不変条件(Invariant)をアサーションします:
- ラウンドトリップデコード(可逆性):
Unpack(Pack(obj))はデータの欠落なく常に元のobjと等しくなること。 - 冪等性(べきとうせい):
Sanitize(Sanitize(str))はSanitize(str)と完全に同一の結果を出力すること。 - グレースフルデグラデーション(安全なエラー処理): 不正なデータに対しては適切に構造化された
errorを返し、プロセスを決してクラッシュさせないこと。
まとめ
Goサービスへの go test -fuzz の導入は、わずか数行のコードを追加するだけで完了します。テストターゲットに実際の入力値をシードとして登録し、重大な変更をリリースする前にローカルでファザーを実行し、生成されたコーパスファイルをバージョン管理にコミットしましょう。これは、本番環境でのクラッシュからコードベースを保護するための、最も費用対効果の高い手法の1つです。

