第12章: テスト戦略とプロパティベーステスト — 11言語比較¶
12.1 はじめに¶
第 11 章までで、関数型プログラミングの基礎から並行処理まで幅広い概念を学んできました。本章では、FP の最大の恩恵の一つであるテスト容易性に焦点を当てます。
純粋関数は同じ入力に対して常に同じ出力を返すため、テストが驚くほど簡単になります。しかし、特定のテストケースを手で書くだけでは、見落としているエッジケースがあるかもしれません。そこで登場するのがプロパティベーステスト(PBT) です。ランダムな入力を自動生成し、関数が満たすべき不変条件(プロパティ) を検証します。
本章では、11 言語それぞれの PBT ライブラリを比較し、ジェネレータの合成、プロパティの定義、Shrinking(最小反例の探索)といった共通概念がどのように表現されるかを見ていきます。
12.2 共通の本質 — テストの 3 層構造¶
FP テストの基本戦略¶
すべての言語に共通する FP テストのアプローチは、3 つの層で構成されます。
第 1 層: 純粋関数の単体テスト — 入力と出力だけを検証すればよく、セットアップもモックも不要です。FP では関数の大部分が純粋なため、この層が最も厚くなります。
第 2 層: プロパティベーステスト — ランダムな入力を生成し、「結果のサイズは入力以下」「すべての結果が条件を満たす」といった不変条件を検証します。手書きのテストケースでは発見できないエッジケースを自動的に発見します。
第 3 層: 統合テスト(スタブ活用) — DataAccess のような外部依存をスタブに差し替え、コンポーネント間の連携をテストします。FP では副作用が明示的に分離されているため、スタブの作成が容易です。
PBT の 3 要素¶
プロパティベーステストは、どの言語でも以下の 3 要素で構成されます。
- ジェネレータ(Generator) — テスト入力をランダムに生成する仕組み
- プロパティ(Property) — 入力に対して常に成り立つべき不変条件
- Shrinking — テスト失敗時に最小の反例を見つける仕組み
12.3 PBT ライブラリの実装方式¶
11 言語の PBT ライブラリは、以下の 3 つのアプローチに分類できます。
アプローチ 1: 専用 PBT フレームワーク(型クラス/プロトコル統合)¶
言語の型システムと深く統合された専用 PBT フレームワークです。ジェネレータの自動導出や Shrinking の自動化が特徴です。
| 言語 | ライブラリ | ジェネレータ型 | 特徴 |
|---|---|---|---|
| Haskell | QuickCheck | Arbitrary a |
PBT の元祖。型クラスで自動導出 |
| Scala | ScalaCheck | Gen[A] |
for 内包表記でジェネレータ合成 |
| F# | FsCheck | Gen<'a> |
.NET 向け QuickCheck ポート |
| C# | FsCheck | Gen<T> |
F# と共通の FsCheck エコシステム |
| Clojure | test.check | gen/fmap |
Clojure 版 QuickCheck |
| Elixir | StreamData | StreamData.t() |
Stream ベースのジェネレータ |
アプローチ 2: マクロ/DSL ベースの PBT¶
マクロや DSL を使って、ジェネレータとプロパティを宣言的に記述します。
| 言語 | ライブラリ | 記述方式 | 特徴 |
|---|---|---|---|
| Rust | proptest | proptest! マクロ |
範囲式でジェネレータを記述 |
| Python | Hypothesis | @given デコレータ |
戦略(Strategy)ベース |
| TypeScript | fast-check | fc.property() |
Arbitrary ベースのジェネレータ |
アプローチ 3: 手動ジェネレータ + 反復テスト¶
専用 PBT ライブラリを使わず、手動でランダムデータを生成して反復テストします。
| 言語 | 方式 | 記述方式 | 特徴 |
|---|---|---|---|
| Java | @RepeatedTest + 手動 |
JUnit 5 の反復テスト | 標準機能のみで PBT 的テスト |
| Ruby | 手動ジェネレータ | カスタム関数 | 軽量で依存なし |
12.4 ジェネレータの合成 — 全 11 言語比較¶
PBT の核心はジェネレータの合成です。基本型のジェネレータを組み合わせて、ドメインオブジェクトのジェネレータを構築します。ここでは Location のジェネレータを各言語で比較します。
代表 3 言語の詳細比較¶
Haskell — QuickCheck の Arbitrary 型クラス:
instance Arbitrary LocationId where
arbitrary = LocationId <$> listOf1 (elements ['a'..'z'])
instance Arbitrary Location where
arbitrary = Location
<$> arbitrary -- LocationId を自動生成
<*> listOf1 (elements ['A'..'Z']) -- name
<*> (abs <$> arbitrary) -- population (非負)
Haskell では Arbitrary 型クラスのインスタンスを定義するだけで、QuickCheck が自動的にランダムデータを生成します。<$> と <*> で Applicative に合成するパターンが特徴的です。
Scala — ScalaCheck の for 内包表記:
val locationGen: Gen[Location] = for {
id <- Gen.alphaNumStr.map(LocationId.apply)
name <- Gen.alphaStr
population <- Gen.posNum[Int]
} yield Location(id, name, population)
Scala では for 内包表記でジェネレータを合成します。Gen.posNum や Gen.alphaStr といった便利なビルトインジェネレータが豊富です。
Rust — proptest の範囲式:
proptest! {
#[test]
fn sort_preserves_elements(
populations in prop::collection::vec(0i32..10_000_000, 0..10)
) {
let locations: Vec<Location> = populations.iter().enumerate()
.map(|(i, &pop)| Location::new(
LocationId::new(&format!("loc{}", i)),
&format!("City{}", i),
pop,
))
.collect();
let sorted = sort_by_population(locations.clone());
prop_assert_eq!(sorted.len(), locations.len());
}
}
Rust の proptest では 0i32..10_000_000 のような範囲式でジェネレータを簡潔に記述できます。
全 11 言語のジェネレータ比較¶
Haskell — QuickCheck
instance Arbitrary LocationId where
arbitrary = LocationId <$> listOf1 (elements ['a'..'z'])
instance Arbitrary Location where
arbitrary = Location
<$> arbitrary
<*> listOf1 (elements ['A'..'Z'])
<*> (abs <$> arbitrary)
Scala — ScalaCheck
val locationIdGen: Gen[LocationId] =
Gen.alphaNumStr.map(LocationId.apply)
val locationGen: Gen[Location] = for {
id <- locationIdGen
name <- Gen.alphaStr
population <- Gen.posNum[Int]
} yield Location(id, name, population)
Rust — proptest
// 範囲式で直接記述
proptest! {
#[test]
fn test_filter(
populations in prop::collection::vec(0i32..10_000_000, 0..10)
) {
// populations をもとに Location を構築
}
}
Python — Hypothesis
# Hypothesis の Strategy を使う場合
from hypothesis import given, strategies as st
@given(
locations=st.lists(st.builds(
Location,
id=st.from_type(LocationId),
name=st.text(min_size=1),
population=st.integers(min_value=0, max_value=10_000_000)
)),
min_pop=st.integers(min_value=0, max_value=10_000_000)
)
def test_filter_result_size(locations, min_pop):
filtered = filter_popular_locations(locations, min_pop)
assert len(filtered) <= len(locations)
def generate_location(min_population=0, max_population=10000000):
name = "".join(random.choices(string.ascii_letters, k=random.randint(3, 10)))
return Location(
id=LocationId(f"Q{random.randint(1, 1000000)}"),
name=name,
population=random.randint(min_population, max_population),
)
TypeScript — fast-check / カスタム Gen
// カスタム Gen(本書の実装)
const locationGen = Gen.location()
const location = locationGen() // ランダムな Location を生成
// fast-check を使う場合
import * as fc from 'fast-check'
const locationArb = fc.record({
id: fc.string().map(LocationId.of),
name: fc.string({ minLength: 1 }),
population: fc.nat({ max: 10_000_000 }),
})
Java — 手動ジェネレータ
static Location randomLocation() {
return new Location(
new LocationId("Q" + nonNegativeInt(1000000)),
randomString(),
nonNegativeInt(10_000_000)
);
}
F# — FsCheck
let locationGen = gen {
let! id = Arb.generate<string> |> Gen.filter (not << String.IsNullOrEmpty)
let! name = Arb.generate<string> |> Gen.filter (not << String.IsNullOrEmpty)
let! population = Gen.choose(0, 10_000_000)
return { Id = LocationId id; Name = name; Population = population }
}
C# — FsCheck
var locationGen = from id in Arb.Generate<string>().Where(s => !string.IsNullOrEmpty(s))
from name in Arb.Generate<string>().Where(s => !string.IsNullOrEmpty(s))
from pop in Gen.Choose(0, 10_000_000)
select new Location(LocationId.Create(id), name, pop);
Clojure — test.check
(def location-gen
(gen/fmap (fn [[id name pop]]
(location id name pop))
(gen/tuple
(gen/fmap #(str "Q" %) gen/nat)
gen/string-alphanumeric
(gen/choose 0 10000000))))
Elixir — StreamData
location_gen =
gen all id <- StreamData.string(:alphanumeric, min_length: 1),
name <- StreamData.string(:alphanumeric, min_length: 1),
population <- StreamData.integer(0..10_000_000) do
Location.new(LocationId.new(id), name, population)
end
Ruby — 手動ジェネレータ
def self.random_location
Location.new(
id: LocationId.new("Q#{rand(1..1_000_000)}"),
name: ('A'..'Z').to_a.sample(rand(3..10)).join,
population: rand(0..10_000_000)
)
end
12.5 プロパティの定義 — filterPopularLocations を例に¶
PBT の最も重要な要素はプロパティの定義です。filterPopularLocations という純粋関数を題材に、3 つの標準的なプロパティを 11 言語で比較します。
filterPopularLocations(locations, minPopulation)
→ locations のうち population >= minPopulation のものだけを返す
3 つの標準プロパティ¶
| プロパティ | 意味 |
|---|---|
| 結果サイズ ≤ 入力サイズ | フィルタは要素を増やさない |
| 全結果が条件充足 | 返された要素はすべて条件を満たす |
| 偽陰性なし | 条件を満たす要素はすべて結果に含まれる |
代表 3 言語での実装¶
Haskell:
it "result size <= input size" $
property $ \(locs :: [Location]) (minPop :: Int) ->
length (filterPopularLocations locs (abs minPop)) <= length locs
it "all results meet minimum" $
property $ \(locs :: [Location]) (minPop :: Int) ->
let filtered = filterPopularLocations locs (abs minPop)
in all (\loc -> locPopulation loc >= abs minPop) filtered
Scala:
property("result size <= input size") =
forAll(locationsGen, Gen.posNum[Int]) {
(locations: List[Location], minPop: Int) =>
filterPopularLocations(locations, minPop).size <= locations.size
}
property("all results meet minimum population") =
forAll(locationsGen, Gen.posNum[Int]) {
(locations: List[Location], minPop: Int) =>
filterPopularLocations(locations, minPop)
.forall(_.population >= minPop)
}
Rust:
proptest! {
#[test]
fn filter_preserves_or_reduces_size(
populations in prop::collection::vec(0i32..10_000_000, 0..20),
min_pop in 0i32..10_000_000
) {
let locations = make_locations(populations);
let filtered = filter_popular_locations(locations.clone(), min_pop);
prop_assert!(filtered.len() <= locations.len());
}
}
12.6 DataAccess スタブ — テスト可能な設計¶
FP のテスト戦略において、外部依存の分離は重要です。すべての言語で「DataAccess インターフェースを定義し、テスト時にスタブに差し替える」パターンが共通しています。
スタブ実装の 3 パターン¶
| パターン | 言語例 | 特徴 |
|---|---|---|
| trait/interface + 匿名実装 | Scala, Haskell, F# | 型安全、インターフェースベース |
| Builder パターン | Java, Rust | 柔軟な設定、エラー注入が容易 |
| レコード/マップ | Clojure, Elixir, Ruby | 軽量、動的型付けの利点を活用 |
Scala のスタブ実装:
val testDataAccess: DataAccess = new DataAccess {
def findAttractions(name: String, ordering: AttractionOrdering, limit: Int) =
IO.pure(List(Attraction("Test", Some("desc"), testLocation)))
def findArtistsFromLocation(locationId: LocationId, limit: Int) =
IO.pure(Right(List(MusicArtist("Test Artist"))))
def findMoviesAboutLocation(locationId: LocationId, limit: Int) =
IO.pure(Right(List(Movie("Test Movie"))))
}
Java の Builder パターン:
DataAccess dataAccess = TestDataAccess.builder()
.withAttractions(TOWER_BRIDGE)
.withArtists(QUEEN)
.failOnMovies("Timeout")
.build();
Clojure のレコード型:
(defrecord TestDataAccess [attractions artists movies hotels]
DataAccess
(find-attractions [_ name ordering limit]
{:ok (take limit (filter #(str/includes? (:name %) name) attractions))})
(find-artists-from-location [_ location-id limit]
{:ok (take limit artists)}))
12.7 SearchReport — テスト可観測性の向上¶
テスト可能性を高めるために導入される SearchReport は、検索の統計情報とエラー情報を保持する構造です。これにより、「検索は成功したが結果が不十分だった」ケースと「エラーが発生した」ケースを区別できます。
すべての言語で共通するパターン:
SearchReport {
attractionsSearched: Int -- 検索したアトラクション数
errors: List[String] -- 発生したエラーのリスト
}
エラーハンドリングとの組み合わせにより、部分的な成功を表現できます。
// Scala: エラーがあっても結果を返す
val errors = List(artistsResult, moviesResult).collect { case Left(e) => e }
val artists = artistsResult.getOrElse(Nil)
TravelGuide(attraction, subjects, SearchReport(attractions.size, errors))
-- Haskell: Either からエラーを収集
let errors = collectErrors [artistsResult, moviesResult]
let artists = either (const []) id artistsResult
// Rust: Result からエラーを収集
let mut errors = Vec::new();
if let Err(e) = &artists_result { errors.push(e.clone()); }
let artists = artists_result.unwrap_or_default();
12.8 比較分析 — 3 つの発見¶
発見 1: PBT 表現力の 3 段階¶
ジェネレータの合成と Shrinking の自動化度合いで、PBT ライブラリは 3 段階に分かれます。
| 段階 | 特徴 | 言語 |
|---|---|---|
| 完全自動 | 型からジェネレータ自動導出 + Shrinking 自動 | Haskell (QuickCheck), F#/C# (FsCheck) |
| 半自動 | ビルトインジェネレータ豊富 + Shrinking 対応 | Scala (ScalaCheck), Rust (proptest), Python (Hypothesis), Clojure (test.check), Elixir (StreamData), TypeScript (fast-check) |
| 手動 | ジェネレータ手書き + Shrinking なし | Java (@RepeatedTest), Ruby (手動) |
Haskell の QuickCheck は Arbitrary 型クラスにより、新しい型のジェネレータを定義するだけで自動的に Shrinking まで提供されます。一方、Java では JUnit 5 の @RepeatedTest と手動ジェネレータの組み合わせで PBT 的なテストを実現しますが、Shrinking はありません。
発見 2: テスト容易性と型システムの関係¶
テストのしやすさは、言語の型システムと副作用の管理方法に強く依存します。
Haskell では型システムが純粋関数と IO を厳密に分離するため、テスト対象の関数が純粋であることが型レベルで保証されます。
発見 3: DataAccess 抽象化の共通性¶
11 言語すべてで、外部依存をインターフェース/プロトコル/レコードで抽象化し、テスト時にスタブに差し替えるパターンが確認できました。
| 抽象化手段 | 言語 |
|---|---|
| trait / interface | Scala, Java, Rust, C# |
| 型クラス + レコード型 | Haskell |
| abstract class (ABC) | Python |
| object expression | F# |
| protocol / behaviour | Clojure, Elixir |
| module (mixin) | Ruby |
| interface (構造的型) | TypeScript |
FP の「副作用の明示的分離」原則が、テスト可能な設計を自然に導くことが、すべての言語で確認できます。
12.9 言語固有の特徴¶
Haskell — QuickCheck: PBT の原点¶
QuickCheck は 2000 年に発表された PBT の元祖です。Arbitrary 型クラスによる自動導出と、shrink 関数による最小反例の探索が最大の特徴です。
instance Arbitrary Attraction where
arbitrary = Attraction <$> arbitrary <*> arbitrary <*> arbitrary
shrink (Attraction n d l) =
[Attraction n' d l | n' <- shrink n] ++
[Attraction n d' l | d' <- shrink d]
Rust — proptest: 安全性と人間工学の両立¶
proptest はマクロベースの DSL により、範囲式でジェネレータを簡潔に記述できます。prop_assert! マクロでアサーションを行い、失敗時には自動的に Shrinking が行われます。
proptest! {
#[test]
fn validate_rejects_negative_population(
name in "[a-zA-Z]+",
population in i32::MIN..-1i32
) {
let location = Location::new(LocationId::new("test"), &name, population);
prop_assert!(!validate_location(location).is_valid());
}
}
Python — Hypothesis: 実用性重視のデコレータ方式¶
Hypothesis は @given デコレータで戦略(Strategy)を指定する方式です。pytest との統合がシームレスで、実プロジェクトでの採用率が高いライブラリです。
Clojure — test.check: データとしてのジェネレータ¶
Clojure の test.check では、ジェネレータ自体がデータ(値)として扱われます。gen/fmap, gen/bind, gen/such-that でジェネレータを合成します。
Elixir — StreamData: Stream とジェネレータの統合¶
StreamData は Elixir の Stream メタファーとジェネレータを統合しています。gen all マクロでモナド的な合成が可能です。
Java — @RepeatedTest: 標準機能での PBT 近似¶
専用 PBT ライブラリを使わず、JUnit 5 の @RepeatedTest(100) で繰り返しテストを行い、手動ジェネレータでランダム入力を生成するアプローチです。軽量で追加依存が不要です。
12.10 選択指針¶
PBT ライブラリの選択¶
PBT が必須か?
├─ はい → 言語に最適なライブラリを選択
│ ├─ Haskell → QuickCheck(標準的)
│ ├─ Scala → ScalaCheck
│ ├─ Rust → proptest
│ ├─ Python → Hypothesis
│ ├─ TypeScript → fast-check
│ ├─ F# / C# → FsCheck
│ ├─ Clojure → test.check
│ └─ Elixir → StreamData
└─ いいえ → 手動ジェネレータ + 反復テスト
├─ Java → @RepeatedTest + ランダム生成関数
└─ Ruby → カスタムジェネレータ + 反復実行
テスト戦略の選択¶
| プロジェクト特性 | 推奨アプローチ |
|---|---|
| 数学的な不変条件が明確 | PBT を主軸に |
| ドメインロジックが複雑 | PBT + ドメインオブジェクトジェネレータ |
| 外部依存が多い | DataAccess スタブ + 統合テスト |
| CI 時間に制約あり | PBT の反復回数を調整 |
| チーム PBT 経験なし | 手動ジェネレータから段階的に導入 |
12.11 まとめ¶
本章では、11 言語のテスト戦略と PBT ライブラリを比較しました。
PBT ライブラリ一覧:
| 言語 | ライブラリ | ジェネレータ合成 | Shrinking |
|---|---|---|---|
| Haskell | QuickCheck | <$>, <*> (Applicative) |
自動 |
| Scala | ScalaCheck | for 内包表記 |
自動 |
| Rust | proptest | proptest! マクロ + 範囲式 |
自動 |
| Python | Hypothesis | @given + st.builds |
自動 |
| TypeScript | fast-check | fc.record / カスタム Gen |
自動 |
| F# | FsCheck | gen { } 計算式 |
自動 |
| C# | FsCheck | LINQ クエリ式 | 自動 |
| Clojure | test.check | gen/fmap, gen/bind |
自動 |
| Elixir | StreamData | gen all マクロ |
自動 |
| Java | @RepeatedTest | 手動関数 | なし |
| Ruby | 手動 | 手動関数 | なし |
テスト戦略の共通原則:
- 純粋関数を最大化 — テスト容易性の根本は純粋性にあります
- プロパティで不変条件を検証 — 手書きテストケースの限界を PBT で補います
- DataAccess で外部依存を分離 — スタブによるテストを可能にします
- SearchReport で可観測性を確保 — 部分的な成功/失敗を区別できます
- 段階的に導入 — 手動ジェネレータから始め、PBT ライブラリに移行できます