10.19.2014

Scala: Property-Based Testing with ScalaTest and ScalaCheck

Scala: ScalaTest + ScalaCheck を使ってプロパティベースのユニットテストを行う

  • ScalaTest とは
    多くの Scala プロジェクトで採用されているユニットテスト・フレームワークのデファクトの一つ。
    双璧をなす Specs2 とどちらを使うかは好みの問題。
  • ScalaCheck とは
    Haskell の QuickCheck から派生したツール。
    実行時にランダムな入力を自動的に生成し、振る舞いをテストすることができる。
    テスト仕様とテストデータを分離できるようになるのが嬉しい。
    ScalaTest/Specs2 には ScalaCheck を透過的に扱うための仕組みが標準で組み込まれている。

 

sbt の設定

ScalaTest/ScalaCheck を使うにはsbt プロジェクトを作るのが一番簡便だろう。

build.sbt などに、以下のように依存ライブラリを追加する。

libraryDependencies ++= Seq(
  "org.scalatest" %% "scalatest" % "2.2.0" % "test",
  "org.scalacheck" %% "scalacheck" % "1.11.6" % "test"
)

 

REPL で ScalaCheck を試す

sbt test:console で REPL を立ち上げれば、すぐに ScalaCheck の動作に触れることができる。

はじめての ScalaCheck

任意のInt型整数に対して、2 を掛けるのと自分自身を足し合わせた結果が同じになることを確かめる。
ランダムな入力 100個に対するテストが行われ、結果「OK」が表示される。

scala> import org.scalacheck.Prop.forAll
import org.scalacheck.Prop.forAll

scala> val prop = forAll { x: Int => x * 2 == x + x }
prop: org.scalacheck.Prop = Prop

scala> prop.check
+ OK, passed 100 tests.

テストに失敗する例。x * 2 でオーバーフローする場合、その値を 2 で割っても x には戻らない。

scala> val prop = forAll { x: Int => x * 2 / 2 == x }

scala> prop.check
! Falsified after 4 passed tests.
> ARG_0: 1073741824
> ARG_0_ORIGINAL: 1461275699

Int 以外の組み込み型も、そのまま直感的に扱える。

scala> val prop =
     |   forAll { (x: String, y: String, n: Int) => (x + y).startsWith(x.take(n)) }
prop: org.scalacheck.Prop = Prop

scala> prop.check
+ OK, passed 100 tests.

scala> val prop =
     |   forAll { xss: List[List[Int]] => xss.map(_.length).sum == xss.flatten.length }
prop: org.scalacheck.Prop = Prop

scala> prop.check

OK, passed 100 tests.

ランダムに生成される String や List の長さ(サイズ)は、デフォルトでは 0以上 100以下となる。

 

入力値の制約

入力に対して制約を与えるには、以下2種類の方法がある。

1. ジェネレータ(Gen)を与える

scala> import org.scalacheck.Gen
import org.scalacheck.Gen

scala> val prop = forAll(Gen.choose(-10000, 10000)){ x: Int => x * 2 / 2 == x }
prop: org.scalacheck.Prop = Prop

scala> prop.check
+ OK, passed 100 tests.

2. テストの内部でフィルタリングする (マッチしない場合のテストを放棄)

scala> import org.scalacheck.Prop.BooleanOperators
import org.scalacheck.Prop.BooleanOperators

scala> val prop = forAll{ x: Int => (-10000 <= x && x <= 10000) ==> (x * 2 / 2 == x) }
prop: org.scalacheck.Prop = Prop

scala> prop.check
+ OK, passed 100 tests.

ただし後者の場合、制約が強すぎるとテストが全く行われない可能性があるので注意。

scala> val prop = forAll{ x: Int => (x == 12345) ==> (x * 2 / 2 == x) }
prop: org.scalacheck.Prop = Prop

scala> prop.check
! Gave up after only 0 passed tests. 101 tests were discarded.

 

ScalaTest の一部として ScalaCheck を使う

BDD スタイルの FlatSpec を使う場合の例。

単純なテストであれば、org.scalatest.prop.Checkers.check を使うだけでよい。

import org.scalatest.prop.Checkers.check
import org.scalatest.{MustMatchers, FlatSpec}

class ExampleSpec extends FlatSpec with MustMatchers {
  "multiplying integer with two" should "be same as adding itself" in check { x: Int =>
    x * 2 == x + x
  }
}

sbt test を流せば以下のような結果が表示される。

[info] ExampleSpec:
[info] multiplying integer with two
[info] - should be same as adding itself

GeneratorDrivenPropertyChecks トレイトをミックスインすれば、より複雑なテストケースを書けるようになる。

例えばこのような平面座標の距離を求める簡単な関数に対して、

case class Coord(x: Double, y: Double) {
  def distance(c: Coord) = math.sqrt(math.pow(c.x - x, 2) + math.pow(c.y - y, 2))
}

ScalaCheck を使ってみる。

import org.scalatest.prop.GeneratorDrivenPropertyChecks
import org.scalatest.{MustMatchers, FlatSpec}

class CoordSpec extends FlatSpec with MustMatchers with GeneratorDrivenPropertyChecks {
  "Coord#distance" should "be same as norm when one side is origin" in forAll { (x: Double, y: Double) =>
    val norm = math.sqrt(x * x + y * y)
    Coord(x, y).distance(Coord(0, 0)) mustBe norm
    Coord(0, 0).distance(Coord(x, y)) mustBe norm
  }
}

 

独自のクラスに対してテストを行う

組み込み型以外のクラスを任意に生成するには、独自のジェネレータを作ればよい。

ジェネレータの実装例

  def genCoord: Gen[Coord] =
    for {
      x <- Gen.choose(-100.0, 100.0)
      y <- Gen.choose(-100.0, 100.0)
    } yield Coord(x, y)

Gen.choose, Gen.oneOf, Gen.someOf がよく使われる。
Gen.frequency で出現頻度を調整したり、Gen.suchThat で制約を付けたり
org.scalacheck.Arbitrary.arbitrary で任意の値を選んだりもできる。(参考: User Guide · rickynils/scalacheck Wiki)

テストコードはこのような形になった。

import org.scalacheck.Gen
import org.scalacheck.Arbitrary.arbitrary
import org.scalatest.prop.GeneratorDrivenPropertyChecks
import org.scalatest.{MustMatchers, FlatSpec}

class CoordSpec extends FlatSpec with MustMatchers with GeneratorDrivenPropertyChecks {
  def genCoord: Gen[Coord] =
    for {
      x <- Gen.choose(-100.0, 100.0)
      y <- Gen.choose(-100.0, 100.0)
    } yield Coord(x, y)

  "Coord#distance" should "be norm when one side is origin" in forAll { (x: Double, y: Double) =>
    val norm = math.sqrt(x * x + y * y)
    Coord(x, y).distance(Coord(0, 0)) mustBe norm
    Coord(0, 0).distance(Coord(x, y)) mustBe norm
  }

  it should "be zero with same coordinates" in forAll(genCoord) { (a: Coord) =>
    a.distance(a) mustBe 0.0
  }
  it should "be positive or zero" in forAll(genCoord, genCoord) { (a: Coord, b: Coord) =>
    a.distance(b) must be >= 0.0
  }
  it should "be less than 300" in forAll(genCoord, genCoord) { (a: Coord, b: Coord) =>
    a.distance(b) must be < 300.0
  }
  it should "not change after swapping parameters" in forAll(genCoord, genCoord) { (a: Coord, b: Coord) =>
    a.distance(b) mustBe b.distance(a)
  }
  it should "not change after parallel shift" in forAll(
    genCoord, genCoord, Gen.choose(-100.0, 100.0), arbitrary[Int], minSuccessful(500), maxDiscarded(2000)) {

    (a: Coord, b: Coord, dx: Double, dy: Int) => whenever(-10000 <= dy && dy <= 10000) {
      a.distance(b) mustBe (Coord(a.x + dx, a.y + dy).distance(Coord(b.x + dx, b.y + dy)) +- 1.0E-8)
    }
  }
}

最後の例は、whenever で入力を制限したり、+- を使うことで浮動小数点数の誤差を吸収している。

 

 

Source code

 

References

0 件のコメント:

コメントを投稿