第6章: テスト駆動開発と関数型プログラミング¶
はじめに¶
テスト駆動開発(TDD)は、テストを先に書いてから実装を行う開発手法です。関数型プログラミングと TDD は相性が良く、純粋関数はテストが容易で、不変データ構造は予測可能な動作を保証します。
本章では、Red-Green-Refactor サイクルを関数型スタイルで実践する方法を学びます。
1. TDD の基本サイクル¶
Red-Green-Refactor¶
- Red(赤): 失敗するテストを書く
- Green(緑): テストを通す最小限のコードを書く
- Refactor(リファクタリング): コードを改善する(テストは通ったまま)
2. FizzBuzz - TDD の典型例¶
Step 1: Red(最初のテスト)¶
test("FizzBuzz: 1は\"1\"を返す") {
FizzBuzz.fizzbuzz(1) shouldBe "1"
}
Step 2: Green(最小限の実装)¶
def fizzbuzz(n: Int): String = "1"
Step 3: 次のテストを追加して段階的に実装を発展¶
// テストを追加
test("FizzBuzz: 2は\"2\"を返す") {
FizzBuzz.fizzbuzz(2) shouldBe "2"
}
test("FizzBuzz: 3は\"Fizz\"を返す") {
FizzBuzz.fizzbuzz(3) shouldBe "Fizz"
}
test("FizzBuzz: 5は\"Buzz\"を返す") {
FizzBuzz.fizzbuzz(5) shouldBe "Buzz"
}
test("FizzBuzz: 15は\"FizzBuzz\"を返す") {
FizzBuzz.fizzbuzz(15) shouldBe "FizzBuzz"
}
最終実装(小さなヘルパー関数に分割)¶
object FizzBuzz:
/** 3で割り切れるかどうか */
def isFizz(n: Int): Boolean = n % 3 == 0
/** 5で割り切れるかどうか */
def isBuzz(n: Int): Boolean = n % 5 == 0
/** 15で割り切れるかどうか(FizzBuzz) */
def isFizzBuzz(n: Int): Boolean = isFizz(n) && isBuzz(n)
/** FizzBuzz変換 */
def fizzbuzz(n: Int): String =
if isFizzBuzz(n) then "FizzBuzz"
else if isFizz(n) then "Fizz"
else if isBuzz(n) then "Buzz"
else n.toString
/** 1からnまでのFizzBuzz列を生成 */
def fizzbuzzSequence(n: Int): List[String] =
(1 to n).map(fizzbuzz).toList
3. ローマ数字変換¶
テストから始める¶
test("ローマ数字: 1はIを返す") {
RomanNumerals.toRoman(1) shouldBe "I"
}
test("ローマ数字: 4はIVを返す") {
RomanNumerals.toRoman(4) shouldBe "IV"
}
test("ローマ数字: 1994はMCMXCIVを返す") {
RomanNumerals.toRoman(1994) shouldBe "MCMXCIV"
}
test("ローマ数字: toRomanとfromRomanは逆関数") {
for n <- 1 to 3999 do
RomanNumerals.fromRoman(RomanNumerals.toRoman(n)) shouldBe n
}
データ駆動の実装¶
object RomanNumerals:
/** ローマ数字の対応表(大きい順) */
private val romanNumerals: List[(Int, String)] = List(
1000 -> "M", 900 -> "CM", 500 -> "D", 400 -> "CD",
100 -> "C", 90 -> "XC", 50 -> "L", 40 -> "XL",
10 -> "X", 9 -> "IX", 5 -> "V", 4 -> "IV",
1 -> "I"
)
/** 整数をローマ数字に変換 */
def toRoman(n: Int): String =
require(n > 0 && n <= 3999, s"n must be between 1 and 3999")
@annotation.tailrec
def loop(remaining: Int, result: StringBuilder): String =
if remaining == 0 then result.toString
else
val (value, numeral) = romanNumerals.find(_._1 <= remaining).get
loop(remaining - value, result.append(numeral))
loop(n, new StringBuilder)
4. ボウリングスコア計算¶
複雑なビジネスロジックの TDD¶
test("ボウリング: ガタースコアは0") {
Bowling.score(List.fill(20)(0)) shouldBe 0
}
test("ボウリング: すべて1ピンは20点") {
Bowling.score(List.fill(20)(1)) shouldBe 20
}
test("ボウリング: スペアの後の投球はボーナス") {
Bowling.score(List(5, 5, 3, 0) ++ List.fill(16)(0)) shouldBe 16
}
test("ボウリング: ストライクの後の2投はボーナス") {
Bowling.score(List(10, 3, 4) ++ List.fill(16)(0)) shouldBe 24
}
test("ボウリング: パーフェクトゲームは300点") {
Bowling.score(List.fill(12)(10)) shouldBe 300
}
小さな関数に分割¶
object Bowling:
def isStrike(rolls: List[Int]): Boolean =
rolls.headOption.contains(10)
def isSpare(rolls: List[Int]): Boolean =
rolls.length >= 2 && rolls.take(2).sum == 10 && !isStrike(rolls)
def strikeBonus(remaining: List[Int]): Int =
remaining.take(2).sum
def spareBonus(remaining: List[Int]): Int =
remaining.headOption.getOrElse(0)
def score(rolls: List[Int]): Int =
@annotation.tailrec
def loop(remainingRolls: List[Int], frame: Int, total: Int): Int =
if frame > 10 || remainingRolls.isEmpty then total
else if isStrike(remainingRolls) then
loop(remainingRolls.tail, frame + 1, total + 10 + strikeBonus(remainingRolls.tail))
else if isSpare(remainingRolls) then
loop(remainingRolls.drop(2), frame + 1, total + 10 + spareBonus(remainingRolls.drop(2)))
else
loop(remainingRolls.drop(2), frame + 1, total + remainingRolls.take(2).sum)
loop(rolls, 1, 0)
5. 素数 - シンプルな関数の TDD¶
テストから設計を導く¶
test("素数: isPrimeは素数を正しく判定する") {
Primes.isPrime(0) shouldBe false
Primes.isPrime(1) shouldBe false
Primes.isPrime(2) shouldBe true
Primes.isPrime(3) shouldBe true
Primes.isPrime(4) shouldBe false
Primes.isPrime(97) shouldBe true
}
test("素数: primesUpToは正しい素数リストを返す") {
Primes.primesUpTo(20) shouldBe List(2, 3, 5, 7, 11, 13, 17, 19)
}
test("素数: primeFactorsは素因数分解を返す") {
Primes.primeFactors(24) shouldBe List(2, 2, 2, 3)
}
test("素数: primeFactorsの積は元の数に等しい") {
for n <- 2 to 100 do
Primes.primeFactors(n).product shouldBe n
}
実装¶
object Primes:
def isPrime(n: Int): Boolean =
if n < 2 then false
else if n == 2 then true
else if n % 2 == 0 then false
else
val sqrtN = math.sqrt(n.toDouble).toInt
!(3 to sqrtN by 2).exists(n % _ == 0)
def primesUpTo(n: Int): List[Int] =
(2 to n).filter(isPrime).toList
def primeFactors(n: Int): List[Int] =
@annotation.tailrec
def loop(remaining: Int, factor: Int, factors: List[Int]): List[Int] =
if remaining == 1 then factors.reverse
else if remaining % factor == 0 then loop(remaining / factor, factor, factor :: factors)
else loop(remaining, factor + 1, factors)
loop(n, 2, Nil)
6. 不変データ構造 - スタックとキュー¶
不変スタック¶
case class Stack[A] private (items: List[A]):
def isEmpty: Boolean = items.isEmpty
def size: Int = items.length
def push(item: A): Stack[A] = Stack(item :: items)
def pop: Option[(A, Stack[A])] =
items match
case head :: tail => Some((head, Stack(tail)))
case Nil => None
def peek: Option[A] = items.headOption
object Stack:
def empty[A]: Stack[A] = Stack(Nil)
テスト¶
test("スタック: LIFO順序で動作する") {
val stack = Stack.empty[String]
.push("a")
.push("b")
.push("c")
val Some((v1, s1)) = stack.pop: @unchecked
val Some((v2, s2)) = s1.pop: @unchecked
val Some((v3, s3)) = s2.pop: @unchecked
v1 shouldBe "c"
v2 shouldBe "b"
v3 shouldBe "a"
s3.isEmpty shouldBe true
}
不変キュー(2つのリストで実装)¶
case class Queue[A] private (front: List[A], back: List[A]):
def isEmpty: Boolean = front.isEmpty && back.isEmpty
def enqueue(item: A): Queue[A] = Queue(front, item :: back)
def dequeue: Option[(A, Queue[A])] =
front match
case head :: tail => Some((head, Queue(tail, back)))
case Nil =>
back.reverse match
case head :: tail => Some((head, Queue(tail, Nil)))
case Nil => None
object Queue:
def empty[A]: Queue[A] = Queue(Nil, Nil)
7. 文字列電卓 - 段階的な要件追加¶
テスト¶
test("文字列電卓: 空文字列は0を返す") {
StringCalculator.add("") shouldBe 0
}
test("文字列電卓: 単一の数値はその値を返す") {
StringCalculator.add("5") shouldBe 5
}
test("文字列電卓: カンマ区切りの数値を合計する") {
StringCalculator.add("1,2,3") shouldBe 6
}
test("文字列電卓: 改行区切りも処理する") {
StringCalculator.add("1\n2,3") shouldBe 6
}
test("文字列電卓: カスタム区切り文字を使用できる") {
StringCalculator.add("//;\n1;2") shouldBe 3
}
test("文字列電卓: 負の数は例外をスローする") {
val exception = intercept[IllegalArgumentException] {
StringCalculator.add("1,-2,3")
}
exception.getMessage should include("-2")
}
test("文字列電卓: 1000より大きい数は無視する") {
StringCalculator.add("2,1001") shouldBe 2
}
実装¶
object StringCalculator:
def add(input: String): Int =
if input.isEmpty then 0
else
val (delimiter, numbers) = parseInput(input)
val nums = parseNumbers(numbers, delimiter)
validateNumbers(nums)
nums.filter(_ <= 1000).sum
private def parseInput(input: String): (String, String) =
if input.startsWith("//") then
val delimiterEnd = input.indexOf("\n")
(input.substring(2, delimiterEnd), input.substring(delimiterEnd + 1))
else
(",|\n", input)
private def parseNumbers(numbers: String, delimiter: String): List[Int] =
numbers.split(delimiter).filter(_.nonEmpty).map(_.toInt).toList
private def validateNumbers(nums: List[Int]): Unit =
val negatives = nums.filter(_ < 0)
if negatives.nonEmpty then
throw new IllegalArgumentException(s"negatives not allowed: ${negatives.mkString(", ")}")
8. 純粋関数とテスト容易性¶
純粋関数の利点¶
case class Item(name: String, price: BigDecimal)
case class TaxCalculation(subtotal: BigDecimal, tax: BigDecimal, total: BigDecimal)
object TaxCalculator:
/** 純粋関数:テストが容易 */
def calculateTax(amount: BigDecimal, rate: BigDecimal): BigDecimal =
amount * rate
def calculateTotalWithTax(items: List[Item], taxRate: BigDecimal): TaxCalculation =
val subtotal = items.map(_.price).sum
val tax = calculateTax(subtotal, taxRate)
TaxCalculation(subtotal, tax, subtotal + tax)
テスト¶
test("税計算: calculateTotalWithTaxは税込み総額を計算する") {
val items = List(
Item("商品A", BigDecimal(1000)),
Item("商品B", BigDecimal(2000))
)
val result = TaxCalculator.calculateTotalWithTax(items, BigDecimal(0.1))
result.subtotal shouldBe BigDecimal(3000)
result.tax shouldBe BigDecimal(300)
result.total shouldBe BigDecimal(3300)
}
9. リファクタリングパターン - データ駆動の実装¶
Before: 複雑な条件分岐¶
def calculateShippingBefore(total: BigDecimal, weight: Double, region: Region): Int =
if total >= 10000 then 0
else region match
case Region.Local if weight < 5 => 300
case Region.Local => 500
case Region.Domestic if weight < 5 => 500
case Region.Domestic => 800
case Region.International if weight < 5 => 2000
case Region.International => 3000
After: データ駆動の実装¶
object ShippingCalculator:
def isFreeShipping(total: BigDecimal): Boolean = total >= 10000
private val shippingRates: Map[Region, Map[Boolean, Int]] = Map(
Region.Local -> Map(true -> 300, false -> 500),
Region.Domestic -> Map(true -> 500, false -> 800),
Region.International -> Map(true -> 2000, false -> 3000)
)
def calculateShipping(order: ShippingOrder): Int =
if isFreeShipping(order.total) then 0
else
val isLight = order.weight < 5.0
shippingRates.get(order.region)
.flatMap(_.get(isLight))
.getOrElse(500)
10. パスワードバリデーター - ルールの合成¶
object PasswordValidator:
type Rule = String => Option[String]
def minLength(min: Int): Rule = password =>
if password.length >= min then None
else Some(s"Password must be at least $min characters")
val hasUppercase: Rule = password =>
if password.exists(_.isUpper) then None
else Some("Password must contain at least one uppercase letter")
val hasLowercase: Rule = password =>
if password.exists(_.isLower) then None
else Some("Password must contain at least one lowercase letter")
val hasDigit: Rule = password =>
if password.exists(_.isDigit) then None
else Some("Password must contain at least one digit")
val defaultRules: List[Rule] = List(
minLength(8), hasUppercase, hasLowercase, hasDigit
)
def validate(password: String, rules: List[Rule] = defaultRules): Either[List[String], String] =
val errors = rules.flatMap(_(password))
if errors.isEmpty then Right(password)
else Left(errors)
TDD のベストプラクティス¶
1. 小さなステップで進む¶
- 一度に1つのテストだけを追加
- テストが通ったら次のテストへ
2. テスト名は仕様として読める¶
test("10000円以上は送料無料")
test("負の数は例外をスローする")
test("パーフェクトゲームは300点")
3. 純粋関数を優先¶
- 副作用を持つ関数は最小限に
- 副作用は境界に追い出す
4. エッジケースをテスト¶
test("空文字列は0を返す")
test("空のリストは空のリストを返す")
test("範囲外の値は例外をスローする")
Clojure との比較¶
| 概念 | Clojure | Scala |
|---|---|---|
| テストフレームワーク | speclj, clojure.test | ScalaTest |
| テスト構文 | (it "..." (should= ...)) |
test("...") { ... shouldBe ... } |
| 例外テスト | (should-throw ...) |
intercept[...] { ... } |
| データ構造 | 永続化データ構造(デフォルト) | case class + copy |
| ループ | loop/recur |
@annotation.tailrec |
| パターンマッチ | cond, case |
match |
まとめ¶
本章では、TDD と関数型プログラミングについて学びました:
- Red-Green-Refactor: 基本サイクル
- FizzBuzz: 典型的な TDD 例
- ローマ数字: データ駆動の実装
- ボウリング: 複雑なビジネスロジック
- 素数: シンプルな関数設計
- スタック/キュー: 不変データ構造
- 文字列電卓: 段階的な要件追加
- 純粋関数: テスト容易性
- リファクタリング: 条件分岐の整理
- パスワードバリデーター: ルールの合成
関数型プログラミングと TDD の組み合わせにより、信頼性の高いコードを効率的に開発できます。
参考コード¶
本章のコード例は以下のファイルで確認できます:
- ソースコード:
apps/scala/part2/src/main/scala/TddFunctional.scala - テストコード:
apps/scala/part2/src/test/scala/TddFunctionalSpec.scala
次章予告¶
次章から第3部「デザインパターン - 構造パターン」に入ります。Composite パターンを関数型スタイルで実装する方法を学びます。