Refined を使って制約を型で表現したケースクラスを、ScalaCheck でプロパティベーステストしてみる。
お題
『The Type Astronaut's Guide to Shapeless』に下のようなアイスクリームクラスがある。
case class IceCream(name: String, numCherries: Int, inCone: Boolean)
名前、チェリーの個数、コーンに入ってるかどうかで構成されている。これをたたき台に、まず Refined を導入して、次にプロパティベーステストを書いてみる。
次のように Refined を導入する
- 下記の制約を型として与える
- 名前は、英数+空白からなる2〜10文字の文字列とする
- チェリーの個数は、1〜10個とする
- 上の制約を満たすアイスクリームを文字列から作る関数を書く。
プロパティベーステストは次のようにする
- サンプルアイスクリームのジェネレータを作る
- そのジェネレータを使って下のプロパティをテストする
- どんなアイスクリームでも、いったん文字列にしてから元のアイスクリームに復元できる
- もしある文字列からアイスクリームを作れたならば、そのアイスクリームを文字列化すると元の文字列に一致する。
実装
使用バージョン等
Refined による制約の表現
上の制約を refined を用いて表現すると下のようになる。
// 名前は、英数+空白からなる2〜10文字の文字列とする
type NameP = MatchesRegex[W.`"[a-zA-Z0-9 ]{2,10}"`.T]
type Name = String Refined NameP
// チェリーの個数は、1〜10個とする
type NumP = Closed[_1, _10]
type Num = Int Refined NumP
case class IceCream(name: Name, numCherries: Num, inCone: Boolean)
名前の制約では正規表現へのマッチを表す述語 MatchesRegex
を使った。指定する正規表現パターンは W.`"~~~"`.T
で囲んで、型引数として与える。
チェリーの個数の制約は述語 Closed
で実装した。_1
や_10
は、import shapeless.nat._
で使えるようになるが、W.`10`.T
などとしても同じ。
Refined なインスタンスの生成
文字列からアイスクリームを作る関数 read
は以下のように書いた。
def read(s: String): Either[String, IceCream] = {
val params = raw"([a-zA-Z0-9 ]+),([0-9]+),(true|false)".r
s match {
case params(s1, s2, s3) => for {
name <- refineV[NameP](s1)
num <- refine(s2.toInt)(refineV[NumP].apply)
cone <- refine(s3.toBoolean)(_.asRight)
} yield IceCream(name, num, cone)
case _ => s"ERROR: $s".asLeft
}
}
private def refine[A, B](f: => A)(g: A => Either[String, B]) =
Try(f).fold(_.getMessage.asLeft, g)
正規表現で文字列片を切り出して、制約を満たせば各フィールドに変換する。制約に合わない場合 refineV
の結果が Left
になるので、文字列→アイスクリームへの変換全体についても Either[String, IceCream]
となる。
アイスクリームから文字列にする show
も下記のように定義しておく。
def show: String = s"$name,$numCherries,$inCone"
サンプルデータのジェネレータ
サンプルアイスクリームのジェネレータは以下のようになる。
object IceCreamSpec extends Properties("IceCream") {
...
val genName = (for {
len <- chooseNum(2, 10)
chars <- listOfN(len, oneOf(alphaChar, numChar, const(' ')))
} yield refineV[NameP](chars.mkString)).map(_.right.get)
val genIceCream = (genName, arbitrary[Num], arbitrary[Boolean]) mapN IceCream.apply
...
名前の生成では、ScalaCheck の Gen
をいろいろ使って、正規表現にマッチする文字列を生成しているが、整数に範囲をつけただけのチェリーの個数は、必要な implicit インスタンスが refined-scalacheck で暗黙に提供されているので、単に Arbitrary[Num]
を召喚するだけで利用できる。
下記のようなサンプルアイスクリームが得られる。
IceCream(38c8e1 d ,1,false)
IceCream(d e2 ,1,false)
IceCream(uhbqnz 918,10,true)
IceCream(2 s X,9,false)
IceCream( 32,1,true)
IceCream(2 ,5,false)
IceCream( 3i1fdq7,9,false)
IceCream(2 a wt 9 ,1,true)
IceCream(1cXd t,1,false)
文字列のジェネレータは、アイスクリームに変換できる文字列と、ただのランダムな文字列のいずれかが生成されるように実装する。
val genString = oneOf(
(genName, arbitrary[Num], arbitrary[Boolean]) mapN ((s, n, b) => s"$s,$n,$b"),
asciiStr
)
以下のようなサンプル文字列が生成される。
p4dcg8 v v,6,true
4 o m ,9,true
dw6 3i r,10,true
3t
q,3,false
:
yq,8,false
7
]] qrq^(
0,1,false
プロパティベースのテスト
これらを用いて、下記のようにプロパティをテストできる。
property("∀ ic: IceCream, read(show(ic))==ic") = forAll(genIceCream) { ic =>
read(ic.show).right.get == ic
}
property("∀ s: String s.t. read(s)=Right(ic), show(ic)==s") = forAll(genString) { s =>
read(s).map(_.show).forall(_ == s)
}
テスト実行結果は以下のようになる。
+ IceCream.∀ ic: IceCream, read(show(ic))==ic: OK, passed 100 tests.
+ IceCream.∀ s: String s.t. read(s)=Right(ic), show(ic)==s: OK, passed 100 tests.
Process finished with exit code 0
Cats メモ
ジェネレータを作っているところで、Cats の apply 構文の mapN
を使っている。そのために、スコープ内に implicit な Apply[Gen]
が必要になるので、以下のように用意しておく。
implicit val genApply: Apply[Gen] = new Apply[Gen] {
def ap[A, B](gf: Gen[A => B])(ga: Gen[A]): Gen[B] = gf flatMap ga.map
def map[A, B](ga: Gen[A])(f: A => B): Gen[B] = ga map f
}