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
- User Guide · rickynils/scalacheck Wiki
- Scalaのユニットテスト入門 - seratch's weblog in Japanese
- Scala逆引きレシピ - 翔泳社の本
<= テストデータをTableで持たせる方法など、より詳細が書かれています - ScalaCheck: The Definitive Guide
<= どのようなプロパティを考えるべきかなどの指針も書かれており、実用的な内容
0 件のコメント:
コメントを投稿