テストコードを楽に書きたい
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.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、単にテストデータの自動生成する程度にカジュアルに使えるので、テストコード書くのを楽にしていってみましょう。
from: https://qiita.com/petitviolet/items/ddf7419b0aed3098bd0c