この記事はなに
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.listOfN
とGen.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のあたりの実装を見ると、指定したminSize
とsizeRange
から生成する文字列長を決定している様子。
が、思うように適用されておらずここはいまいちわかっていない...。
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、単にテストデータの自動生成する程度にカジュアルに使えるので、テストコード書くのを楽にしていってみましょう。