YAMAGUCHI::weblog

噛み付き地蔵に憧れて、この神の世界にやってきました。マドンナみたいな男の子、コッペです。

gopterでステートフルなPBT

はじめに

こんにちは、Cloud Ops担当者です。最近はGoogle Cloud Profilerがイチオシです。ワークショップやってるんで興味がある方はご連絡ください。

この記事は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が使われました。

groups.google.com

このバグは17ステップを特定の条件で踏まないと再現しないという、非常にややこしいものだったのですが、PBTはきちんとその手順を見つけてきました。人間がこれを発見するのはほぼ無理なのではないでしょうか。他にもステートフルPBTが見つけたバグは多くあります。

他にもDropboxが分散同期システムでのバグを発見したり、Volvoが車載システムの検証に使ったと多くの事例があります。たとえば

  • ショッピングカートの実装
  • オンラインゲームなどでのターン制戦闘の管理やポイント処理周り
  • 決済システム
  • ステートフルプロトコル

など、多くの場面で有用でしょう。

GoでステートフルPBT

GoでPBTをする場合にはいくつか選択肢があります。

github.com

github.com

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)を表すものを定義し、入力値をランダムに生成して、テスト対象が返すものと属性の実装が返すものが不一致ならテスト対象に問題があると判断する」というものです。(下図参照)

f:id:ymotongpoo:20201219214846p:plain

ステートレス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対応しています。)

カウンターの例でいうと IncDecGetReset の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.Statecommands.Result がありますが、stateは自分がテスト用に用意した状態管理用のオブジェクト、resultはRunFuncの結果です。それぞれ int なので int にキャストして比較しています。不一致だったら gopter.PropResult{Status: gopter.PropFalse} として失敗した旨を返します。

NextStateFuncRunFunc を行った場合に状態がどのように変化しているべきかをテスト用に用意したオブジェクトに対して操作を記述し、その値を返します。ここでは無名関数の引数に渡される commands.State を直接操作して、そうあるべき状態に変化させます。"INC"ではカウンターとして用意した int の値に対して1を足すだけです。状態を管理しているものが構造体の場合も、その構造体にキャストしてから適切な操作をします。

同様にして DecReset についてもコマンドを定義します。

ステートフル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さんです。