第5章: プロパティベーステスト¶
はじめに¶
従来の単体テストでは、特定の入力に対する期待される出力を検証します。一方、プロパティベーステストでは、すべての入力に対して成り立つべき「性質(プロパティ)」を定義し、ランダムに生成された多数のテストケースで検証します。
本章では、Scala の ScalaCheck ライブラリを使ったプロパティベーステストの手法を学びます。
1. プロパティベーステストとは¶
従来のテストとの違い¶
// 従来のテスト:特定の入力に対する出力を検証
test("reverseString should reverse a specific string") {
StringOperations.reverseString("hello") shouldBe "olleh"
StringOperations.reverseString("") shouldBe ""
StringOperations.reverseString("a") shouldBe "a"
}
// プロパティベーステスト:性質を検証
test("文字列反転は対合(involutory): 2回反転すると元に戻る") {
forAll { (s: String) =>
StringOperations.reverseString(StringOperations.reverseString(s)) shouldBe s
}
}
プロパティベーステストの利点¶
- 網羅性: 手動では思いつかないエッジケースを発見
- ドキュメント性: コードの性質を明確に表現
- 回帰防止: リファクタリング時の安全網
- シュリンキング: 失敗時に最小の反例を提示
2. ScalaCheck の基本¶
セットアップ¶
// build.sbt に追加
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.2.17" % Test,
"org.scalatestplus" %% "scalacheck-1-17" % "3.2.17.0" % Test,
"org.scalacheck" %% "scalacheck" % "1.17.0" % Test
)
基本的な使い方¶
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import org.scalacheck.Gen
class MySpec extends AnyFunSuite with Matchers with ScalaCheckPropertyChecks:
test("プロパティの例") {
forAll { (s: String) =>
s.reverse.reverse shouldBe s
}
}
3. 基本的なジェネレータ¶
プリミティブジェネレータ¶
import org.scalacheck.Gen
// 整数
Gen.posNum[Int] // 正の整数
Gen.negNum[Int] // 負の整数
Gen.chooseNum(0, 100) // 範囲指定
// 文字列
Gen.alphaStr // 英字のみ
Gen.alphaNumStr // 英数字のみ
Gen.numStr // 数字のみ
// その他
Gen.oneOf(true, false) // 列挙
Gen.oneOf("a", "b", "c") // 選択
Gen.option(Gen.posNum[Int]) // Option型
Gen.uuid // UUID
コレクションジェネレータ¶
// リスト
Gen.listOf(Gen.posNum[Int]) // 任意長
Gen.listOfN(5, Gen.posNum[Int]) // 固定長
Gen.nonEmptyListOf(Gen.posNum[Int]) // 非空
// マップ
Gen.mapOf(Gen.alphaStr -> Gen.posNum[Int])
// セット
Gen.containerOf[Set, Int](Gen.posNum[Int])
4. カスタムジェネレータ¶
ドメインモデルのジェネレータ¶
// 会員ランク
enum Membership:
case Bronze, Silver, Gold, Platinum
val membershipGen: Gen[Membership] = Gen.oneOf(
Membership.Bronze, Membership.Silver,
Membership.Gold, Membership.Platinum
)
// 人物データ
case class Person(name: String, age: Int, membership: Membership)
val personGen: Gen[Person] = for
name <- Gen.alphaNumStr.suchThat(_.nonEmpty)
age <- Gen.chooseNum(0, 150)
membership <- membershipGen
yield Person(name, age, membership)
// 商品データ
case class Product(productId: String, name: String, price: BigDecimal, quantity: Int)
val productGen: Gen[Product] = for
id <- Gen.posNum[Int].map(n => f"PROD-${n % 100000}%05d")
name <- Gen.alphaNumStr.suchThat(_.nonEmpty)
price <- Gen.posNum[Int].map(n => BigDecimal(1 + n % 10000))
quantity <- Gen.posNum[Int].map(n => 1 + n % 100)
yield Product(id, name, price, quantity)
ジェネレータの変換¶
// map: 値の変換
val upperCaseGen = Gen.alphaStr.map(_.toUpperCase)
// flatMap / for-comprehension: 依存関係のある生成
val subsetGen = for
list <- Gen.nonEmptyListOf(Gen.posNum[Int])
size <- Gen.chooseNum(1, list.length)
yield list.take(size)
// suchThat: フィルタリング(注意:効率が悪くなる可能性)
val positiveEvenGen = Gen.posNum[Int].suchThat(_ % 2 == 0)
// filter: suchThatのエイリアス
val nonEmptyStringGen = Gen.alphaStr.filter(_.nonEmpty)
5. プロパティの定義パターン¶
冪等性(Idempotency)¶
同じ操作を複数回適用しても結果が変わらない性質。
test("ソートは冪等: 2回ソートしても結果は同じ") {
forAll { (nums: List[Int]) =>
NumberOperations.sortNumbers(NumberOperations.sortNumbers(nums)) shouldBe
NumberOperations.sortNumbers(nums)
}
}
test("大文字変換は冪等") {
forAll { (s: String) =>
StringOperations.toUpperCase(StringOperations.toUpperCase(s)) shouldBe
StringOperations.toUpperCase(s)
}
}
対合性(Involution)¶
2回適用すると元に戻る性質。
test("文字列反転は対合: 2回反転すると元に戻る") {
forAll { (s: String) =>
StringOperations.reverseString(StringOperations.reverseString(s)) shouldBe s
}
}
test("リスト反転は対合") {
forAll { (list: List[Int]) =>
CollectionOps.reverse(CollectionOps.reverse(list)) shouldBe list
}
}
不変量(Invariant)¶
操作の前後で保存される性質。
test("ソートは要素を保存する") {
forAll { (nums: List[Int]) =>
NumberOperations.sortNumbers(nums).groupBy(identity).view.mapValues(_.size).toMap shouldBe
nums.groupBy(identity).view.mapValues(_.size).toMap
}
}
test("ソートは長さを保存する") {
forAll { (nums: List[Int]) =>
NumberOperations.sortNumbers(nums).length shouldBe nums.length
}
}
境界条件(Boundary Conditions)¶
test("割引後の価格は0以上、元の価格以下") {
forAll(
Gen.chooseNum(0, 10000).map(BigDecimal(_)),
Gen.chooseNum(0.0, 1.0)
) { (price, rate) =>
val discounted = PricingLogic.calculateDiscount(price, rate)
discounted should be >= BigDecimal(0)
discounted should be <= price
}
}
6. ラウンドトリッププロパティ¶
エンコード/デコードの可逆性は典型的なプロパティです。
object RunLengthEncoding:
/** ランレングス符号化 */
def encode(s: String): List[(Char, Int)] =
if s.isEmpty then List.empty
else
s.foldLeft(List.empty[(Char, Int)]) { (acc, char) =>
acc match
case Nil => List((char, 1))
case (lastChar, count) :: rest if lastChar == char =>
(lastChar, count + 1) :: rest
case _ => (char, 1) :: acc
}.reverse
/** ランレングス復号化 */
def decode(encoded: List[(Char, Int)]): String =
encoded.map { case (char, count) => char.toString * count }.mkString
test("ランレングス符号化は可逆") {
forAll(Gen.alphaStr) { s =>
RunLengthEncoding.decode(RunLengthEncoding.encode(s)) shouldBe s
}
}
test("Base64エンコード/デコードは可逆") {
forAll { (s: String) =>
Base64Codec.decode(Base64Codec.encode(s)) shouldBe s
}
}
7. 代数的性質¶
モノイドの法則¶
trait Monoid[A]:
def empty: A
def combine(x: A, y: A): A
object Monoid:
given intAddition: Monoid[Int] with
def empty: Int = 0
def combine(x: Int, y: Int): Int = x + y
given stringConcat: Monoid[String] with
def empty: String = ""
def combine(x: String, y: String): String = x + y
given listConcat[A]: Monoid[List[A]] with
def empty: List[A] = List.empty
def combine(x: List[A], y: List[A]): List[A] = x ++ y
// 結合律のテスト
test("Int加算モノイドの結合律") {
import Monoid.given
forAll { (a: Int, b: Int, c: Int) =>
val m = summon[Monoid[Int]]
m.combine(m.combine(a, b), c) shouldBe m.combine(a, m.combine(b, c))
}
}
// 単位元のテスト
test("Int加算モノイドの単位元") {
import Monoid.given
forAll { (a: Int) =>
val m = summon[Monoid[Int]]
m.combine(a, m.empty) shouldBe a
m.combine(m.empty, a) shouldBe a
}
}
算術演算の性質¶
test("加算の結合律") {
forAll(Gen.chooseNum(-1000, 1000), Gen.chooseNum(-1000, 1000), Gen.chooseNum(-1000, 1000)) {
(a, b, c) =>
((a + b) + c) shouldBe (a + (b + c))
}
}
test("加算の交換律") {
forAll { (a: Int, b: Int) =>
(a + b) shouldBe (b + a)
}
}
test("加算の単位元") {
forAll { (a: Int) =>
(a + 0) shouldBe a
}
}
8. オラクルテスト¶
既知の正しい実装(標準ライブラリなど)と比較します。
test("sortNumbersは標準ライブラリのsortと同じ結果") {
forAll { (nums: List[Int]) =>
NumberOperations.sortNumbers(nums) shouldBe nums.sorted
}
}
test("distinctは標準ライブラリのdistinctと同じ結果") {
forAll { (list: List[Int]) =>
CollectionOps.distinct(list) shouldBe list.distinct
}
}
test("reverseは標準ライブラリのreverseと同じ結果") {
forAll { (list: List[Int]) =>
CollectionOps.reverse(list) shouldBe list.reverse
}
}
9. コレクション操作のプロパティ¶
filter の性質¶
test("filterは長さを減らすか維持する") {
forAll { (list: List[Int]) =>
CollectionOps.filter(list)(_ > 0).length should be <= list.length
}
}
test("filter(常にtrue)は元のリストと同じ") {
forAll { (list: List[Int]) =>
CollectionOps.filter(list)(_ => true) shouldBe list
}
}
test("filter(常にfalse)は空リスト") {
forAll { (list: List[Int]) =>
CollectionOps.filter(list)(_ => false) shouldBe List.empty
}
}
map の性質¶
test("mapは長さを保存する") {
forAll { (list: List[Int]) =>
CollectionOps.map(list)(_ * 2).length shouldBe list.length
}
}
test("map(identity)は元のリストと同じ") {
forAll { (list: List[Int]) =>
CollectionOps.map(list)(identity) shouldBe list
}
}
// ファンクター則:map(f).map(g) == map(f andThen g)
test("map(f).map(g) == map(f andThen g)") {
val f: Int => Int = _ + 1
val g: Int => Int = _ * 2
forAll { (list: List[Int]) =>
CollectionOps.map(CollectionOps.map(list)(f))(g) shouldBe
CollectionOps.map(list)(f andThen g)
}
}
concat の性質¶
test("concatの結合律") {
forAll { (a: List[Int], b: List[Int], c: List[Int]) =>
CollectionOps.concat(CollectionOps.concat(a, b), c) shouldBe
CollectionOps.concat(a, CollectionOps.concat(b, c))
}
}
test("concatの長さは入力の長さの合計") {
forAll { (a: List[Int], b: List[Int]) =>
CollectionOps.concat(a, b).length shouldBe (a.length + b.length)
}
}
10. ビジネスロジックのプロパティ¶
test("注文合計は常に非負") {
forAll(orderGen) { order =>
PricingLogic.calculateOrderTotal(order) should be >= BigDecimal(0)
}
}
test("最終価格は注文合計以下(会員割引適用後)") {
forAll(orderGen, membershipGen) { (order, membership) =>
val total = PricingLogic.calculateOrderTotal(order)
val finalPrice = PricingLogic.calculateFinalPrice(order, membership)
finalPrice should be <= total
}
}
test("Platinumは最大の割引を受ける") {
forAll(orderGen) { order =>
val platinumPrice = PricingLogic.calculateFinalPrice(order, Membership.Platinum)
val bronzePrice = PricingLogic.calculateFinalPrice(order, Membership.Bronze)
platinumPrice should be <= bronzePrice
}
}
11. バリデーションのプロパティ¶
ジェネレータで有効な入力を生成し、バリデーションを通過することを確認します。
val validEmailGen: Gen[String] = for
local <- Gen.alphaNumStr.suchThat(_.nonEmpty)
domain <- Gen.alphaNumStr.suchThat(_.nonEmpty)
tld <- Gen.oneOf("com", "org", "net", "io", "jp")
yield s"$local@$domain.$tld"
test("生成された有効なメールアドレスはバリデーションを通過する") {
forAll(validEmailGen) { email =>
Validation.isValidEmail(email) shouldBe true
}
}
val validPhoneGen: Gen[String] = for
length <- Gen.chooseNum(10, 15)
digits <- Gen.listOfN(length, Gen.numChar)
yield digits.mkString
test("生成された有効な電話番号はバリデーションを通過する") {
forAll(validPhoneGen) { phone =>
Validation.isValidPhoneNumber(phone) shouldBe true
}
}
Clojure との比較¶
| 概念 | Clojure (test.check) | Scala (ScalaCheck) |
|---|---|---|
| ジェネレータ | gen/string, gen/int |
Gen.alphaStr, Gen.posNum[Int] |
| 範囲指定 | gen/choose |
Gen.chooseNum |
| 列挙 | gen/elements |
Gen.oneOf |
| コレクション | gen/vector, gen/list |
Gen.listOf, Gen.containerOf |
| 変換 | gen/fmap |
Gen.map / for-comprehension |
| 依存関係 | gen/bind |
Gen.flatMap / for-comprehension |
| フィルタ | gen/such-that |
Gen.suchThat |
| プロパティ定義 | prop/for-all |
forAll |
| 実行 | tc/quick-check |
ScalaTest統合 |
まとめ¶
本章では、プロパティベーステストについて学びました:
- ジェネレータ: テストデータの自動生成
- プリミティブ: 整数、文字列、ブール値など
- コレクション: リスト、マップ、セット
- 変換: map, flatMap, suchThat による加工
- プロパティ: すべての入力で成り立つべき性質
- パターン: 冪等性、対合性、不変量、ラウンドトリップ
- 代数的性質: モノイド則、ファンクター則
プロパティベーステストは、従来のテストを補完し、より堅牢なソフトウェアを実現します。
参考コード¶
本章のコード例は以下のファイルで確認できます:
- ソースコード:
apps/scala/part2/src/main/scala/PropertyBasedTesting.scala - テストコード:
apps/scala/part2/src/test/scala/PropertyBasedTestingSpec.scala
次章予告¶
次章では、テスト駆動開発と関数型プログラミングについて学びます。Red-Green-Refactor サイクルを関数型スタイルで実践する方法を探ります。