第6章: TDD と関数型プログラミング — 6言語統合ガイド¶
1. はじめに¶
テスト駆動開発(TDD)は「テストを先に書き、テストが通る最小限のコードを実装し、リファクタリングする」サイクルです。関数型プログラミングの純粋関数と不変データは、TDD と極めて相性が良い組み合わせです。副作用がなく、同じ入力に対して常に同じ出力を返す関数は、テストが書きやすく、結果が予測可能です。
2. 共通の本質¶
Red-Green-Refactor サイクル¶
すべての言語で共通の TDD サイクル:
1. Red: 失敗するテストを書く
2. Green: テストが通る最小限のコードを書く
3. Refactor: コードをきれいにする(テストは緑のまま)
関数型 TDD の利点¶
| 利点 | 説明 |
|---|---|
| テスト容易性 | 純粋関数は入力→出力のみ、セットアップ不要 |
| 再現性 | 副作用がないため、テスト結果が決定的 |
| 合成可能性 | 小さな関数をテスト → 合成して大きな機能を構築 |
| リファクタリング安全性 | 不変データにより意図しない状態変更がない |
3. 言語別実装比較¶
3.1 テストフレームワーク¶
| 言語 | フレームワーク | テスト記述スタイル |
|---|---|---|
| Clojure | clojure.test / speclj | deftest + is / describe + it |
| Scala | ScalaTest / MUnit | test("...") + shouldBe |
| Elixir | ExUnit | test "..." do + assert |
| F# | xUnit / Expecto | [<Fact>] + Assert / testCase |
| Haskell | HSpec / QuickCheck | describe + it + shouldBe |
| Rust | 組み込み #[test] |
#[test] + assert_eq! |
3.2 FizzBuzz で学ぶ TDD サイクル¶
FizzBuzz は TDD の入門に最適な問題です。すべての言語で同じ段階的アプローチを取ります。
ステップ 1: 通常の数値を返す¶
テスト(全言語比較)
;; Clojure
(deftest test-normal-number
(is (= "1" (fizzbuzz 1)))
(is (= "2" (fizzbuzz 2))))
// Scala
test("通常の数値を文字列で返す") {
FizzBuzz.fizzbuzz(1) shouldBe "1"
FizzBuzz.fizzbuzz(2) shouldBe "2"
}
# Elixir
test "通常の数値を返す" do
assert FizzBuzz.fizzbuzz(1) == "1"
assert FizzBuzz.fizzbuzz(2) == "2"
end
// F#
[<Fact>]
let ``通常の数値を文字列で返す`` () =
fizzbuzz 1 |> should equal "1"
fizzbuzz 2 |> should equal "2"
-- Haskell
describe "FizzBuzz" $ do
it "通常の数値を返す" $ do
fizzBuzz 1 `shouldBe` "1"
fizzBuzz 2 `shouldBe` "2"
// Rust
#[test]
fn fizzbuzz_1_returns_1() {
assert_eq!("1", fizzbuzz(1));
}
#[test]
fn fizzbuzz_2_returns_2() {
assert_eq!("2", fizzbuzz(2));
}
ステップ 2: 3 の倍数で "Fizz"¶
実装(全言語比較)
;; Clojure
(defn fizz? [n] (zero? (mod n 3)))
(defn fizzbuzz [n]
(cond
(fizz? n) "Fizz"
:else (str n)))
// Scala
def isFizz(n: Int): Boolean = n % 3 == 0
def fizzbuzz(n: Int): String =
if isFizz(n) then "Fizz" else n.toString
-- Haskell
fizz :: Int -> Bool
fizz n = n `mod` 3 == 0
fizzBuzz :: Int -> String
fizzBuzz n
| fizz n = "Fizz"
| otherwise = show n
// Rust
fn is_fizz(n: u32) -> bool { n % 3 == 0 }
pub fn fizzbuzz(n: u32) -> String {
if is_fizz(n) { "Fizz".to_string() }
else { n.to_string() }
}
ステップ 3: 完成形¶
完成した fizzbuzz(全言語比較)
;; Clojure
(defn fizzbuzz [n]
(cond
(and (fizz? n) (buzz? n)) "FizzBuzz"
(fizz? n) "Fizz"
(buzz? n) "Buzz"
:else (str n)))
// Scala
def fizzbuzz(n: Int): String =
if isFizz(n) && isBuzz(n) then "FizzBuzz"
else if isFizz(n) then "Fizz"
else if isBuzz(n) then "Buzz"
else n.toString
# Elixir
def fizzbuzz(n) do
cond do
rem(n, 15) == 0 -> "FizzBuzz"
rem(n, 3) == 0 -> "Fizz"
rem(n, 5) == 0 -> "Buzz"
true -> Integer.to_string(n)
end
end
// F#
let fizzbuzz n =
if isFizzBuzz n then "FizzBuzz"
elif isFizz n then "Fizz"
elif isBuzz n then "Buzz"
else string n
-- Haskell
fizzBuzz :: Int -> String
fizzBuzz n
| fizz n && buzz n = "FizzBuzz"
| fizz n = "Fizz"
| buzz n = "Buzz"
| otherwise = show n
// Rust
fn is_fizzbuzz(n: u32) -> bool { is_fizz(n) && is_buzz(n) }
pub fn fizzbuzz(n: u32) -> String {
if is_fizzbuzz(n) { "FizzBuzz".to_string() }
else if is_fizz(n) { "Fizz".to_string() }
else if is_buzz(n) { "Buzz".to_string() }
else { n.to_string() }
}
3.3 ボウリングスコア — 複雑な TDD¶
ボウリングスコア計算は、TDD で複雑なロジックを段階的に構築する典型例です。
| ルール | 説明 |
|---|---|
| 通常 | フレーム内のピン数の合計 |
| スペア | 10 + 次の 1 投のボーナス |
| ストライク | 10 + 次の 2 投のボーナス |
| 10 フレーム目 | ストライク/スペア時に追加投球 |
ボウリングスコア計算の実装比較
;; Clojure: loop/recur による再帰
(defn score [rolls]
(loop [frame 0, roll-idx 0, total 0]
(if (= frame 10) total
(cond
(strike? rolls roll-idx)
(recur (inc frame) (inc roll-idx)
(+ total (strike-score rolls roll-idx)))
(spare? rolls roll-idx)
(recur (inc frame) (+ roll-idx 2)
(+ total (spare-score rolls roll-idx)))
:else
(recur (inc frame) (+ roll-idx 2)
(+ total (frame-score rolls roll-idx)))))))
// Scala: パターンマッチ + 尾部再帰
@tailrec
def scoreFrames(rolls: List[Int], frame: Int, acc: Int): Int =
if frame >= 10 then acc
else rolls match
case x :: y :: z :: rest if x == 10 =>
scoreFrames(y :: z :: rest, frame + 1, acc + 10 + y + z)
case x :: y :: z :: rest if x + y == 10 =>
scoreFrames(z :: rest, frame + 1, acc + 10 + z)
case x :: y :: rest =>
scoreFrames(rest, frame + 1, acc + x + y)
case _ => acc
-- Haskell: リスト再帰 + ガード式
score :: [Int] -> Int
score = go 0 0
where
go 10 total _ = total
go frame total (x:y:z:rest)
| x == 10 = go (frame+1) (total+10+y+z) (y:z:rest)
| x + y == 10 = go (frame+1) (total+10+z) (z:rest)
| otherwise = go (frame+1) (total+x+y) (z:rest)
go _ total _ = total
// Rust: スライス + ヘルパー関数による再帰
fn is_strike(rolls: &[u32]) -> bool { !rolls.is_empty() && rolls[0] == 10 }
fn is_spare(rolls: &[u32]) -> bool { rolls.len() >= 2 && rolls[0] + rolls[1] == 10 && rolls[0] != 10 }
fn strike_bonus(r: &[u32]) -> u32 { r.iter().take(2).sum() }
fn spare_bonus(r: &[u32]) -> u32 { r.first().copied().unwrap_or(0) }
pub fn bowling_score(rolls: &[u32]) -> u32 {
fn go(r: &[u32], frame: u32, total: u32) -> u32 {
if frame > 10 || r.is_empty() { total }
else if is_strike(r) { go(&r[1..], frame + 1, total + 10 + strike_bonus(&r[1..])) }
else if is_spare(r) { go(&r[2..], frame + 1, total + 10 + spare_bonus(&r[2..])) }
else { go(&r[2..], frame + 1, total + r.iter().take(2).sum::<u32>()) }
}
go(rolls, 1, 0)
}
3.4 依存性注入と副作用の分離¶
関数型 TDD では、副作用を持つ処理を関数の引数として注入し、テスト時にモックに差し替えます。
| 言語 | 依存性注入の方法 | テスト時の差し替え |
|---|---|---|
| Clojure | 高階関数の引数 | テスト用関数を渡す |
| Scala | 関数型パラメータ / trait | テスト用実装を渡す |
| Elixir | 高階関数の引数 | テスト用クロージャ |
| F# | 関数型パラメータ | テスト用関数を渡す |
| Haskell | 型クラス / Reader モナド | テスト用インスタンス |
| Rust | trait object / 関数ポインタ | テスト用 struct |
依存性注入の実装比較
;; Clojure: 高階関数
(defn calculate-price [product-id base-price discount-fetcher]
(let [discount (discount-fetcher product-id)]
(* base-price (- 1 discount))))
;; テスト
(deftest test-calculate-price
(let [mock-fetcher (constantly 0.1)]
(is (= 900.0 (calculate-price "P001" 1000 mock-fetcher)))))
# Elixir: クロージャ
def calculate_price(product_id, base_price, discount_fetcher) do
discount = discount_fetcher.(product_id)
base_price * (1.0 - discount)
end
# テスト
test "価格計算" do
mock = fn _id -> 0.10 end
assert PricingService.calculate_price("P001", 1000, mock) == 900.0
end
// Rust: 関数ポインタ / クロージャ
pub fn calculate_price(
product_id: &str,
base_price: f64,
discount_fetcher: impl Fn(&str) -> f64,
) -> f64 {
let discount = discount_fetcher(product_id);
base_price * (1.0 - discount)
}
// テスト
#[test]
fn test_calculate_price() {
let mock = |_: &str| 0.10;
assert_eq!(calculate_price("P001", 1000.0, mock), 900.0);
}
-- Haskell: 型クラスによる抽象化
class DiscountFetcher m where
fetchDiscount :: String -> m Double
calculatePrice :: (Monad m, DiscountFetcher m) => String -> Double -> m Double
calculatePrice productId basePrice = do
discount <- fetchDiscount productId
return $ basePrice * (1.0 - discount)
3.5 不変データ構造のテスト¶
スタックの TDD 実装比較
;; Clojure: リストベース
(defn create-stack [] '())
(defn stack-push [stack item] (conj stack item))
(defn stack-pop [stack] [(first stack) (rest stack)])
(deftest test-stack
(let [stack (-> (create-stack) (stack-push "a") (stack-push "b"))
[top remaining] (stack-pop stack)]
(is (= "b" top))
(is (= '("a") remaining))))
// Scala: case class + List
case class Stack[A](items: List[A] = Nil):
def push(item: A): Stack[A] = Stack(item :: items)
def pop: (A, Stack[A]) = (items.head, Stack(items.tail))
test("push して pop すると LIFO") {
val stack = Stack[String]().push("a").push("b")
val (top, remaining) = stack.pop
top shouldBe "b"
remaining.pop._1 shouldBe "a"
}
// Rust: Vec ベース + 不変メソッド
pub struct Stack<T: Clone> {
items: Vec<T>,
}
impl<T: Clone> Stack<T> {
pub fn empty() -> Self { Stack { items: Vec::new() } }
pub fn push(&self, item: T) -> Self {
let mut new_items = self.items.clone();
new_items.push(item);
Stack { items: new_items }
}
pub fn pop(&self) -> Option<(T, Self)> {
if self.items.is_empty() {
None
} else {
let mut new_items = self.items.clone();
let top = new_items.pop().unwrap();
Some((top, Stack { items: new_items }))
}
}
}
4. 比較分析¶
4.1 テスト記述の簡潔さ¶
| 言語 | FizzBuzz テスト行数 | ボイラープレート |
|---|---|---|
| Clojure | 最小 | deftest + is で完結 |
| Scala | 少 | shouldBe マッチャーが豊富 |
| Elixir | 少 | assert がシンプル |
| F# | 少 | [<Fact>] で宣言的 |
| Haskell | 最小 | shouldBe で数学的な記述 |
| Rust | 少 | assert_eq! マクロがシンプル |
4.2 副作用分離の厳密さ¶
厳密 ←――――――――――――――――――→ 柔軟
Haskell F# Rust Scala Elixir Clojure
├─IO モナド┤ │ │ │ │
├─CE──┤ │ │ │
├─trait──┤ │ │
├─高階──┤ │
├─関数──┤
├─規約
Haskell は型レベルで副作用を分離します(IO モナド)。Clojure は規約ベースで分離します。他の言語はその中間に位置します。
4.3 F# / Rust の TDD 詳細¶
F# と Rust は共通の章構成(FizzBuzz、ローマ数字、ボウリング、素数、スタック/キュー、文字列電卓、リファクタリング、パスワードバリデーター)で TDD を体系的にカバーしています。
F# の特筆点:
- 計算式(Computation Expression) による非同期テスト
- パイプライン演算子 を活用したテストデータの構築
- xUnit + FsCheck 統合 によるプロパティベーステストとの連携
Rust の特筆点:
#[should_panic]アトリビュートによる例外テスト- 所有権システム がコンパイル時に不変性を保証
- proptest によるプロパティベーステストとの連携
5. 実践的な選択指針¶
| 要件 | 推奨言語 | 理由 |
|---|---|---|
| テスト容易性最優先 | Haskell | 純粋関数 + 型システムで副作用を完全分離 |
| 段階的な TDD 導入 | Scala | ScalaTest の豊富なマッチャーと柔軟なスタイル |
| Web アプリの TDD | Elixir | ExUnit + Phoenix の統合テスト環境 |
| REPL 駆動の TDD | Clojure | 対話的な開発サイクルが高速 |
| システムプログラミングの TDD | Rust | 組み込みテストフレームワークの安定性 |
| エンタープライズの TDD | F# | xUnit + .NET エコシステムとの統合 |
6. まとめ¶
TDD と関数型プログラミングの組み合わせは、テスト可能なコードを自然に書くことを促進します:
- 純粋関数: 入力→出力のみでテスト可能、セットアップ/ティアダウン不要
- 不変データ: テストの再現性が保証され、テスト間の干渉がない
- 依存性注入: 高階関数や型クラスで副作用を分離し、テスト時にモック化
- 段階的構築: TDD サイクルと関数合成が自然に結びつく