新人SEの学習記録

14年度入社SEの学習記録用に始めたブログです。気づけば社会人3年目に突入。

学習記録:Scala関数型デザイン 第8章

第8章:プロパティベースのテスト(続き)

データ型と関数の選択

API:最初のコード

テスト用のライブラリで使用するデータ型はどのようなものだろうか。
定義するプリミティブはどのようなもので,関数はどのような法則を満たすのだろうか。
前章と同様に,単純な例を調べて必要となるデータ型と関数を読み上げ,何が見つかるか確認していく。

val intList = Gen.listOf(Gen.choose(0, 100))

上は前節で示したScalaCheckの例だが,Gen.chooseやGen.listOfの実装について何も知らない状態でも,
それらが返すデータ型(ここではgeneratorを略してGenと呼ぶ)が何らかの型パラメータを持つはずだと推測できる。
つまり,Gen.choose(0, 100)はおそらくGen[Int]を返し,Gen.listOfはGen[Int] => Gen[List[Int]]というシグネチャを持つ。
ただし,Int, Double, Stringなどのリストを作成するために別々のコンビネータを作成するのはおかしな話なので,
Gen.listOfに入力として渡されるGenの型を意識させず,思い切って多相関数にしてみる。

// List[A]の型パラメータを持つGenを生成する                           
def listOf[A](a: Gen[A]): Gen[List[A]]

このシグネチャから色々なことがわかる。生成するリストのサイズが指定されないことに注目してほしい。
したがって,これを実装可能にするためにはジェネレータにサイズを推測させるか,サイズを知らせる必要がある。
ジェネレータにサイズを推測されるのは少し柔軟性に欠けるように思える――全ての状況に適用される前提はあり得ない。
よって,生成するテストケースのサイズをジェネレータに知らせる必要があるようだ。

// サイズnのList[A]型パラメータを持つGenを生成する                        
def listOfN[A](n: Int, a: Gen[A]): Gen[List[A]]

コンビネータとして便利なことは間違いないが,サイズを明示的に指定する必要がないことも見逃せない。
テストケースのサイズはテストを実行する関数が自由に選択できるので,「テストケースの最小化」が現実味を帯びてくる。
サイズが常に固定でプログラマによって指定されるとしたら,テストランナーがこうした柔軟性を持つ必要はない。

続いてforAll関数を見ていく。

val prop =
  forAll(intList)(ns => ns.reverse.reverse == ns) &&
  forAll(intList)(ns => ns.headOption == ns.reverse.lastOption)

この関数がGen[List[Int]]とそれに対応する述語のようなもの―List[Int] => Boolean―を受け取ることが見て取れる。
しかしこの場合も,ジェネレータと述語の型が一致していればforAllがそれらの型を意識する必要はなさそうである。

def forAll[A](a: Gen[A])(f: A => Boolean): Prop

Genを述語にバインドした結果として,(propertyの略である)Propという新しい型を取得している。
PropやPropがサポートする関数の内部表現まではわからないが,この例では&&演算子を使用することがわかっている。

trait Prop { def &&(p: Prop): Prop }
プロパティの意味とAPI

APiの形が少し見えてきたところで,型と関数にどのような意味を持たせるかについて考える。
Propについては,プロパティを生成するためのforAll関数,プロパティを合成するための&&関数,
そしてプロパティを実行するためのcheck関数が存在することがわかっている。

ScalaCheckでは,checkメソッドにコンソールへの出力という副作用がある。
これを便利な関数として公開すること自体に問題はないが,それは合成のもとになるものではない。
例えば,Propの表現がcheckメソッドだけだとしたら,&&を実装することはできない。

trait Prop {                                                                        
  def check: Unit                                                                   
  def &&(p: Prop): Prop = ???                                                       
}                                                                                   

checkには副作用があるので,&&を実装するには両方のcheckメソッドを実行するしかない。
したがって,checkがテストレポートを出力する際には2つのcheckが実行され,成功と失敗を別々に出力することになる。
これは,正しい実装――一つでも失敗があれば失敗を出力するようなーーとは言えない。

&&などのコンビネータを使ってPropの値を結合するには,プロパティを実行するcheckなどの関数が何か意味のある値を返さないといけない。
プロパティをチェックすることによってどのような情報を取得したいのかといえば,最低でも成否については知る必要がある。
というわけで,&&を実装してみよう。

Exercise 8.3
  • Propの表現が trait Prop { def check: Boolean } であるとき,&&をPropのメソッドとして実装せよ。
  // 新しいPropを生成し,その内部でcheckを定義する。
  // その際,外側のPropはProp.thisで得られることに注意。
  def &&(p: Prop): Prop = new Prop {
    def check = Prop.this.check && p.check
  }


この表現では,Propは非正格なBooleanにすぎないため,Propに対してAND, OR, NOTといった関数をどれでも定義できる。
しかし,おそらくBooleanだけでは不十分である。
プロパティが失敗した場合,どれだけのテストが成功したのか,どの引数が失敗したのかは知る必要があるためだ。
また,成功した場合には実行されたテストの数がわかると便利である。
そこで,成功または失敗を示すEitherを返すことにする。

object Prop {
  // こうした型エイリアスを利用することでAPIが見やすくなる。              
  type SuccessCount = Int
  ...
}
trait Prop {
  def check: Either[???, SuccessCount]
}

さて,失敗のケースではどのような型を返せば良いだろうか。
生成されているテストケースの型については何もわからない。Propに型パラメータを追加してProp[A]にすべきか?
そうすればcheckからEither[A, Int]を返せるが,ここで失敗させた値の型が本当に重要なのか考えてみる。
型を意識するのは,失敗したケースでさらに計算を行うときだけである。

それよりも,それを画面上に出力して,テストを実行しているユーザが調査できるようにするほうが一般的だろう。
突き詰めれば,バグを引き起こすテストケースを示すことでユーザがそれらを修正できるようにすることが目標である。
ユーザに表示するための値ということであれば,Stringで十分である。

object Prop {
  // 型エイリアス                                                         
  type FailedCase = String
  type SuccessCount = Int
}
trait Prop {
  def check: Either[FailedCase, SuccessCount]
}

checkは失敗のケースでLeft( (s, n) )を返す。
このsはプロパティを失敗させる原因になった値を表すStringで,nは失敗する前に成功したケースの数である。

少なくとも今のところはcheckの戻り値が処理されているが,checkの引数についてはどうか。
現時点ではcheckは引数を受け取らないが,これで十分だろうか。
もう一度forAll関数を見てみよう。

def forAll[A](a: Gen[A])(f: A => Boolean): Prop

Genについてわかっているのがこれだけだとしたら,
checkの実装に必要なA型の値を生成するのに十分な情報があるかどうか判断するのは難しい。
そこで,Genが何を意味し,その依存関係がどのようなものになるかを理解するため,まずはGenに目を向けることにする。