ScalaCheck + ScalaTest で Property-Based Testing する
こんにちは、山崎です。
ひとつのテストをたくさんのテストデータに対して実施したい時、fixtureにテスト用のデータを定義するというのが良くあるパターンだと思います。しかし、データの構造を変更するたびに必要になる、fixtureの大量のテストデータの手動での変更は辛い作業になってしまうことが多いと思います。
そこで今回は、データをランダムに生成することでそんな悩みを解決するProperty-Based Testingを、ScalaCheckとScalaTestにより実行する方法についてご紹介します。
Property-Based Testingとは
ランダムに生成された値を使用してテストを行うことで、テスト対象のコードの満たすべき性質を検証するテストです。 今回はScalaのProperty-Based TestingのライブラリであるScalaCheckを使用します。
さっそく例を見ていきましょう。
依存ライブラリ
準備として、今回使用するライブラリをbuild.sbt
に定義します
libraryDependencies ++= Seq( "org.scalacheck" %% "scalacheck" % "1.13.4" % Test, "org.scalatest" % "scalatest_2.12" % "3.0.4" % Test, "org.mockito" % "mockito-core" % "2.7.0" % Test )
例:合計を求める処理
合計を求めるメソッドのテストを書いてみます。
テストの対象とするメソッドは以下のようなものです。
object SeqUtil { def sum[N](list: Seq[N])(implicit numeric: Numeric[N]): N = list.foldLeft(numeric.zero)(numeric.plus) }
さっそくテストの方を見てみましょう。
class SeqUtilSpec extends WordSpec with Matchers with GeneratorDrivenPropertyChecks { "sum()" should { "空のリストの合計は0" in SeqUtil.sum(List.empty[Int]) shouldBe 0 //ScalaTestの通常の書き方 "空ではないリストの合計はtailの合計にheadを足したもの" in forAll { (head: Int, tail: List[Int]) => SeqUtil.sum(head :: tail) shouldBe head + SeqUtil.sum(tail) } } }
Property-Based Testingのために、GeneratorDrivenPropertyChecks
をミックスインしています。これにより、forAll
を使ってランダムに生成された値を使用したテストを行うことが可能です。
上の例では、forAll
を使用することで、ランダムに生成されたInt
とList[Int]
の値を受け取ってテストを行っています。forAll
に渡したテストの関数が(2, List())
, (4, List(1, 4)
のようなランダムな値で順番に呼び出されることでテストが実行されるイメージです。
また、上記コードでお分かりいただけるように、forAll
を使用しない従来のScalaTestの書き方と混在させることができます。
独自の型にジェネレータを定義
上の例ではInt
やList[Int]
型の生成された値を用いてテストを行うことができました。これはInt
やList[Int]
のジェネレータがScalaCheckで事前に定義されているためです。では、自分で定義した型の値を生成してテストを行うにはどうしたら良いのでしょうか。
ScalaCheckには、特定の型の値を生成するのに関わる、Gen[A]
とArbitrary[A]
という2つの型が存在しています。これらを使用することで独自の型の値もランダムに生成する事が可能になります。それぞれを順番に見ていきましょう。
Gen[A]
Gen[A]
はAという型の値を生成するジェネレーターです。
以下がその使用例です。
case class Yen(amount: Long) //500円から1000000円まで val moneyGen: Gen[Yen] = Gen.choose(500l, 100000l).map(Yen) forAll(moneyGen){ money => money.amount should be >= 500l }
Yen
型に対してGenを定義し、それをforAllに引数として渡して使用しています。これにより、ランダムに生成されたYen
型の値を使用したテストをすることができます
Arbitrary[A]
Arbitrary[A]
はGen[A]をプロパティとして持つクラスです。
自分で定義した型に対してArbitary
のインスタンスをimplicitで宣言しておけば、合計のテストの例でInt
やList[Int]
を生成したときと同様に、明示的にジェネレータを渡すことなくforAll
を使用することができます。
val moneyGen: Gen[Yen] = arbitrary[Long].map(Yen) implicit val arbMoney: Arbitrary[Yen] = Arbitrary(moneyGen) forAll { (money: Yen) => money.amount shouldBe hogehoge }
例:PostServiceクラス
独自の型にジェネレータを定義しテストする例を紹介します。 テスト対象のコードは以下のようなものです。
投稿を行うクラスPostService
は、投稿を表すPost
のインスタンスを受け取り、PostDAO
を使用してDBに登録します。
ただし、投稿内容にForbiddenWordsLoader
を使用して取得した禁止ワードが含まれている場合は登録を行いません。
まず、テストの対象のコードを示します。
import scala.util.{ Failure, Try } case class Post(userId: Long, comment: String) { def contains(forbiddenWord: ForbiddenWord): Boolean = comment.contains(forbiddenWord.word) } case class ForbiddenWord(word: String) case class ForbiddenWordIsContainedException extends RuntimeException class PostService(postDAO: PostDAO, forbiddenWordsLoader: ForbiddenWordsLoader) { def doPost(post: Post): Try[Unit] = for { forbiddenWords <- forbiddenWordsLoader.load() _ <- if (forbiddenWords.exists(post.contains)) { Failure(ForbiddenWordIsContainedException()) } else { postDAO.create(post) } } yield () } trait PostDAO { def create(post: Post): Try[Unit] } trait ForbiddenWordsLoader { def load(): Try[Seq[ForbiddenWord]] }
このコードに対してテストを行います。
まず、今回はテストケースごとにContext
インスタンスを作成することで、毎回mock objectを使用して新しいPostService
のインスタンスを作成しています。
class PostServiceSpec extends WordSpec with Matchers with GeneratorDrivenPropertyChecks with MockitoSugar { trait Context { val postDaoMock: PostDAO = mock[PostDAO] val forbiddenWordsLoaderMock: ForbiddenWordsLoader = mock[ForbiddenWordsLoader] val postService = new PostService(postDaoMock, forbiddenWordsLoaderMock) }
Post
及びForbiddenWord
という2つの対象に対して任意のインスタンスを表すArbitrary
のインスタンスを定義します。
//Post(正の数値, 任意の文字列)のような値を生成 implicit val arbPost: Arbitrary[Post] = Arbitrary { for { userId <- Gen.posNum[Long] comment <- arbitrary[String] } yield Post(userId, comment) } //ForbiddenWord(任意の文字列)のような値を生成 implicit val arbForbiddenWord: Arbitrary[ForbiddenWord] = Arbitrary { arbitrary[String].map(ForbiddenWord) } /*下のようにも書けます implicit val arbPost: Arbitrary[Post] = Arbitrary(Gen.resultOf(Post)) implicit val arbForbiddenWord: Arbitrary[ForbiddenWord] = Arbitrary(Gen.resultOf(ForbiddenWord)) */
これを使用することで例外系のテストが以下のように記述できます。
"Loaderでの処理が失敗した時" should { "例外をLeftに包んで返す" in new Context { forAll { (post: Post, exception: Exception) => when(forbiddenWordsLoaderMock.load()).thenReturn(Failure(exception)) val result = postService.doPost(post) result shouldBe Failure(exception) } } } "DAOでの処理が失敗した時" should { "例外をLeftに包んで返す" in new Context { forAll { (post: Post, exception: Exception) => when(forbiddenWordsLoaderMock.load()).thenReturn(Success(Seq.empty)) when(postDaoMock.create(post)).thenReturn(Failure(exception)) val result = postService.doPost(post) result shouldBe Failure(exception) } } }
ご覧のように、使用する値をハードコードすることなくテストを記述することができました。
テストで使用されるPostの値の生成はArbitraryのインスタンスの定義のロジックにのみ依存しているため、 例えばPostがもつ属性が変更されても1箇所の変更で済み、使用する値がハードコードされている場合に比べテストの変更はとても楽です。
次に禁止ワードに関する処理についてテストを書いていきます。
まず、特定のPost
にマッチするForbiddenWord
、マッチしないForbiddenWord
を生成するGenを定義していきます。
// PostにマッチするForbiddenWordを生成する def postAndForbiddenWordMatchesGen(post: Post): Gen[ForbiddenWord] = (for { post <- arbitrary[Post] start <- Gen.choose(0, post.comment.length) end <- Gen.choose(start, post.comment.length) } yield ForbiddenWord(post.comment.substring(start, end))) .suchThat { word => post.contains(word) } // PostにマッチするForbiddenWordを含んだリストを生成する def forbiddenWordListMatchesGen(post: Post): Gen[List[ForbiddenWord]] = (for { matches <- postAndForbiddenWordMatchesGen(post) prefix <- Gen.listOf(arbitrary[ForbiddenWord]) postfix <- Gen.listOf(arbitrary[ForbiddenWord]) } yield prefix ++ List(matches) ++ postfix) .suchThat { words => words.exists(post.contains) } // PostにマッチしないForbiddenWordを生成する def forbiddenWordNotMatchesGen(post: Post): Gen[ForbiddenWord] = arbitrary[ForbiddenWord] .suchThat { forbiddenWord => !post.contains(forbiddenWord) } // PostにマッチしないForbiddenWordのリストを生成する def forbiddenWordListNotMatchesGen(post: Post): Gen[List[ForbiddenWord]] = Gen.listOf(forbiddenWordNotMatchesGen(post))
suchThat
で求める性質を明示することで、テストが失敗する最小の値を探すShrink
という仕組みによって、求める性質を満たさない値が意図せず生成されるのを防いでいます。
これを用いて、テストは以下のように記述します。
"依存コンポーネントが正常に処理を終了する時" should { "禁止ワードが含まれないポストは正常に投稿される" in new Context { forAll {(post: Post) => forAll(forbiddenWordListNotMatchesGen(post), minSuccessful(1)) { forbiddenWords: Seq[ForbiddenWord] => when(forbiddenWordsLoaderMock.load()).thenReturn(Success(forbiddenWords)) when(postDaoMock.create(post)).thenReturn(Success(())) val result = postService.doPost(post) verify(postDaoMock, times(1)).create(post) result shouldBe Success(()) } } } "禁止ワードが含まれたポストは投稿されない" in new Context { forAll { (post: Post) => forAll(forbiddenWordListMatchesGen(post), minSuccessful(1)) { forbiddenWords: Seq[ForbiddenWord] => when(forbiddenWordsLoaderMock.load()).thenReturn(Success(forbiddenWords)) val result = postService.doPost(post) verify(postDaoMock, never()).create(post) result shouldBe Failure(ForbiddenWordIsContainedException()) } } } }
今回は生成される値に満たしてほしい条件があるパターンでしたが、こちらも値をハードコードすることなくテストを記述できました。
また、今回はネストの内側のforAllは外側のforAllと同じように繰り返す必要はないので、minSuccessful(1)
を指定しました。
まとめ
ScalaCheck
とScalaTest
を組み合わせてテストを実施する例をいくつかご紹介しました。GeneratorDrivenPropertyChecks
を使用することで比較的簡単にProperty-Based Testingを導入できるのがお分かりいただけたと思います。今回は紹介しませんでしたが、ScalaCheck
にはステートフルな対象に対するテストをするためのCommands
という興味深い仕組みも用意されています。これについても別の機会で紹介していけたらなと思っています。
今回のコード
https://github.com/cm-yamasaki-michihiro/pbt-example
参考文献
- ScalaTestのドキュメント
- 日本語のスライド
- F#ですがPBTについてとても詳しく解説してくれている記事
使用したライブラリ
変更履歴
- 2017/11/26 suchThatを適切に使用するようにコードを大幅に修正しました。
- 2017/12/17 forAllをネストして使用するように修正しました。