第6章: テスト駆動開発と関数型プログラミング¶
はじめに¶
テスト駆動開発(TDD)は、テストを先に書いてから実装を行う開発手法です。関数型プログラミングと TDD は相性が良く、純粋関数はテストが容易で、不変データ構造は予測可能な動作を保証します。
本章では、Red-Green-Refactor サイクルを Haskell で実践する方法を学びます。
1. TDD の基本サイクル¶
- Red(赤): 失敗するテストを書く
- Green(緑): テストを通す最小限のコードを書く
- Refactor(リファクタリング): コードを改善する
2. FizzBuzz - TDD の典型例¶
Step 1: Red(最初のテスト)¶
-- test/TddFunctionalSpec.hs
spec :: Spec
spec = do
describe "FizzBuzz" $ do
it "1 returns \"1\"" $
fizzBuzz 1 `shouldBe` "1"
Step 2: Green(最小限の実装)¶
-- src/TddFunctional.hs
fizzBuzz :: Int -> String
fizzBuzz _ = "1"
Step 3: 次のテストを追加¶
it "2 returns \"2\"" $
fizzBuzz 2 `shouldBe` "2"
段階的に実装を発展¶
-- 小さなヘルパー関数に分割
fizz :: Int -> Bool
fizz n = n `mod` 3 == 0
buzz :: Int -> Bool
buzz n = n `mod` 5 == 0
fizzBuzz :: Int -> String
fizzBuzz n
| fizz n && buzz n = "FizzBuzz"
| fizz n = "Fizz"
| buzz n = "Buzz"
| otherwise = show n
最終テストスイート¶
describe "FizzBuzz (Basic TDD)" $ do
it "1 returns \"1\"" $
fizzBuzz 1 `shouldBe` "1"
it "2 returns \"2\"" $
fizzBuzz 2 `shouldBe` "2"
it "3 returns \"Fizz\"" $
fizzBuzz 3 `shouldBe` "Fizz"
it "5 returns \"Buzz\"" $
fizzBuzz 5 `shouldBe` "Buzz"
it "15 returns \"FizzBuzz\"" $
fizzBuzz 15 `shouldBe` "FizzBuzz"
3. ローマ数字変換¶
TDD で段階的に実装¶
-- Step 1: 最初のテスト
it "converts 1 to I" $
toRoman 1 `shouldBe` "I"
-- Step 2: 最小限の実装
toRoman :: Int -> String
toRoman 1 = "I"
toRoman _ = ""
-- Step 3: テストを追加しながら発展
it "converts 4 to IV" $
toRoman 4 `shouldBe` "IV"
it "converts 5 to V" $
toRoman 5 `shouldBe` "V"
完成した実装¶
toRoman :: Int -> String
toRoman n
| n <= 0 = ""
| n >= 1000 = "M" ++ toRoman (n - 1000)
| n >= 900 = "CM" ++ toRoman (n - 900)
| n >= 500 = "D" ++ toRoman (n - 500)
| n >= 400 = "CD" ++ toRoman (n - 400)
| n >= 100 = "C" ++ toRoman (n - 100)
| n >= 90 = "XC" ++ toRoman (n - 90)
| n >= 50 = "L" ++ toRoman (n - 50)
| n >= 40 = "XL" ++ toRoman (n - 40)
| n >= 10 = "X" ++ toRoman (n - 10)
| n >= 9 = "IX" ++ toRoman (n - 9)
| n >= 5 = "V" ++ toRoman (n - 5)
| n >= 4 = "IV" ++ toRoman (n - 4)
| otherwise = "I" ++ toRoman (n - 1)
fromRoman :: String -> Int
fromRoman = go 0
where
go acc [] = acc
go acc [x] = acc + romanCharValue x
go acc (x:y:rest)
| romanCharValue x < romanCharValue y = go (acc - romanCharValue x) (y:rest)
| otherwise = go (acc + romanCharValue x) (y:rest)
romanCharValue 'I' = 1
romanCharValue 'V' = 5
romanCharValue 'X' = 10
romanCharValue 'L' = 50
romanCharValue 'C' = 100
romanCharValue 'D' = 500
romanCharValue 'M' = 1000
romanCharValue _ = 0
4. ボウリングスコア¶
ゲームの状態¶
type Game = ([Int], Int) -- (rolls, current frame)
newGame :: Game
newGame = ([], 1)
roll :: Int -> Game -> Game
roll pins (rolls, frame) = (rolls ++ [pins], frame)
スコア計算¶
score :: Game -> Int
score (rolls, _) = scoreFrames rolls 1 0
scoreFrames :: [Int] -> Int -> Int -> Int
scoreFrames _ 11 acc = acc -- 10フレーム超えたら終了
scoreFrames [] _ acc = acc
scoreFrames rolls@(r1:rest) frame acc
| frame == 10 = acc + sum rolls -- 10フレーム目
| r1 == 10 = scoreFrames rest (frame + 1) (acc + 10 + bonus2 rest) -- Strike
| otherwise = case rest of
[] -> acc + r1
(r2:rest2)
| r1 + r2 == 10 -> scoreFrames rest2 (frame + 1) (acc + 10 + bonus1 rest2) -- Spare
| otherwise -> scoreFrames rest2 (frame + 1) (acc + r1 + r2)
where
bonus1 (x:_) = x
bonus1 [] = 0
bonus2 (x:y:_) = x + y
bonus2 (x:_) = x
bonus2 [] = 0
テスト¶
describe "Bowling Game" $ do
it "starts with score 0" $
score newGame `shouldBe` 0
it "scores 0 for all zeros" $ do
let game = foldr roll newGame (replicate 20 0)
score game `shouldBe` 0
it "scores 20 for all ones" $ do
let game = foldr roll newGame (replicate 20 1)
score game `shouldBe` 20
it "spare adds next roll bonus" $ do
let rolls = [5, 5, 3] ++ replicate 17 0
let game = foldr roll newGame (reverse rolls)
score game `shouldBe` 16
5. String Calculator Kata¶
add :: String -> Int
add "" = 0
add s = sum $ parseNumbers s
parseNumbers :: String -> [Int]
parseNumbers s = map read $ splitOn ',' $ map replaceNewline s
where
replaceNewline '\n' = ','
replaceNewline c = c
splitOn _ [] = []
splitOn delim str =
let (before, after) = break (== delim) str
in before : case after of
[] -> []
(_:rest) -> splitOn delim rest
6. 素因数分解¶
primeFactors :: Int -> [Int]
primeFactors n = factorize n 2
where
factorize 1 _ = []
factorize num factor
| factor * factor > num = [num]
| num `mod` factor == 0 = factor : factorize (num `div` factor) factor
| otherwise = factorize num (factor + 1)
テスト¶
describe "Prime Factors" $ do
it "returns empty for 1" $
primeFactors 1 `shouldBe` []
it "returns [2] for 2" $
primeFactors 2 `shouldBe` [2]
it "returns [2,2] for 4" $
primeFactors 4 `shouldBe` [2, 2]
it "returns [2,3] for 6" $
primeFactors 6 `shouldBe` [2, 3]
-- プロパティベーステスト
it "product of factors equals original" $ property $
\(Positive n) ->
let n' = max 2 (n `mod` 1000)
in product (primeFactors n') == n'
7. まとめ¶
TDD と FP の相性¶
| 特徴 | メリット |
|---|---|
| 純粋関数 | 入出力が明確でテストしやすい |
| 不変データ | 状態管理の複雑さがない |
| 型システム | 多くのバグをコンパイル時に検出 |
| パターンマッチ | 網羅的なケース処理 |
TDD のベストプラクティス¶
- 小さなステップで進める: 一度に大きな変更をしない
- 最初に失敗するテストを書く: テストが本当に機能していることを確認
- 最小限のコードで通す: 過度な一般化を避ける
- リファクタリングはグリーンの後: テストが通った状態でのみ改善
- プロパティベーステストを併用: エッジケースの発見