Scalacheckで無理矢理クラス毎にパラメータを変える

Scalacheckのversionは1.11.3です。
あくまでも無理矢理です。


色々調査した結果、クラス毎にパラメータ変える機能は直接Scalacheckに存在しないようです。
Propのcheckメソッドを直接呼び出してしまえば不可能ではないですが
https://github.com/rickynils/scalacheck/blob/1.11.3/src/main/scala/org/scalacheck/Prop.scala#L35
このメソッドはあくまで簡易的なもので、sbt経由でテストする場合は、これは直接呼ぶべきではないものです。


無理矢理なので、version変わったらこの方法使えなくなるかもしれません。



結論とは関係ない流れから説明。

  • 現状(7.1.0-M5)のScalazでは、scalacheckのパラメータが以下のようになっている

https://github.com/scalaz/scalaz/blob/v7.1.0-M5/project/build.scala#L78

Tests.Argument(TestFrameworks.ScalaCheck, "-maxSize", "5", "-minSuccessfulTests", "33", "-workers", "1"),
  • デフォルトのmaxSizeは100であり、同じくデフォルトのminSuccessfulTestsも100である
  • maxSizeとは、たとえば「最大で長さ100のListまで生成するよ」というもの*1
  • つまり、デフォルトより小さくしてある
  • なぜ小さくしてあるかというと、デフォルト値でテストしてしまうと、巨大なオブジェクトが生成されてOutOfMemoryErrorが発生するか、いつまで経っても終わらないようなテストがでてきてしまうから
  • しかし、そのようなテストは、Scalazのテスト全体からみればごく一部に過ぎない。
  • できれば、ある程度大きめの値でテストするのに越したことはない(そのほうがバグがみつかりやすい)
  • 実際Mapのtest https://github.com/scalaz/scalaz/blob/v7.1.0-M5/tests/src/test/scala/scalaz/MapTest.scala において、とてもクリティカルなバグが半年くらい発見されないということがあった
  • もしデフォルトのある程度大きいパラメータ値でテストしていたら、このバグはもっとはやく見つかっていたはずである
  • やはり、テストのclass毎にパラメータ変えるべき!という思いが強まる


というような経緯です。で、Scalacheckのそのあたりドキュメントあまりない?というか、もし英語のドキュメントあっても、それ読むよりもコード読んだほうがはやいので、ひたすらコード読んだ結果の結論がこれから説明するもの。


結論説明するまえに、
そもそも、なぜそのようにパラメータ値をクラス毎に変える方法がないのか?」
ということについての自分の勝手な予想としては
「org.scalacheck.Genの方をテスト毎に変えれば済むから」
ということかもしれません。しかし、(説明しづらいので詳細は省きますが)Scalazのテストにおいて、Genをそれぞれ変えるより、class毎にパラメータそのものを変えたい(そのほうが楽)のです。



さて、Scalacheckを単体で使う場合は、通常org.scalacheck.Propertiesというクラスを継承してテスト用のクラスを作成しますが、そのときに、

def properties: Seq[(String,Prop)]

https://github.com/rickynils/scalacheck/blob/1.11.3/src/main/scala/org/scalacheck/Properties.scala#L39
というメソッドがあるので、これを無理矢理overrideして、それぞれのPropを変更してしまいます。
ここで、Propとは、ものすごく単純に言い表すと、

org.scalacheck.Gen.Parameters
を引数にとり
org.scalacheck.Prop.Result
を返す関数と同型です。つまり
Function1[Gen.Parameters, Prop.Result]
です。その引数のGen.Parametersを変更した新しいPropを生成して返します。つまりcontramapですね!

contramapといって、なんのことかわからない人は 以前Contravariant Functorについて書いたもの読むか Contravariantでググってください


そのあたりの実験をして、とりあえずScalazでうまくいったコミットが以下ですが
https://github.com/xuwei-k/scalaz/commit/d3b93c3120c43
ちょっと無理矢理感があるので、Scalaz本体に入れるのも微妙かな・・・という感じで、pull reqはしてません。

Scalazにおいてのテスト用の親のtraitであるSpecLite.scalaの、重要部分だけ抜き出すと以下

  def maxSize: Option[Int] = None

  private def contramapProp(prop: Prop)(f: Parameters => Parameters): Prop =
    Prop(params => prop(f(params)))

  private def resizeProp(name: String, prop: Prop): Prop = {
    maxSize.map(max =>
       contramapProp(prop)(_.withSize(scala.util.Random.nextInt(max)))
    ).getOrElse(prop)
  }

  override def properties: Seq[(String, Prop)] =
    super.properties.map{ case (name, prop) =>
      name -> resizeProp(name, prop)
    }

maxSizeを変更したい場合はoverrideして、デフォルト値でいい場合はなにもしない、という使い方です。



ちなみに、Propにcontramapを追加したpull reqをScalacheckにしてみました(現状無反応・・・)
https://github.com/rickynils/scalacheck/pull/85

しかし、これで変更できるパラメータ(つまりGen.Parameters)はごく一部です。
このあたりがなんかわかりにくいというか、もっと柔軟に使いやすく作れるのでは?とおもうのですが。
つまりGen.Parametersと、org.scalacheck.Test.Parameters

https://github.com/rickynils/scalacheck/blob/1.11.3/src/main/scala/org/scalacheck/Test.scala#L20

が異なります。

これ以外にも詳しくコード読むと、以外とScalacheckは細かいところで使いにくい部分や、例外発生時に変な挙動するなど、微妙な部分がある気がするのですが、全然関係ない話になるので、また気が向いたら書きます。あと、sbtの最新のtest-interfaceに合わせてtest-interfaceも改善すれば、もっと使いやすくなるかもしれない*2とか色々・・・

*1:ランダムといっても、生成されるListの長さまでがランダムで、もしInt.MaxValueのサイズのListを生成されたら、一発でメモリ足りなくなって死んでしまいますよね?なのでそういうものの最大値の制約は必要です

*2:テストのclass内の、特定のメソッドだけテストするとか