はじめに
こんにちは、Cloud Ops担当者です。最近はGoogle Cloud Profilerがイチオシです。ワークショップやってるんで興味がある方はご連絡ください。
さすがにどことは言えないけど、あるお客さんにCloud Profilerの90分のワークショップをやったら、翌日お客さんがスプリントを実施してサービスの性能が倍になったって喜んでくれた。自分も嬉しい。Cloud Profilerのワークショップに興味ある企業の方がいらしたらDMください。 #gcpja #GoogleClould
— Yoshi Yamaguchi ⌨ Keyboard builder (@ymotongpoo) 2020年10月13日
この記事はGo Advent Calendar 2020の19日目の記事です。昨日は@DoarakkoさんのGo の Web フレームワーク Gin にちょっとコミットしましたでした。
Property Based Testingとは
先日開催されたGoCon SendaiでFuzzingとProperty Based Testingについてお話しました。Property Based Testingを簡単に説明すると、テスト対象の関数に対して、入力値の条件とその関数が満たすべき性質(Property)を定義してあげて、入力時をマシンパワーに任せて自動生成しながら、テスト対象の関数の戻り値と性質を表す関数の戻り値を比較して、一致しない条件を探すという、高級なファジングです。詳しくはGoCon Sendaiの発表を見てください。
S3-1 Goにおけるfuzzingとproperty based testing
資料はここです。
ステートフルProperty Based Testing
GoCon Sendaiでの発表ではProperty Based Testingの紹介だったので、一番始めやすいステートレスPBTについての話をしました。それだけでも十分有用なのですが、PBTが真に力を発揮するのはステートフルなテストを行うときです。発表でも紹介しましたが、LebelDBのバグを発見するのにステートフルPBTが使われました。
このバグは17ステップを特定の条件で踏まないと再現しないという、非常にややこしいものだったのですが、PBTはきちんとその手順を見つけてきました。人間がこれを発見するのはほぼ無理なのではないでしょうか。他にもステートフルPBTが見つけたバグは多くあります。
他にもDropboxが分散同期システムでのバグを発見したり、Volvoが車載システムの検証に使ったと多くの事例があります。たとえば
- ショッピングカートの実装
- オンラインゲームなどでのターン制戦闘の管理やポイント処理周り
- 決済システム
- ステートフルプロトコル
など、多くの場面で有用でしょう。
GoでステートフルPBT
GoでPBTをする場合にはいくつか選択肢があります。
gopterのほうが多くの事例があるので、いま使うのであればgopterのほうがいろいろ都合がいいと思います。発表やスライドにもありますが、ステートレスなPBTの場合に、PBTの実行は properties.Property()
にテスト自体の説明と、テスト対象の関数を渡していました。(次はステートレスPBTの例)
func TestSqrt(t *testing.T) { properties := gopter.NewProperties(nil) properties.Property("greater one of all greater one", prop.ForAll( func(v float64) bool { return math.Sqrt(v) >= 1 }, gen.Float64Range(1, math.MaxFloat64), )) properties.Property("squared is equal to value", prop.ForAll( func(v float64) bool { r := math.Sqrt(v) return math.Abs(r*r-v) < 1e-10*v }, gen.Float64Range(0, math.MaxFloat64), )) properties.TestingRun(t) }
gopterでステートフルなPBTを行う場合は commands
というサブパッケージを使うことになりますが、ステートフルPBTを行う場合も、実行はステートレスなPBTと同様に properties.Property()
関数でおこないます。ただし、渡すのが commands.ProtoCommands
になります。
func TestAll(t *testing.T) { if testing.Short() { t.Skip("skip PBT in short mode.") } parameters := gopter.DefaultTestParameters() parameters.Rng.Seed(1234) // デモなので常に同じ結果になるようにシードを固定 properties := gopter.NewProperties(parameters) properties.Property("buggy counter", commands.Prop(buggyCounterCommands)) properties.TestingRun(t) }
これが何者なのかを説明することでステートフルPBTの書き方がわかりやすくなるので、以下で説明します。
ステートフルPBTに必要なもの
上の発表はスライドを読む時間がない忙しい人に、そもそもPBTは何をするものなのかという点をもう一度簡単に書くと「テスト対象の属性(Property)を表すものを定義し、入力値をランダムに生成して、テスト対象が返すものと属性の実装が返すものが不一致ならテスト対象に問題があると判断する」というものです。(下図参照)
ステートレスPBTの場合は上の図のPropertyが関数にしかならないのですが、ステートフルPBTの場合はそれが変数と関数の組み合わせだったり、メソッドをもった構造体です。比較対象も直接出力値を比較するというよりも「状態」を比較することになります。そして入力値は値を入れるというよりも「呼び出す関数(およびその引数)の組み合わせ」になります。整理して
- ステートを持つテスト対象
- 状態
- 呼び出す関数の組み合わせ
が必要になります。
百聞は一見にしかずで、ドキュメントの例にある次の関数を見てみましょう。
テスト対象
まずテスト対象を定義します。これは普通に機能を実装するだけなので、PBT関係なく行います。ここではカウンターを実装します。例ではわざとバグを作るためにカウンターの値が3より大きい場合は、デクリメントの際に2を引くことにしています。
type BuggyCounter struct { n int } func (c *BuggyCounter) Inc() { c.n++ } func (c *BuggyCounter) Dec() { if c.n > 3 { // Intentional error c.n -= 2 } else { c.n-- } } func (c *BuggyCounter) Get() int { return c.n } func (c *BuggyCounter) Reset() { c.n = 0 }
状態の定義
次にテストファイルで書く内容です。ステートフルPBTでは状態を比較することになるので、比較対象として状態を管理するためのオブジェクトを定義します。カウンターは状態としてカウンターの数値しか持っていないので、ここでは特段構造体などを用意せず、int
とします。とりあえずこのことだけ理解した上で次の話に進みます。
コマンドの定義
gopterで「コマンド」と呼んでいるのは、状態を持ったオブジェクトに対する操作を指します。なぜ「コマンド=関数/メソッド」と呼ばないかといえば、コマンドは「一意な操作」の単位なので、たとえば引数を取る関数であれば、引数の値ごとにコマンドが異なります。(カウンターの例では引数なしのメソッドしかないので、たまたまコマンドとメソッドが1対1対応しています。)
カウンターの例でいうと Inc
、 Dec
、 Get
、 Reset
の4つのコマンドを定義します。まず Get
メソッドと Inc
コマンドに対応するコマンドを定義してみます。
var GetCommand = &commands.ProtoCommand{ Name: "GET", RunFunc: func(systemUnderTest commands.SystemUnderTest) commands.Result { return systemUnderTest.(*BuggyCounter).Get() }, PostConditionFunc: func(state commands.State, result commands.Result) *gopter.PropResult { if state.(int) != result.(int) { return &gopter.PropResult{Status: gopter.PropFalse} } return &gopter.PropResult{Status: gopter.PropTrue} }, } var IncCommand = &commands.ProtoCommand{ Name: "INC", RunFunc: func(systemUnderTest commands.SystemUnderTest) commands.Result { systemUnderTest.(*BuggyCounter).Inc() return nil }, NextStateFunc: func(state commands.State) commands.State { return state.(int) + 1 }, }
Name
フィールドはこの操作の愛称です。これはテスト結果に出力されるので、1単語でわかるくらいの短いラベルが良いです。それぞれ "GET"、"INC"としています。
RunFunc
フィールドはテスト対象側で実行する操作です。commands.SystemUnderTest
をテスト対象のオブジェクトにキャストしてから、対象の操作を実行します。
PostConditionFunc
はコマンドを実行し終えたあとに行う処理です。通常はここで状態の比較を行います。また、ここでフィールドに与えている無名関数の引数に commands.State
と commands.Result
がありますが、stateは自分がテスト用に用意した状態管理用のオブジェクト、resultはRunFunc
の結果です。それぞれ int
なので int
にキャストして比較しています。不一致だったら gopter.PropResult{Status: gopter.PropFalse}
として失敗した旨を返します。
NextStateFunc
は RunFunc
を行った場合に状態がどのように変化しているべきかをテスト用に用意したオブジェクトに対して操作を記述し、その値を返します。ここでは無名関数の引数に渡される commands.State
を直接操作して、そうあるべき状態に変化させます。"INC"ではカウンターとして用意した int
の値に対して1を足すだけです。状態を管理しているものが構造体の場合も、その構造体にキャストしてから適切な操作をします。
同様にして Dec
と Reset
についてもコマンドを定義します。
ステートフルPBTの初期設定
最後に、初期値に関する設定と、存在するすべてのコマンドを定義します。commands.ProtoCommands
がそれです。(commands.ProtoCommand
ではなく複数形になっていることに注意。)次のような形です。
var buggyCounterCommands = &commands.ProtoCommands{ NewSystemUnderTestFunc: func(initialState commands.State) commands.SystemUnderTest { return &BuggyCounter{} }, InitialStateGen: gen.Const(0), InitialPreConditionFunc: func(state commands.State) bool { return state.(int) == 0 }, GenCommandFunc: func(state commands.State) gopter.Gen { return gen.OneConstOf(GetCommand, IncCommand, DecCommand, ResetCommand) }, }
NewSystemUnderTestFunc
はテスト対象のオブジェクトを初期化するための関数を定義するフィールドです。
InitialStateGen
はテスト用に定義したオブジェクトの初期化。この例では int
のまま、かつカウンターは常に初期値0となっているので gen.Const(0)
となっています。
InitialPreConditionFunc
はPBTを始める前にテスト対象とテスト用状態管理オブジェクトの各初期値が持っているべき状態です。
そして最後に GenCommandFunc
で実行すべきコマンドを返す関数を渡します。カウンターでは状態によらずすべてのメソッドが呼べるので、特に引数の commands.State
は触っていませんが、複雑な状態を管理している場合には状態に応じて呼ばれるコマンドがかわるわけです。ここでステートマシンのグラフをすべて書くことになります。
ステートフルPBTの実行
最後にステートフルPBTを実行します。自分は testing.T
を使って go test
コマンドで呼び出せるほうが好きなので、普通のテストと同様に xxx_test.go
ファイルに書きます。ただし、PBTはファジングと同様に実行にどうしても時間がかかるので、常に起動することがないように --short
フラグで起動しないようにするなど、条件付きで起動するようにしたほうが良いと思います。
func TestStatefulPBT(t *testing.T) { if testing.Short() { t.Skip("skip PBT in short mode.") } parameters := gopter.DefaultTestParameters() parameters.Rng.Seed(1234) // デモなので常に同じ結果になるようにシードを固定 properties := gopter.NewProperties(parameters) properties.Property("buggy counter", commands.Prop(buggyCounterCommands)) properties.TestingRun(t) }
実行は先にも書いたように properties.Property()
関数に上で定義した commands.PropCommands
を渡してあげれば終わりです。( commansd.Prop
でラップしてるのは型を揃えるため)
実行
これを go test
で実行してみます。
$ go test ! buggy counter: Falsified after 43 passed tests. ARG_0: initialState=0 sequential=[INC INC INC INC DEC GET] ARG_0_ORIGINAL (8 shrinks): initialState=0 sequential=[RESET GET GET GET RESET DEC DEC INC INC RESET RESET DEC INC RESET INC INC GET INC INC DEC DEC GET RESET INC INC DEC INC INC INC RESET RESET INC INC GET INC DEC GET DEC GET INC RESET INC INC] Elapsed time: 19.201782ms --- FAIL: TestAll (0.02s) properties.go:57: failed with initial seed: 1608381699594525693 FAIL exit status 1
ここで特筆すべきは、ステートフルPBTの場合、テストを失敗させた入力値の表示として、失敗する状態まで持っていったコマンド列が表示されることです。さらに上の例を見ると、ちゃんとその結果まで shrink していることがわかります。今回の例だと3より大きい数字でデクリメントを呼んだときに2を引いてしまっているので、そこでおかしくなることが最小限のステップで再現できるコマンド列が得られています。こんな簡単な例ですら6手順必要なので、リアルワールドではとても有用であることが容易に想像できます。
ステートフルPBTのデメリット
ステートフルPBTは非常に強力なのですが、上で書いているように状態を本来の実装とは別に管理するロジックを書いたり、またコマンドの準備などが非常に複雑になりがちなので、なかなか手が出しづらい側面があります。手動で境界条件になりうるテストシナリオをすべて列挙するのは非現実的ですが、露見するバグによる障害が手動による例外的な対応で許容できる場合もあります。そのようなテスト実装コストと得られるメリットのバランスはなかなか読めないので、すべての人におすすめできるわけでもありません。個人的にはステートフルPBTはもっと事例が出てほしいなと思ってはいますが。
おわりに
急いで書いたのでサンプルの解説になってしまいましたが、少しでもこれでステートフルPBTを使う人が増えると嬉しいです。明日は@soichisumiさんです。