LoginSignup
1
1

More than 5 years have passed since last update.

Refined なケースクラスの ScalaCheck テスト

Posted at

Refined を使って制約を型で表現したケースクラスを、ScalaCheck でプロパティベーステストしてみる。

お題

The Type Astronaut's Guide to Shapeless』に下のようなアイスクリームクラスがある。

case class IceCream(name: String, numCherries: Int, inCone: Boolean)

名前、チェリーの個数、コーンに入ってるかどうかで構成されている。これをたたき台に、まず Refined を導入して、次にプロパティベーステストを書いてみる。

次のように Refined を導入する

  • 下記の制約を型として与える
    • 名前は、英数+空白からなる2〜10文字の文字列とする
    • チェリーの個数は、1〜10個とする
  • 上の制約を満たすアイスクリームを文字列から作る関数を書く。

プロパティベーステストは次のようにする

  • サンプルアイスクリームのジェネレータを作る
  • そのジェネレータを使って下のプロパティをテストする
    • どんなアイスクリームでも、いったん文字列にしてから元のアイスクリームに復元できる
    • もしある文字列からアイスクリームを作れたならば、そのアイスクリームを文字列化すると元の文字列に一致する。

実装

ソース→Gist

使用バージョン等

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
}
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1