前回
Goの公式チュートリアルをやってみる【RESTful API】はじめに
前回はRESTfulなAPIを作成することができました。今回はソフトウェアテスト手法の一つであるファジングテストをGoで実行していく方法を学んでいきます。
ファジングとは、ランダムなデータをテストに対して実行して、脆弱性やクラッシュの原因となる入力を見つけようとします。ランダムなデータを入力として与えることで、予期しないエッジケースの不具合や脆弱性を見つけるのに有効な手段となります。
今回のチュートリアルではファジングに関する用語がたくさん出てきます。用語についてはこちらを参考にしてください。
テスト対象となる関数の用意
まずは作業フォルダにfuzz
というフォルダを作成しましょう。その後コマンドプロンプトでfuzz
ディレクトリに移動し、go mod init example/fuzz
コマンドを実行してモジュールの初期化をしましょう。
モジュールの初期化ができたらmain.go
ファイルを新しく作成して以下のコードを追加しましょう。
package main
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)
}
Reverse
関数は文字列を受け取り、1バイトずつループして反転させ、最後に反転した文字列を返す処理です。main
関数では文字列を初期化してから反転・再反転させ、結果を出力しています。
コードの準備ができましたら、コマンドプロンプトfuzz
ディレクトリに移動してgo run .
を実行します。以下の内容が出力されたら作成完了です。
$ 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"
単体テストの追加
続いて先ほど作成した関数をテストするメソッドを作成していきます。fuzz
ディレクトリにreverse_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
を実行して以下の内容になれば単体テストの追加は完了です。
$ go test
PASS
ok example/fuzz 0.251s
ファジングテストの追加
先ほど単体テストを追加しました。ですが、開発者がこの単体テストに入力をひたすら追加しなければならないので、ヒューマンエラー等や量の問題で限界があります。ファジングテストを利用すると、開発者が考え出したテストケースが到達しなかったエッジケースを特定することがあります。
今回は先ほど作成した単体テストをファジングテストに変換して、多くの入力を生成するようにします。
まずはreverse_test.go
の内容を以下のコードに変更しましょう。
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)
}
})
}
ファジングテストではいくつか制限があります。ファジングテストでは入力がランダムに選択されるため、期待値を予測することができません。
しかし、ファジングテストで検証できるReverse
関数の性質はいくつかあります。それは「文字列を二回反転すると元の値が保持される」、「反転された文字列はUTF-8の状態を保持する」という点です。上記ファジングテストはこの2点を確認しています。
ファジングテストの構文は単体テストとは異なります。以下の点に注意しましょう。
- 関数名は
TestXxx
ではなくFazzXxx
の様にFazzで始まる - 関数の引数は
*testing.T
ではなく*testing.F
t.Run
のような期待値チェックは*testing.T
とテスト対象となる型を引数にした関数をf.Fuzz
関数に渡して使用する- 入力は
f.Add
を使用してシードコーパスの入力とする
それでは再度go test
を実行して確認しましょう。以下の様にテストが通ればファジングテストの追加は完了となります。
$ go test
PASS
ok example/fuzz 0.238s
続いて、FuzzReverse
をファジング付きでテストを実行します。ランダムに生成された文字列入力が失敗を引き起こすかどうかを確認してみましょう。go test -fuzz=Fuzz
を実行して再度実行しましょう。
ここでは指定していませんが-fuzztime
フラグを使用することでファジングにかかる時間を制限することができます。ほかにもフラグはありますのでこちらのTesting flagsセクションを参照してください。
$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 22 workers
fuzz: elapsed: 0s, execs: 382 (7023/sec), new interesting: 2 (total: 5)
--- FAIL: FuzzReverse (0.07s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:36: Reverse produced invalid UTF-8 string "\x88\xdf00000"
Failing input written to testdata\fuzz\FuzzReverse\d4ec9bd884b46c47
To re-run:
go test -run=FuzzReverse/d4ec9bd884b46c47
FAIL
exit status 1
FAIL example/fuzz 0.355s
ファジング中に失敗が発生し、その原因となった入力が修正後のテストコードで再発されないように、testdata/fuzz/FuzzReverse
ディレクトリにあるシードコーパスファイルに書き込まれます。中身を確認すると文字列は異なりますが、書式が同じデータを確認できます。
go test fuzz v1
string("00000߈")
このファイルの一行目には『エンコーディングのバージョン』が、二行目以降には『コーパス・エントリを校正する型と値』を表します。ファズ・ターゲットは一つの入力しか受け取らないため、バージョンのあとには一つの値しかありません。
失敗したシード・コーパスのエントリーが使用されることを確認するため、-fuzz
フラグを外して再度go test
を実行してみましょう。
$ go test
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/d4ec9bd884b46c47 (0.00s)
reverse_test.go:36: Reverse produced invalid UTF-8 string "\x88\xdf00000"
FAIL
exit status 1
FAIL example/fuzz 0.212s
無効な文字列エラーを修正
失敗を確認できました。先ほどバグを見つけたのでこのままバグを修正していきましょう。
まずはどんなエラーかをチェックしていきます。筆者はVSCodeを使用しているため、こちらの方法でデバッグするようにDelve
をインストールしました。
まずはエラーが発生しているutf8.ValidString
のドキュメントを確認してみましょう。「ValidString
は、s
がすべて有効なUTF-8エンコードされたルーン文字で構成されているかどうかを報告する。」とあります。
現在のReverse
関数は文字列をバイト単位で反転させています。バイト単位で変換させると、元のUTF-8エンコードされたルーン(文字)が維持されません。反転してもUTF-8維持させるためには、ルーンごとに文字列を反転させる必要があります。
入力を反転させたときに反転した文字列のルーン数を調べることで、なぜReverse
が無効な文字列を生成するのかを知ることができます。
ファジング・ターゲットのコードを以下のように変更して、go test
でテストを実行しましょう。t.Log
関数の実行を追加しています。
ログの追加
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
t.Logf("Number of runes: orig=%d, rev=%d, doubleRev=%d", utf8.RuneCountInString(orig), utf8.RuneCountInString(rev), utf8.RuneCountInString(doubleRev))
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)
}
})
$ go test
--- FAIL: FuzzReverse (0.01s)
--- FAIL: FuzzReverse/d4ec9bd884b46c47 (0.00s)
reverse_test.go:32: Number of runes: orig=6, rev=7, doubleRev=6
reverse_test.go:37: Reverse produced invalid UTF-8 string "\x88\xdf00000"
FAIL
exit status 1
FAIL example/fuzz 0.436s
このt.Log
関数はエラーが発生した場合、コマンドに-v
を付けて実行した場合コマンドラインに実行されます。
シード・コーパス全体ではすべての文字が1バイトの文字列が使われていました。しかし今回の問題の様に複数バイトを必要とすることがあります。そのため、文字列をバイト単位で反転させると、複数バイトの文字が無効になってしまいます。
バイト単位ではなくルーン単位で文字列を処理するようにReverse
関数を以下のように修正してみましょう。
修正
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
b := []byte(s)
からr := []rune(s)
に変更して、文字列からバイトのスライスを宣言するのではなく、ルーンのスライスを宣言するように変更します。
変更したらgo test
でテストを実行しましょう。
$ go test
PASS
ok example/fuzz 0.405s
見つけたバグは修正できました。続いてgo test -fuzz=Fuzz
で新しいバグがないかを探します。何度か実行して新しいバグがないかを探してみましょう。
$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/6 completed
fuzz: minimizing 31-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 4/6 completed
--- FAIL: FuzzReverse (0.15s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:32: Number of runes: orig=1, rev=1, doubleRev=1
reverse_test.go:34: Before: "\xe0", after: "�"
Failing input written to testdata\fuzz\FuzzReverse\828d5059791d0353
To re-run:
go test -run=FuzzReverse/828d5059791d0353
FAIL
exit status 1
FAIL github.com/Yamase7/go-tutorial/fuzz 0.543s
新しくエラーが発生しました。続けて修正していきましょう。
再反転処理のエラーを修正
ログの追加
今回はデバッガを使用しながら修正していきます。反転した文字列をよく見てみましょう。GoではstringにはUTF-8ではないバイトを含めることもできます。今回は元の文字列が\xe0
という1バイトのスライスになりました。入力文字列を[]rune
に設定すると、GoはバイトスライスをUTF-8にエンコードして、そのバイトをUTF-8に置き換えます。置き換えた文字を入力のバイトスライスを比較すると異なることが分かります。
以下の様にログを追加してみましょう。
func Reverse(s string) string {
fmt.Printf("input: %q\n", s)
r := []rune(s)
fmt.Printf("runes: %q\n", r)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
コードを追加したらテストを実行します。今回はログを確認するだけなので失敗したテストだけを実行するだけで構いません。特定のコーパスエントリーを実行するには-run
でファイル名を指定することで、指定したものが実行されます。
以下の様にパスを指定して失敗したテストだけ再実行してみましょう。
$ go test -run=FuzzReverse/828d5059791d0353
input: "\xe0"
runes: ['�']
input: "�"
runes: ['�']
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/828d5059791d0353 (0.00s)
reverse_test.go:32: Number of runes: orig=1, rev=1, doubleRev=1
reverse_test.go:34: Before: "\xe0", after: "�"
FAIL
exit status 1
FAIL github.com/Yamase7/go-tutorial/fuzz 0.405s
ログを確認することができました。問題が確認出来たらそのまま修正していきます。
修正
入力する文字列にUTF-8ではない文字列が含まれる場合はエラーを返すように、エラー処理を追加しましょう。
その際使用するパッケージが増えるのでimport
文で対象のパッケージを追加します。また、エラーを返すように戻り値も増やしたので、main
関数にも変更を加えています。
package main
import (
"errors"
"fmt"
"unicode/utf8"
)
func main() {
input := "The quick brown fox jumped over the lazy dog"
rev, revErr := Reverse(input)
doubleRev, doubleRevErr := Reverse(rev)
fmt.Printf("original: %q\n", input)
fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}
func Reverse(s string) (string, error) {
if !utf8.ValidString(s) {
return s, errors.New("input is not valid UTF-8")
}
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r), nil
}
続いてエラーが発生した場合はテストをスキップするように、テスト側のコードも変更していきましょう。
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, err1 := Reverse(orig)
if err1 != nil {
return
}
doubleRev, err2 := Reverse(rev)
if err2 != nil {
return
}
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)
}
})
}
コードの変更が終わったら実行確認をしてみましょう。まずはgo test
で修正できたかを確認していきます。
$ go test
PASS
ok github.com/Yamase7/go-tutorial/fuzz 0.410s
続いて新しくエラーが発生しないかをgo test -fazz=Fazz
確認していきます。ファズ・テストは失敗する入力が見つかるまで繰り返されます。デフォルトでは失敗しなければ永遠に実行されてしまうので、数秒経過したらctrl+C
で処理を中断しましょう。
$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/6 completed
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 22 workers
fuzz: elapsed: 3s, execs: 917816 (304872/sec), new interesting: 34 (total: 40)
fuzz: elapsed: 6s, execs: 2181917 (422455/sec), new interesting: 36 (total: 42)
fuzz: elapsed: 9s, execs: 3762398 (526440/sec), new interesting: 36 (total: 42)
fuzz: elapsed: 10s, execs: 4161332 (489941/sec), new interesting: 36 (total: 42)
PASS
ok github.com/Yamase7/go-tutorial/fuzz 10.324s
失敗されずに続いたので中断することができました。-fuzztime
を使用すると指定時間経過後、自動的に停止させることも可能です。go test -fuzz=Fuzz -fuzztime 30s
を実行して30秒待機してみましょう。
$ go test -fuzz=Fuzz -fuzztime 30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/42 completed
fuzz: elapsed: 0s, gathering baseline coverage: 42/42 completed, now fuzzing with 22 workers
fuzz: elapsed: 3s, execs: 1231558 (409251/sec), new interesting: 3 (total: 45)
fuzz: elapsed: 6s, execs: 2226952 (332333/sec), new interesting: 3 (total: 45)
fuzz: elapsed: 9s, execs: 3642961 (470944/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 12s, execs: 5192335 (518124/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 15s, execs: 6726389 (511227/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 18s, execs: 8183006 (485361/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 21s, execs: 9650622 (488543/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 24s, execs: 11111394 (488066/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 27s, execs: 12557149 (480219/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 30s, execs: 14042048 (496737/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 30s, execs: 14042048 (0/sec), new interesting: 4 (total: 46)
PASS
ok github.com/Yamase7/go-tutorial/fuzz 30.683s
自動的に止まったことを確認できたでしょうか。ほかにも様々なフラグが用意されているので、詳細はドキュメントで確認してください。
ファジング・テストに関する用語はこちらのドキュメントを確認しましょう。今回発生したnew interesting
はカバレッジの範囲が広がったことを表します。基本的には最初に広がり、収束していくような傾向がみられます。
まとめ
- ファジング・テストとはランダムな入力を自動的に生成・入力するテスト。
- 想定外のエッジケースのバグを見つけ出すのが目的で利用されることが多い。
- テストの書き方。
- 関数名は
TestXxx
ではなくFazzXxx
の様にFazzで始まる。 - 関数の引数は
*testing.T
ではなく*testing.F
。 t.Run
のような期待値チェックは*testing.T
とテスト対象となる型を引数にした関数をf.Fuzz
関数に渡して使用する。- 入力は
f.Add
を使用してシードコーパスの入力とする。
- 関数名は
- ファジング・テストで見つかったエラーが出るシードコーパスは
testdata/fuzz/FuzzReverse
のシードコーパスファイルに出力される。 - 基本的にはエラーが出るまで継続されるのでテスト時間を設定する。