petitviolet blog

    テストコードを楽に書きたい

    2017-12-04

    QiitaScalaunittestScalaCheckproperty-based-testing

    この記事はなに

    Property Based Testing は何となく難しくて普通のアプリケーション開発におけるテストコードには使用しないイメージかも知れないが、単にテストデータ自動生成器くらいに捉えてカジュアルに使ってみてはいかがでしょうか?という紹介です。 言語には Scala を用いますが、他言語でも探せばライブラリやフレームワークは見つかると思います。

    UnitTest って面倒くさいですよね

    JUnit とか ScalaTest で書いているテストは Example Based Testing と呼ばれるもので、いわゆる UnitTest が指しているもののはず。 名前の通り、あるテストデータを用意してそれに対して条件を満たすかどうか判定するテストのこと。

    Scala のような静的型付け言語はコンパイルが通れば動くことはおおよそ保証出来るため、 UnitTest の対象としてはドメインロジックの実装が正しいかどうかのテストになることが多いはず。

    関数やメソッドに入力を与えて得られる出力が妥当であるかどうかを判断するケースを書くことになる。 でもテストのための入力、つまりテストデータを用意するのって面倒くさいですよね...?

    そこで Property Based Testing を使ってみるとちょっと楽になるかも。

    Property Based Testing って?

    テストデータをランダムに生成して、生成された値に対して条件を満たすかどうか判定するテスト。 簡単にいうとテストデータを生成するためのルールだけ記述すれば、具体的なテストデータについては考えなくて良くなる。

    どんな感じ?

    Scala + rickynils/scalacheck + ScalaTest で書くとこんな雰囲気。

    forAll(Gen.alphaStr) { alphaStr: String =>
      alphaStr shouldBe alphaStr.reverse.reverse
    }
    

    英字からなる文字列を自動生成したものと、それを reverse して reverse したものが全て等しくなることをテストしている。 forAllに対してGen[A]を渡すとAが自動生成され、そのAを使ったブロック内のテストを実行・評価する。

    Gen[A]だけでなくArbitrary[A]というのもあって、それなら implicit で渡すことが出来る。

    implicit val arbStr = Arbitrary { Gen.alphaStr }
    forAll { alphaStr: String =>
      alphaStr shouldBe alphaStr.reverse.reverse
    }
    

    forAllに明示的にGenを渡すかArbitraryを implicit に渡すかで特に挙動は変わらないので、お好みでいいはず。

    Property Based Testing は何が嬉しいのか

    私見ですが、ざっとこんな感じ。

    • データの生成を自動化出来る
      • 生成のルールさえ記述すれば良い
      • テストデータが 1 つ 1 つ正しいかどうかチェックしなくてよい
      • 生成のルールさえ確認すれば良い
        • コードレビューも楽になる
    • 取りうる値に対する認識が深まる
      • 生成ルールを作るためには境界条件を知っていないといけない
      • 自動生成したデータに勝手に境界値が入ってくれる
      • 結果としてドメインに対する理解も深まる

    書くための準備

    Scala の PropertyBasedTesting フレームワークとして今回はrickynils/scalacheckを使う。

    依存の追加

    UT の一部として PropertyBasedTest を記述できるように、ScalaTest と併用する。 詳しくはScalaTest のドキュメントを参照。

    build.sbt に以下を書く。

    libraryDependencies ++= Seq(
      "org.scalatest" %% "scalatest" % "3.0.4" % Test,
      "org.scalacheck" %% "scalacheck" % "1.13.4" % Test
    )
    

    これで他のテスト(XxxSpec)を書きつつ、必要に応じて PropertyBasedTest を入れていける。

    基底クラスの実装

    ScalaTest と ScalaCheck を併用するための準備として UT を書くクラスの継承元にあたるクラスを定義しておく。 今回は ScalaCheck のGenを使ってデータ生成したいので、GeneratorDrivenPropertyChecksを extends する。 大体こんな感じになるはず。

    import org.scalacheck.Gen
    import org.scalatest._
    import org.scalatest.prop.GeneratorDrivenPropertyChecks
    
    class MyPropertyBasedSpec extends FeatureSpec
      with GeneratorDrivenPropertyChecks with Matchers {
      feature("feature") {
        scenario("scenario") {
          forAll(Gen.alphaStr) { alphaStr: String =>
            alphaStr shouldBe alphaStr.reverse.reverse
          }
        }
      }
    }
    

    sbt testで普通にテストを実行すれば Success する。

    どんな感じ? 2nd

    もうちょっとそれっぽい例。

    object Age {
      def create(value: Int): Option[Age] =
        if (value >= 0 && value <= 120) { // バリデーション
          Some(new Age(value))
        } else None
      }
    }
    

    このAge.createをテストしたい。 つまり、Age.createは入力が 0~120 の値ならSomeでそうでないならNoneを返すことをテストしたい。

    PropertyBasedTesting なスタイルで書いてみるとこんな感じ。

    feature("Age with Property") {
      scenario("create valid age") { // 成功ケース
        forAll(Gen.chooseNum(0, 120)) { value: Int =>
          Age.create(value).isDefined shouldBe true
        }
      }
    
      scenario("create invalid age") { // 失敗ケース
        val gen = Gen.oneOf(
          Gen.negNum[Int],   // 負数
          Gen.posNum[Int].suchThat { _ > 120 } // 正数かつ120より大きい
        )
        forAll(gen) { value: Int =>
          Age.create(value).isDefined shouldBe false
        }
      }
    }
    

    何をしているかは何となく察せるはず。 今回だとバリデーションを通る/通らない"具体的な"値は用意せずに、ルールだけを記述してある。

    ここでGen#suchThatを使っているが、特定の条件を満たす値を生成したい場合にはこれを使うと良い。 あるいは、Whenever#wheneverを使うと良い。 こちらは生成された値を filter して、特定の条件を満たすものだけでテストを実施する。

    いろんなものを生成してみる

    ベースになるGenの実装はscalacheck/Gen.scalaを参照。

    文字列の自動生成

    まずはシンプルに文字列を生成して組み合わせてみる

    scenario("Gen.alphaStr") {
      // 英字と数字を生成
      forAll(Gen.alphaLowerStr, Gen.numStr) { (alpha, num) =>
        whenever(alpha.nonEmpty && num.nonEmpty) { // filter的な処理
          (alpha + num).matches("^[a-z]+[0-9]+$") shouldBe true
        }
      }
    }
    

    java.util.Calendarの自動生成

    こんなのもある

    scenario("Gen.calendar") {
      // java.util.Calendarを生死絵
      forAll(Gen.calendar) { calendar: Calendar =>
        whenever(calendar.get(Calendar.ERA) == GregorianCalendar.AD) {
          // なんか適当なテスト
          val before = calendar.get(Calendar.YEAR)
          calendar.add(Calendar.YEAR, 100)
          val after = calendar.get(Calendar.YEAR)
          (before + 100) shouldBe after
        }
      }
    }
    

    電話番号を自動生成

    Gen.listOfNGen.numCharのあわせ技でやれる

    scenario("Telephone") {
      // for式で合成できる
      val telGen = for {
        z <- Gen.const(0)
        n1 <- Gen.listOfN(2, Gen.numChar)
        n2 <- Gen.listOfN(4, Gen.numChar)
        n3 <- Gen.listOfN(4, Gen.numChar)
      } yield { s"$z${n1.mkString}-${n2.mkString}-${n3.mkString}" }
    
      forAll(telGen) { tel =>
        println(s"tel: $tel")
        tel.matches("\\d+{3}-\\d+{4}-\\d+{4}") shouldBe true
      }
    }
    

    こんな感じで生成される

    tel: 068-3959-7993
    tel: 032-3444-9758
    tel: 088-2730-2448
    tel: 026-6474-0285
    tel: 054-0625-8596
    tel: 081-8973-9863
    

    独自クラスの自動生成

    ↑ の電話番号に対応するクラスを生成してみる

    case class TelephoneNumber(value: String)
    implicit val telArbitrary = Arbitrary {
      for {
        z <- Gen.const(0)
        n1 <- Gen.listOfN(2, Gen.numChar)
        n2 <- Gen.listOfN(4, Gen.numChar)
        n3 <- Gen.listOfN(4, Gen.numChar)
      } yield { TelephoneNumber(s"$z${n1.mkString}-${n2.mkString}-${n3.mkString}") }
    }
    

    Arbitrary[TelephoneNumber]にしてみたので、forAllに渡さなくても型を指定すれば勝手にやってくれる。

    forAll { tel: TelephoneNumber =>
      println(s"tel: $tel")
      tel.value.matches("\\d+{3}-\\d+{4}-\\d+{4}") shouldBe true
    }
    

    決まった文字数の文字列を生成する

    パスワードって 8 文字-30 文字だったりする。

    feature("string generator") {
      scenario("between 8 and 30") {
        val strGen: Gen[String] = for {
          n <- Gen.chooseNum(8, 30) // 8-30で文字数を適当に選択
          cs <- Gen.listOfN(n, Gen.alphaNumChar) // その長さのList[Char]を生成
        } yield {
          cs.mkString
        }
    
        forAll(strGen) { (s: String) =>
          s.length >= 8 shouldBe true
          s.length <= 30 shouldBe true
        }
      }
    }
    

    それっぽい例

    さっきの生成器を使ってパスワードのテストしてみる。 scalaz のValidationを使って

    import scalaz._; import Scalaz._
    
    object Password {
      private val spec: String => ValidationNel[String, Unit] = { s: String =>
        if (s.length >= 8 && s.length <= 30) ().success
        else "password length must be between 8 and 30".failureNel
      }
    
      def create(raw: String): ValidationNel[String, Password] = {
        spec.apply(raw) match {
          case Success(b) => Password(raw).success
          case Failure(msgs) => msgs.failure
        }
      }
    }
    

    テストを書く

    まずは前述の長さ指定で文字列を生成するGen[String]を作る関数を実装する。

    def strGenWithMinMax(min: Int, max: Int): Gen[String] = for {
      n <- Gen.chooseNum(min, max) // min~maxな長さを指定
      cs <- Gen.listOfN(n, Gen.alphaNumChar) // その長さのList[Char]を生成
    } yield {
      cs.mkString
    }
    

    これを使ったテストはだいたいこんな感じ。

    scenario("success with proper string") {
      val properStrGen = strGenWithMinMax(8, 30) // 8-30文字
      forAll(properStrGen) { (s: String) =>
        Password.create(s).isSuccess shouldBe true
      }
    }
    scenario("fail with non-proper string") {
      val nonProperStrGen = Gen.oneOf(
        strGenWithMinMax(0, 7), // 0-7文字
        strGenWithMinMax(31, 100) // 31~文字
      )
      forAll(nonProperStrGen) { (s: String) =>
        Password.create(s).isFailure shouldBe true
      }
    }
    

    テストが Gave Up する問題

    例えば以下のようなテストは、本来パスするはずがこけてしまう。

    scenario("discarded") {
      forAll { s: String =>
        whenever(s.length > 10000) {
          s.length > 10000 shouldBe true
        }
      }
    }
    

    実行するとこける。

    [info] Scenario: discarded _ FAILED _ > [info] Gave up after 0 successful property evaluations. 51 evaluations were discarded.

    なぜかというと、生成した値に対してテストが何も実行されず捨てられてしまっているため、 結果として生成したデータに対してテストがパスしていないことになっている。 ちなみにこれはsuchThatでもwhenverでもどちらでも発生する。

    テスト実行の設定

    本当は前述したGen.listOfNなどを用いて実装するほうが良いが、テスト実行の設定を変更することでも回避できる。 ドキュメントによると以下のように書けばテスト実行時の設定を変更できる。

    implicit val generatorDrivenConfig = PropertyCheckConfig(minSize = 10, maxSize = 20)
    

    詳しい項目はドキュメントおよび実装を参照。

    scalatest/Generator.scalaのあたりの実装を見ると、指定したminSizesizeRangeから生成する文字列長を決定している様子。

    が、思うように適用されておらずここはいまいちわかっていない...。

    scenario("discarded") {
      // `generatorDrivenConfig`という名前で定義する
      implicit val generatorDrivenConfig = PropertyCheckConfiguration(
        minSize = PosZInt(10001)
      )
      forAll { s: String =>
        whenever(s.length > 10000) {
          s.length > 10001 shouldBe true
        }
      }
    }
    

    これだと 10001 以上の length の String が生成されてテストは discard されないはずなのだが、同様のエラーでこけてしまう。 minSize = PosZInt(100001)くらいにするとパスする...。 ともかく、実行に関して設定出来るのでおぼえておくとよい。

    たとえばminSuccessfulはデフォルトで 10 なので 10 回成功したらパスしたと見なすが、もう少し厳重にチェックしたいのであれば数を大きくすれば良い。

    まとめ

    Property Based Testing、単にテストデータの自動生成する程度にカジュアルに使えるので、テストコード書くのを楽にしていってみましょう。

    from: https://qiita.com/petitviolet/items/ddf7419b0aed3098bd0c