ScalaCheck + ScalaTest で Property-Based Testing する

79件のシェア(ちょっぴり話題の記事)

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、山崎です。

ひとつのテストをたくさんのテストデータに対して実施したい時、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を使用することで、ランダムに生成されたIntList[Int]の値を受け取ってテストを行っています。forAllに渡したテストの関数が(2, List()), (4, List(1, 4)のようなランダムな値で順番に呼び出されることでテストが実行されるイメージです。

また、上記コードでお分かりいただけるように、forAllを使用しない従来のScalaTestの書き方と混在させることができます。

独自の型にジェネレータを定義

上の例ではIntList[Int]型の生成された値を用いてテストを行うことができました。これはIntList[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で宣言しておけば、合計のテストの例でIntList[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)を指定しました。

まとめ

ScalaCheckScalaTestを組み合わせてテストを実施する例をいくつかご紹介しました。GeneratorDrivenPropertyChecksを使用することで比較的簡単にProperty-Based Testingを導入できるのがお分かりいただけたと思います。今回は紹介しませんでしたが、ScalaCheckにはステートフルな対象に対するテストをするためのCommandsという興味深い仕組みも用意されています。これについても別の機会で紹介していけたらなと思っています。

今回のコード

https://github.com/cm-yamasaki-michihiro/pbt-example

参考文献

使用したライブラリ

変更履歴

  • 2017/11/26 suchThatを適切に使用するようにコードを大幅に修正しました。
  • 2017/12/17 forAllをネストして使用するように修正しました。