名状し難きFsCheck入門のようなもの

皆さんこんばんは、F# Advent Calendar 2012の6日目ですね。いかがお過ごしでしょうか?
私は前5名の記事を見ながら胃が痛いなと感じています。

本題の前に

前日のnakamura_toさんの記事に登場するシグネチャファイルの話について、思うところがあったので私見をてきとーに書いておきます。急いで書いたので読みにくかったらごめんなさい。

FSharpxはシグネチャファイルを書かずになるべく型推論にお任せ的な風潮なのですが*1、それによって(個人的に)一部弊害が現れています。ここではIteratee.List.map関数を例にします。

// 型推論任せ版
val map : ('a -> 'b) -> (Iteratee<'b list,'c> -> Iteratee<'a list,Iteratee<'b list,'c>>)
// シグネチャファイル版
val map : ('a -> 'b) -> Enumeratee<'a list, 'b list, 'c>

上は今のFSharpxのもの、下はシグネチャファイルを加えたものを、それぞれ呼び出し(利用者)側からみたものです。おそらくシグネチャファイルを加えたほうがわかりやすいと思います。型はドキュメントとはよくいったものです。
なら、実装側に直接型を書けばいいじゃないかという話ですが、今度は関数や引数の定義部分がごちゃごちゃして個人的には読みにくく感じます。
というわけで、

  • リリース後も仕様変更が多い場合は書かない
  • 型に別名を与えている場合などはなるべく書きたい
  • ついでにドキュメントもシグネチャファイルに書く

と考えたりしていますが、もしかしたら開発者の趣味の問題かもしれませんね。

おまけ: https://gist.github.com/4222818

FsCheckぽよ!

それでは本編です。
FsCheckを簡単に説明すると、HaskellのQuickCheckを.NETに移植したランダムテストフレームワークです。

FsCheck: A random testing framework - Documentation

インストール方法(nugetを使えば一発ですが)や使い方はぶっちゃけ上記ドキュメントに書いてあるので、今回はこんな感じに使っているよ的なことを書きます。

NUnitで実行できるようにするぽよ

FsCheckは単体でも使用可能ですが、私は基本的ユニットテストフレームワーク(FSharpxではFsUnit、趣味開発ではNaturalSpec)と併用しています。
併用するにあたっては、一括でテストできたほうが楽なので、NUnit用のRunnerを記述します。

module FsCheck.NUnit

open FsCheck
open NUnit.Framework

let private nUnitRunner =
    { new IRunner with
        member x.OnStartFixture t = ()
        member x.OnArguments(ntest, args, every) = ()
        member x.OnShrink(args, everyShrink) = ()
        member x.OnFinished(name, result) = 
            match result with 
            | TestResult.True data -> 
                printfn "%s" (Runner.onFinishedToString name result)
            | _ -> Assert.Fail(Runner.onFinishedToString name result) }
   
let private nUnitConfig = { Config.Default with Runner = nUnitRunner }

let fsCheck name testable =
    FsCheck.Check.One (name, nUnitConfig, testable)

このあたりについてもドキュメントのTipsに書いてあります。

関数の性質がわかりやすいものの場合

データ構造の関数など、関数の性質がわかりやすいものはいきなりランダムテストから書いたりしています。ここではTDDBC 大阪2,0の自販機問題を使って例を書いてみます。

open NaturalSpec
open FsCheck
open FsCheck.NUnit

let fsCheck t = fsCheck "" t

module ``お金がひとつも投入されていないとき`` =

  [<Scenario>]
  let ``自販機の中にはお金が投入されていないはず``() =
    Given VendingMachine.empty
    |> When List.isEmpty
    |> It should equal true
    |> Verify

  [<Scenario>]
  let ``投入したコインと払い戻し操作を行って入手したお金のリストは一致するよね``() =
    fsCheck <| fun x ->
      VendingMachine.empty |> VendingMachine.insert x |> VendingMachine.refund = [x]

module ``お金が既に投入されているとき`` =

  [<Scenario>]
  let ``新たに追加投入したお金も含めて全部払い戻されるよね``() =
    fsCheck <| fun machine x ->
      machine |> VendingMachine.insert x |> VendingMachine.refund = x :: machine

では、実装してみましょう。

type Money =
  | Ten
  | Fifty
  | Hundred
  | FiftyHundred
  | Thousand

module VendingMachine =

  let empty = []

  let insert money machine = money :: machine

  let refund (machine: Money list) = machine

ただのリストじゃん、というのはまぁ…許してください。
とまぁ、こんな感じでユニットテストとランダムテストを混ぜて使っています。すぐに性質を思いつかない場合でも、いくつか具体値でテストしていたら思いつくこともあります。
書きやすいのはデータ構造の挿入/削除対応やデータの変換系です。今回だとお金の投入/払い戻しは挿入/削除に対応します。データの変換だとMoney -> 数値 -> Moneyと変換すれば同じデータのはず、といった感じでしょうか。

性質なんてわかんないぽよ

まぁ、世の中そんなに甘くないですね。副作用などが含まれてきたりするとわりと上記の使い方は厳しくなっていきます*2
その場合、どんな状況で使っているかというと、適当にデータを放り込んで実行し、コーナーケースを見つけたいときです。極端に簡単な例として0による除算を考えます。

[<Scenario>]
let ``なにかコーナーケースがみつからないかな?``() =
  fsCheck <| fun x ->
    100 / x |> ignore // 何か例外が発生するとテストに失敗する
    true // コーナーケースが見つからなかったら、ひとまずテスト成功としておく

これを実行すると

FsCheckExample.NaturalSpecSample.なんかコーナーケースがみつからないかな?:
Falsifiable, after 4 tests (0 shrinks) (StdGen (1800389021,295650313)):
0
with exception:
System.DivideByZeroException: 0 で除算しようとしました。
場所 FsCheckExample.NaturalSpecSample.なんかコーナーケースがみつからないかな?@12.Invoke(Int32 x) 場所 ...

こんな感じでコーナーケースを発見できることがあります。データがランダムに生成されるので絶対に見つかるという保障はありません。気休め程度ですが、思いつかないようなものを発見できるときもあるので便利です。
なお、コーナーケースを見つけたら、そのデータでのユニットテストを書きます。

[<Scenario>]
[<FailsWithType (typeof<System.DivideByZeroException>)>]
let ``0除算はDivideByZeroExceptionが発生して欲しいなぁ``() =
  Given (1,0)
  ||> When (/)
  |> Verify

先ほど書いたランダムテストはいらなくなったら捨てます。リポジトリ汚したくないなら、別ブランチでとりあえずまわしてみるなどしてみると良いとおもいます(私もそうしています)。


ちなみに、この使い方はランダムテストとして正しい使い方なのかはよく知りません。

テストデータ生成を制御する

データによっては理論的にはありえないのに、FsCheckにお任せで生成したデータだと生成されてしまうものがあります。
ここでは例として、FSharpx.DataStructures.IntMapにご登場いただきましょう。IntMapはkeyがintに特化したMapです。データ構造の定義は以下の通り。

type 'a IntMap =
    | Nil
    | Tip of int * 'a
    | Bin of int * int * 'a IntMap * 'a IntMap

Nilは要素なし、Tipは要素1つ、Binは2つ以上の要素を表しています。
ここで問題になるのはBinです。何も制限をかけずにテストデータを生成すると、Bin(0,0,Nil,Nil)やBin(1,0,Tip(0,Nil),Nil)のような理論上ありえないデータ構造がテストデータとして選ばれる可能性があります。
そこで、ArbモジュールやGenBuilderを利用します。

type IntMapGen =
    static member IntMap() =
        let intMapGen() = 
            gen {
                let! ks = Arb.generate
                let! xs = Arb.generate
                return IntMap.ofSeq (Seq.zip (Seq.ofList xs) (Seq.ofList ks))
            }
        Arb.fromGen (intMapGen())

let registerGen = lazy (Arb.register<IntMapGen>() |> ignore)

まず、Arb.geenerateで任意のデータを生成しつつ、関数を駆使してテストに使いたいデータを用意してreturnするジェネレータを用意します。その後、Arb.registerでジェネレータを登録します。あとは、テストメソッドで呼び出すだけです。

[<Test>]
let ``prop insert and delete``() =
    registerGen.Force()
    fsCheck <| fun k t ->
        IntMap.tryFind k t = None ==> (IntMap.delete k (IntMap.insert k () t) = t)

なお、ジェネレータは一度評価すればあとは使いまわしできるのでlazyにしています。
この方法以外にもいくつかの生成方法があるので、気になる方はドキュメントを読んでみてください。

データの判定と破棄

先ほどの例でちらっとでてきた(==>)は、条件を満たしたときはそのテストデータを評価対象として使い、条件を満たさない場合はそのデータを捨ててデータを再生成します。無限ループに陥らないため再生成回数が決められており、これは変更が可能です。
ドキュメントを読めばわかりますが、気をつけるべき点はF#が正格評価である点です。条件を満たさなければ実行しないというわけではなく、条件を満たそうとそうでなかろうと実行は行われます。よって、以下のテストはZeroDivideExceptionが発生します。

[<Scenario>]
let ``DivideByZeroExceptionが発生しちゃう(>_<)``() =
  fsCheck <| fun a -> a <> 0 ==> (1/a = 1/a)

これを回避するには、評価部分をlazyにします。

[<Scenario>]
let ``DivideByZeroExceptionが発生しないぽよ!``() =
  fsCheck <| fun a -> a <> 0 ==> lazy (1/a = 1/a)

Lazy.Forceを実行する前にデータを破棄して次のデータでのテストを行うので、例外が発生しなくなりました。


なお、条件つきの性質はテスト対象データが制限されやすいので、使わないで済むようならそれに越したことはありません。でもま、使わざるを得ないこともあるので紹介しておきました。

まとめ

簡単にではありますが、FsCheckの入門記事でした。『実用』といえる点はFSharpxで利用されていることと私もたまに使っているよ、というところですかね。。。
FsCheckはQuickCheckを基にしているだけあり、書き方がわりと似ています。そのため、良い書き方を学ぶならHaskellのコードを眺めてみることがお勧めです。もちろん、実際にFsCheckを利用しているFSharpxのテストコードも参考になると思います。また、同じくQuickCheckから移植されたScalaCheckを利用しているScalazのテストコードも勉強になるかと思われます。
私も使い始めてまだ日が浅いので、一緒に学んでいきませんか?


さて、明日はposaunehmさんです。お楽しみに!

*1:MLで確認したわけではないので私の誤解かもしれません

*2:私が残念or初心者なだけかもしれません