フューチャー技術ブログ

Goでio.Readerをラップしてio.ReadSeekerを作る関数を作った & プロパティベーステストで徹底的にテスト


Goアドベントカレンダーその5の穴埋めです。

Goではインタフェースをうまく利用することで、ライブラリの柔軟性が大幅にあがります。ライブラリでデータの読み込みをos.Fileではなくて、io.Readerで行うようにすれば、メモリの情報を直接bytes.Readerstrings.Readerで渡したり、http.Request.BodyでHTTPクライアントからアップロードされた内容を直接読み込ませたり、とかですね。

で、io.Readerio.Writerあたりはこの恩恵に授かりやすいのですが、標準ライブラリのioパッケージで定義されているものの、ごくごくたまーに要求されるが、そのインタフェースを実装している構造体が少なくてついつい慌てがちなのが、io.Seekerです。

会社のチャットで話題になって、回答はしたものの、裏で自動でバッファリングしてシーク可能にするようなラッパー作れそうだな、と思ってつい書き始めました。

io.Readerとしてラップ元のio.Readerから読み込まれた内容とか、Seek()呼び出しで内容の読み飛ばしが指示されたときに、内部にバッファリングして、自由にシークできるようにします。バッファサイズはデフォルトで1MBにしていますが、オプションで変更できるようにしました。

1
readSeeker := iowrapper.NewSeeker(reader, iowrapper.MaxBufferSize(10 * 1024 * 1024)

徹底的にテストしてみる

この手の入出力が決まって、なおかつ内部の状態を持つものはとても歯ごたえがあってテストのしがいがあります。いろいろな組み合わせがあるので、テストのカバレッジをあげるのは頭を使います。いくつかチャレンジしてみました。

ペアワイズ法

何度か使ったことがある、PICTを使って見ます。次のQiitaの記事が詳しいです。

M1 macでしたが、git cloneしてmake一発でビルド完了。ビルド時間もごくわずかです。入力パラメータとして、カテゴリとその構成要素をカンマ区切りで書きます。これでpictコマンドに渡すとテストすべき組み合わせがリストで出力されます。

state.txt
1
2
3
4
seek-whence: start, current, end
seek-offset: negative, zero, positive
read-position: negative, zero, in-buffer, out-of-buffer, end, exceed-end
read-size: zero, in-range, end, exceed-end

このうち、条件で組み合わせが限定されるもの(確定されるもの、あるいは存在しない組み合わせ)を式で追加できます。

1
2
3
4
5
6
7
8
if [seek-whence] = "start" AND [seek-offset] = "negative"
THEN ( [read-position] = "negative" );
if [seek-whence] = "start" AND [seek-offset] = "zero"
THEN ( [read-position] = "start" );
if [seek-whence] = "end" AND [seek-offset] = "zero"
THEN ( [read-position] = "end" );
if [seek-whence] = "end" AND [seek-offset] = "positive"
THEN ( [read-position] = "exceed-end" );

結果はこのように出力されます。

1
2
3
4
5
6
7
8
9
$ pict state.txt
seek-whence seek-offset read-position read-size
current negative negative exceed-end
start positive negative end
current zero exceed-end in-range
current zero in-buffer exceed-end
end zero end end
current positive end exceed-end
current zero negative zero

これを元にテストコードを書けばばっちりです!

・・・とはなかなかうまくはいきませんでした。

これを元にテーブルテストの項目を手動で書いてみたのですが、「あ、この条件を足そう」と、カテゴリが増えたり、組み合わせの制約を足したら、がらっと結果が変わってしまって、過去に書いたテストがどれに該当するのか、どれが足りないのかを目視で確認するのが結構大変です。以前にも何度かチャレンジしたものの、やはり同様の理由で途中で放棄しました。出発点のテストのボイラープレート作成用の元データとして使い、コードが成長してきて、後からif文が増えたのでカバレッジ向上のために足したいものがあれば手動で足す・・・みたいな運用に結果的になってしまいました。テーブル駆動テストのネタ作りのための使い捨てと割り切って、組み合わせ算出の電卓として使うのが良さそう。

テストコードを書きながら書いて設計していくようなサイクルを回して成長させていくテスト駆動開発に組み込むには、乱数の種を固定(/r:Nオプション)しつつ、テストケースの中にこのカテゴリの値そのものをどこかに入れておいて、生成されたものと比較して足りないものを出力するみたいな静的解析との組み合わせが必要かな、と。あるいは、この入力をダイレクトにテスト実行時に読み込んでテストケースにしてしまう方法も考えられますが、それであれば、正直このツールを使うよりは次のプロパティベーステストの方が開発のサイクルには合わせやすいと思いました。

まあ、組み合わせとかカバレッジを考えるには良い題材だと思うので、新入社員研修とかで触らせてみるのは良いかもしれません。

プロパティベーステスト(PBT)

上記のPICTを(初期だけ)使って、テーブルテストを手動で書いて、一通りの実装を完成させるところまではできました。しかし、この手のバッファを扱うものは扱いを間違えると即座にバッファオーバーフローでセキュリティホールで・・・となる修羅の世界。せっかくなので、追加で、gopterを使ってプロパティベーステストしたり、ファジングのテストもしました。プロパティベーステストについては次のサイトが日本語でよくわかる説明になっています。

細かい定義とかは追っかけていないですが、形式仕様記述と、ユニットテストの1手法であるテーブル駆動テストの中間ぐらいの感触です。テーブル駆動テストだと、入力値を人力でリスト化し、テストコードに投入して期待する結果が出るかをテストします。形式仕様記述はAlloyをちょっと触っただけなので違う側面があるかもしれませんが、論理的に入出力や内部の状態の関係性を記述し、それを解釈して仕様が矛盾しないかを検証します。

PICTとの違い

PICTは、テストケースをなるべく削減するために、問題の発生原因となる因子の組み合わせ数を考慮してなるべく少ないテストケースを生成しますし、入力はenumの組み合わせです。Property Base Testも、因子を与えると、入力値の組み合えわせを生成する点は一緒ですが、こちらは「文字列が入力」と型の指定をすると、空文字列から長い文字列までいろんなバリエーションの文字列を生成してテストの入力とします。自動テスト前提なのでデフォルトで100件とかそれなりの分量のテストを生成します。実際の入力の組み合わせは無限大ありますが、PICT同様、無限大の選択肢の中から、100パターン効率よくいろいろなバリエーションのインプットを生成します。ただたくさん組み合わせを作るだけではなく、エラーが発生したときに、失敗した組み合わせを分析(シュリンク)し、「この要素が原因である」と少し分析したうえでエラーを返してくれます。問題が発生したかどうかだけではなく、問題の解析にも役立ちます。

PBTはPICTのテストケース生成と異なり、「Aならば結果がBにはならない」みたいな論理的な記述はできません。評価は単にtrue/falseで返すだけなので単にありえない組み合わせならさっさとtrueを返して終了、という方法もできますが、いろいろ考慮されて絞られた100通りのパターンのテストケースを、そこからさらに何割か削ってしまうと、本来テストすべきだったケースも検証がスキップされてしまう可能性があります。そういう無駄玉は打たない方が良いでしょう。

書いてみたテスト

今回書いたテストはこんな感じです。プロパティを宣言し、それにテストを書いていきます。プロパティには入力値のジェネレータを渡します。ジェネレータでは文字数の上限とか数値の値の範囲とか細かく制御はできますが、今回みたいなケースの場合、「元のio.Readerのコンテンツよりも少ない数値を入力パラメータに利用したい」みたいなニーズが出てきます。しかし、ジェネレータが生成する値を元に別の値を生成する指定はできません。

今回は、「読み飛ばす文字列」「実際に読む文字列」「読み残す文字列」と3つの文字列を生成し、それを合成してio.Readerの入力を生成したり、Seek()のオフセット値や読み込む量といった数値を算出しています。Property Base Testの場合はこのようなジェネレータを使ったパズルを解いて、効率よく入力を作り出す方法を考えるところが楽しいところですし、結果の質に影響がある部分です。

プロパティベーステストがユニットテストと異なるのが、入力時が自動生成なので結果の比較をどうするか考える点です。今回の入力を3つに分けて生成しており、正解のデータも自動生成してから組み込んでいるので、厳密に正しいかどうかテストできます。他に先行実装があってその移植であればそれをテストにもできるでしょう。こちらも考慮が必要です。まあホワイトボックステストですね。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func TestSuccessfullyRead(t *testing.T) {
properties := gopter.NewProperties(nil)

skipBytesGen := gen.AnyString()
readBytesGen := gen.AnyString()
remainedBytesGen := gen.AnyString()

properties.Property("Read successfully with SeekStart", prop.ForAll(func(skipBytesSrc, readBytesSrc, remainedBytesSrc string) bool {
skipBytes := []byte(skipBytesSrc)
readBytes := []byte(readBytesSrc)
remainedBytes := []byte(remainedBytesSrc)
var buffer bytes.Buffer
buffer.Write(skipBytes)
buffer.Write(readBytes)
buffer.Write(remainedBytes)

readSeeker := NewSeeker(&buffer)
readSeeker.Seek(int64(len(skipBytes)), io.SeekStart)

var output bytes.Buffer
n, err := io.CopyN(&output, readSeeker, int64(len(readBytes)))
if n != int64(len(readBytes)) {
t.Logf("Read successfully with SeekStart fail: n(%d) != len(readBytes)()%d\n", n, len(readBytes))
return false
}
if output.String() != readBytesSrc {
t.Logf("Read successfully with SeekStart fail: %s != %s\n", output.String(), readBytesSrc)
return false
}
if err != nil {
t.Logf("Read successfully with SeekStart fail: err(%v) != nil\n", err)
return false
}
return true
}, skipBytesGen, readBytesGen, remainedBytesGen))
properties.TestingRun(t)
}

ファジング

PBTのライブラリは、ファジングにも便利です。ありとあらゆる入力値の可能性を与えて、パニックが起きないかどうかの検証です。Goでファジングだと、go-fuzzが有名ですが、gopterを使い回す方が、入力値の型に合わせたデータ生成ができるので使いやすいです。

今回は、オフセット値や読み込むバイト数をソース文字列とは別に生成器を作っています。入力文字列よりもはるかに大きなオフセットを与えたり、読み込みを行おうとしたりします。このテストでは結果の検証はしなくて、完走したらreturn trueとしています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func TestFuzzing(t *testing.T) {
properties := gopter.NewProperties(nil)

srcGen := gen.AnyString()
whenceGen := gen.IntRange(0, 2)
offsetGen := gen.Int64()
readBytesGen := gen.Int64()

properties.Property("Don't panic", prop.ForAll(func(srcStr string, whence int, offset, readBytes int64) bool {
src := strings.NewReader(srcStr)
readSeeker := NewSeeker(src)
readSeeker.Seek(offset, whence)
io.CopyN(ioutil.Discard, readSeeker, readBytes)
return true
}, srcGen, whenceGen, offsetGen, readBytesGen))

properties.TestingRun(t)
}

まとめ

Goのインタフェースのラッパーを作ってみたのと同時に、いつもよりも少し気合いを入れてテストを書いてみました。本体のコード量の数倍のテストになりました。

PICTはお手軽に始められる言語非依存のツールです。テスト駆動開発のサイクルの出発時に状態の組み合わせを考えてみるお手伝いとして使う方が良いな、というのが実感です。

一方プロパティベーステストはプログラミング言語に合わせて作られているライブラリを利用します。徹底的にテストされてしまうので、テスト駆動開発だといつまでも青くならない、真っ赤になり続けることになるため、テスト駆動開発に組み込むのは少し難いですね。一通り実装が完了したら、セルフQAとしてPBTを使ったテストを書いてみる使い方が良いかな、と思いました。