第6章: テスト駆動開発と関数型プログラミング¶
はじめに¶
テスト駆動開発(TDD)は、テストを先に書いてから実装を行う開発手法です。関数型プログラミングと TDD は相性が良く、純粋関数はテストが容易で、不変データ構造は予測可能な動作を保証します。
本章では、Red-Green-Refactor サイクルを関数型スタイルで実践する方法を学びます。
1. TDD の基本サイクル¶
Red-Green-Refactor¶
┌─────────────────────────────────────────────────┐
│ │
│ ┌───────┐ ┌───────┐ ┌───────────┐ │
│ │ Red │ ──► │ Green │ ──► │ Refactor │ │
│ └───────┘ └───────┘ └───────────┘ │
│ ▲ │ │
│ └──────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
- Red(赤): 失敗するテストを書く
- Green(緑): テストを通す最小限のコードを書く
- Refactor(リファクタリング): コードを改善する(テストは通ったまま)
2. FizzBuzz - TDD の典型例¶
Step 1: Red(最初のテスト)¶
#[test]
fn fizzbuzz_1_returns_1() {
assert_eq!("1", fizzbuzz(1));
}
Step 2: Green(最小限の実装)¶
pub fn fizzbuzz(n: u32) -> String {
"1".to_string()
}
Step 3: 次のテストを追加して段階的に実装を発展¶
// テストを追加
#[test]
fn fizzbuzz_2_returns_2() {
assert_eq!("2", fizzbuzz(2));
}
#[test]
fn fizzbuzz_3_returns_fizz() {
assert_eq!("Fizz", fizzbuzz(3));
}
#[test]
fn fizzbuzz_5_returns_buzz() {
assert_eq!("Buzz", fizzbuzz(5));
}
#[test]
fn fizzbuzz_15_returns_fizzbuzz() {
assert_eq!("FizzBuzz", fizzbuzz(15));
}
最終実装(小さなヘルパー関数に分割)¶
/// 3で割り切れるかどうか
fn is_fizz(n: u32) -> bool {
n % 3 == 0
}
/// 5で割り切れるかどうか
fn is_buzz(n: u32) -> bool {
n % 5 == 0
}
/// 15で割り切れるかどうか(FizzBuzz)
fn is_fizzbuzz(n: u32) -> bool {
is_fizz(n) && is_buzz(n)
}
/// FizzBuzz変換
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()
}
}
/// 1からnまでのFizzBuzz列を生成
pub fn fizzbuzz_sequence(n: u32) -> Vec<String> {
(1..=n).map(fizzbuzz).collect()
}
プロパティベーステストと組み合わせ¶
use proptest::prelude::*;
proptest! {
#[test]
fn multiples_of_3_always_contain_fizz(n in 1..1000u32) {
let result = fizzbuzz(n * 3);
prop_assert!(result.contains("Fizz"));
}
#[test]
fn multiples_of_5_always_contain_buzz(n in 1..1000u32) {
let result = fizzbuzz(n * 5);
prop_assert!(result.contains("Buzz"));
}
}
3. ローマ数字変換¶
テストから始める¶
#[test]
fn to_roman_1_returns_i() {
assert_eq!("I", to_roman(1));
}
#[test]
fn to_roman_4_returns_iv() {
assert_eq!("IV", to_roman(4));
}
#[test]
fn to_roman_1994_returns_mcmxciv() {
assert_eq!("MCMXCIV", to_roman(1994));
}
#[test]
fn to_roman_and_from_roman_are_inverse() {
for n in 1..=100 {
assert_eq!(n, from_roman(&to_roman(n)));
}
}
データ駆動の実装¶
/// ローマ数字の対応表(大きい順)
const ROMAN_NUMERALS: &[(u32, &str)] = &[
(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"),
];
/// 整数をローマ数字に変換
pub fn to_roman(n: u32) -> String {
assert!(n >= 1 && n <= 3999, "n must be between 1 and 3999");
let mut remaining = n;
let mut result = String::new();
for &(value, numeral) in ROMAN_NUMERALS {
while remaining >= value {
result.push_str(numeral);
remaining -= value;
}
}
result
}
/// ローマ数字から整数へ変換(逆変換)
pub fn from_roman(roman: &str) -> u32 {
let roman_value = |c: char| -> i32 {
match c {
'I' => 1, 'V' => 5, 'X' => 10, 'L' => 50,
'C' => 100, 'D' => 500, 'M' => 1000,
_ => 0,
}
};
let values: Vec<i32> = roman.chars().map(roman_value).collect();
let total = values.windows(2).fold(0i32, |acc, w| {
if w[0] < w[1] { acc - w[0] } else { acc + w[0] }
}) + values.last().copied().unwrap_or(0);
total as u32
}
4. ボウリングスコア計算¶
複雑なビジネスロジックの TDD¶
#[test]
fn gutter_game_scores_0() {
assert_eq!(0, bowling_score(&vec![0; 20]));
}
#[test]
fn all_ones_scores_20() {
assert_eq!(20, bowling_score(&vec![1; 20]));
}
#[test]
fn spare_adds_next_roll_as_bonus() {
let mut rolls = vec![5, 5, 3, 0];
rolls.extend(vec![0; 16]);
assert_eq!(16, bowling_score(&rolls));
}
#[test]
fn strike_adds_next_two_rolls_as_bonus() {
let mut rolls = vec![10, 3, 4];
rolls.extend(vec![0; 16]);
assert_eq!(24, bowling_score(&rolls));
}
#[test]
fn perfect_game_scores_300() {
assert_eq!(300, bowling_score(&vec![10; 12]));
}
小さな関数に分割¶
/// ストライクかどうか
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(remaining: &[u32]) -> u32 {
remaining.iter().take(2).sum()
}
/// スペアボーナス
fn spare_bonus(remaining: &[u32]) -> u32 {
remaining.first().copied().unwrap_or(0)
}
/// ボウリングスコアを計算
pub fn bowling_score(rolls: &[u32]) -> u32 {
fn loop_score(remaining: &[u32], frame: u32, total: u32) -> u32 {
if frame > 10 || remaining.is_empty() {
total
} else if is_strike(remaining) {
loop_score(&remaining[1..], frame + 1, total + 10 + strike_bonus(&remaining[1..]))
} else if is_spare(remaining) {
loop_score(&remaining[2..], frame + 1, total + 10 + spare_bonus(&remaining[2..]))
} else {
let frame_score: u32 = remaining.iter().take(2).sum();
loop_score(&remaining[2..], frame + 1, total + frame_score)
}
}
loop_score(rolls, 1, 0)
}
5. 素数 - シンプルな関数の TDD¶
テストから設計を導く¶
#[test]
fn zero_is_not_prime() {
assert!(!is_prime(0));
}
#[test]
fn two_is_prime() {
assert!(is_prime(2));
}
#[test]
fn primes_up_to_20_returns_correct_list() {
let expected = vec![2, 3, 5, 7, 11, 13, 17, 19];
assert_eq!(expected, primes_up_to(20));
}
#[test]
fn prime_factors_24_returns_2_2_2_3() {
let expected = vec![2, 2, 2, 3];
assert_eq!(expected, prime_factors(24));
}
#[test]
fn prime_factors_product_equals_original() {
for n in 2..=100 {
let factors = prime_factors(n);
let product: u64 = factors.iter().product();
assert_eq!(n, product);
}
}
実装¶
/// 素数判定
pub fn is_prime(n: u64) -> bool {
if n < 2 {
return false;
}
if n == 2 {
return true;
}
if n % 2 == 0 {
return false;
}
let sqrt_n = (n as f64).sqrt() as u64;
!(3..=sqrt_n).step_by(2).any(|i| n % i == 0)
}
/// n以下の素数をすべて返す
pub fn primes_up_to(n: u64) -> Vec<u64> {
(2..=n).filter(|&x| is_prime(x)).collect()
}
/// 素因数分解
pub fn prime_factors(n: u64) -> Vec<u64> {
let mut remaining = n;
let mut factor = 2;
let mut factors = Vec::new();
while remaining > 1 {
while remaining % factor == 0 {
factors.push(factor);
remaining /= factor;
}
factor += 1;
}
factors
}
6. 不変データ構造 - スタックとキュー¶
不変スタック¶
#[derive(Debug, Clone, PartialEq)]
pub struct Stack<T: Clone> {
items: Vec<T>,
}
impl<T: Clone> Stack<T> {
pub fn empty() -> Self {
Stack { items: Vec::new() }
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn size(&self) -> usize {
self.items.len()
}
/// アイテムを追加(新しいスタックを返す)
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 value = new_items.pop().unwrap();
Some((value, Stack { items: new_items }))
}
}
/// 先頭を参照する
pub fn peek(&self) -> Option<&T> {
self.items.last()
}
}
テスト¶
#[test]
fn stack_works_in_lifo_order() {
let stack = Stack::empty()
.push("a")
.push("b")
.push("c");
let (v1, s1) = stack.pop().unwrap();
assert_eq!("c", v1);
let (v2, s2) = s1.pop().unwrap();
assert_eq!("b", v2);
let (v3, s3) = s2.pop().unwrap();
assert_eq!("a", v3);
assert!(s3.is_empty());
}
不変キュー(2つの Vec で実装)¶
#[derive(Debug, Clone, PartialEq)]
pub struct Queue<T: Clone> {
front: Vec<T>,
back: Vec<T>,
}
impl<T: Clone> Queue<T> {
pub fn empty() -> Self {
Queue { front: Vec::new(), back: Vec::new() }
}
pub fn is_empty(&self) -> bool {
self.front.is_empty() && self.back.is_empty()
}
/// アイテムを追加(新しいキューを返す)
pub fn enqueue(&self, item: T) -> Self {
let mut new_back = self.back.clone();
new_back.push(item);
Queue { front: self.front.clone(), back: new_back }
}
/// 先頭を取り出す(新しいキューを返す)
pub fn dequeue(&self) -> Option<(T, Self)> {
if let Some((head, tail)) = self.front.split_first() {
Some((head.clone(), Queue { front: tail.to_vec(), back: self.back.clone() }))
} else if !self.back.is_empty() {
// Vec::push は末尾追加のため、back は oldest-first の順序
let (head, tail) = self.back.split_first().unwrap();
Some((head.clone(), Queue { front: tail.to_vec(), back: Vec::new() }))
} else {
None
}
}
}
7. 文字列電卓 - 段階的な要件追加¶
テスト¶
#[test]
fn empty_string_returns_0() {
assert_eq!(0, string_calculator_add(""));
}
#[test]
fn single_number_returns_its_value() {
assert_eq!(5, string_calculator_add("5"));
}
#[test]
fn comma_separated_numbers_are_summed() {
assert_eq!(6, string_calculator_add("1,2,3"));
}
#[test]
fn newline_separator_is_handled() {
assert_eq!(6, string_calculator_add("1\n2,3"));
}
#[test]
fn custom_delimiter_can_be_used() {
assert_eq!(3, string_calculator_add("//;\n1;2"));
}
#[test]
#[should_panic(expected = "-2")]
fn negative_numbers_cause_panic() {
string_calculator_add("1,-2,3");
}
#[test]
fn numbers_greater_than_1000_are_ignored() {
assert_eq!(2, string_calculator_add("2,1001"));
}
実装¶
/// 区切り文字と数値文字列をパース
fn parse_input(input: &str) -> (&str, &str) {
if let Some(rest) = input.strip_prefix("//") {
if let Some(newline_pos) = rest.find('\n') {
let delimiter = &rest[..newline_pos];
let numbers = &rest[newline_pos + 1..];
(delimiter, numbers)
} else {
(",", input)
}
} else {
(",", input)
}
}
/// 数値をパース
fn parse_numbers(numbers: &str, delimiter: &str) -> Vec<i32> {
numbers
.replace('\n', delimiter)
.split(delimiter)
.filter(|s| !s.is_empty())
.map(|s| s.trim().parse::<i32>().unwrap_or(0))
.collect()
}
/// 負の数をバリデーション
fn validate_numbers(nums: &[i32]) {
let negatives: Vec<i32> = nums.iter().filter(|&&n| n < 0).copied().collect();
if !negatives.is_empty() {
let neg_str: Vec<String> = negatives.iter().map(|n| n.to_string()).collect();
panic!("negatives not allowed: {}", neg_str.join(", "));
}
}
/// 文字列電卓
pub fn string_calculator_add(input: &str) -> i32 {
if input.is_empty() {
return 0;
}
let (delimiter, numbers) = parse_input(input);
let nums = parse_numbers(numbers, delimiter);
validate_numbers(&nums);
nums.iter().filter(|&&n| n <= 1000).sum()
}
8. 純粋関数とテスト容易性¶
純粋関数の利点¶
#[derive(Debug, Clone)]
pub struct Item {
pub name: String,
pub price: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TaxCalculation {
pub subtotal: f64,
pub tax: f64,
pub total: f64,
}
/// 税額を計算
pub fn calculate_tax(amount: f64, rate: f64) -> f64 {
amount * rate
}
/// 税込み総額を計算
pub fn calculate_total_with_tax(items: &[Item], tax_rate: f64) -> TaxCalculation {
let subtotal: f64 = items.iter().map(|i| i.price).sum();
let tax = calculate_tax(subtotal, tax_rate);
TaxCalculation {
subtotal,
tax,
total: subtotal + tax,
}
}
テスト¶
#[test]
fn calculate_total_with_tax_computes_correctly() {
let items = vec![
Item { name: "商品A".to_string(), price: 1000.0 },
Item { name: "商品B".to_string(), price: 2000.0 },
];
let result = calculate_total_with_tax(&items, 0.1);
assert_eq!(3000.0, result.subtotal);
assert_eq!(300.0, result.tax);
assert_eq!(3300.0, result.total);
}
9. リファクタリングパターン - データ駆動の実装¶
Before: 複雑な条件分岐¶
fn calculate_shipping_before(total: f64, weight: f64, region: &Region) -> u32 {
if total >= 10000.0 {
0
} else {
match (region, weight < 5.0) {
(Region::Local, true) => 300,
(Region::Local, false) => 500,
(Region::Domestic, true) => 500,
(Region::Domestic, false) => 800,
(Region::International, true) => 2000,
(Region::International, false) => 3000,
}
}
}
After: データ駆動の実装¶
use std::collections::HashMap;
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub enum Region {
Local,
Domestic,
International,
}
#[derive(Debug, Clone)]
pub struct ShippingOrder {
pub total: f64,
pub weight: f64,
pub region: Region,
}
pub fn is_free_shipping(total: f64) -> bool {
total >= 10000.0
}
fn shipping_rates() -> HashMap<(Region, bool), u32> {
HashMap::from([
((Region::Local, true), 300),
((Region::Local, false), 500),
((Region::Domestic, true), 500),
((Region::Domestic, false), 800),
((Region::International, true), 2000),
((Region::International, false), 3000),
])
}
pub fn calculate_shipping(order: &ShippingOrder) -> u32 {
if is_free_shipping(order.total) {
return 0;
}
let is_light = order.weight < 5.0;
let rates = shipping_rates();
rates
.get(&(order.region.clone(), is_light))
.copied()
.unwrap_or(500)
}
10. パスワードバリデーター - ルールの合成¶
/// バリデーションルールの型
pub type Rule = fn(&str) -> Option<String>;
pub fn min_length(min: usize) -> Rule {
fn check(password: &str) -> Option<String> {
if password.len() >= 8 {
None
} else {
Some(format!("Password must be at least 8 characters"))
}
}
check
}
pub fn has_uppercase(password: &str) -> Option<String> {
if password.chars().any(|c| c.is_uppercase()) {
None
} else {
Some("Password must contain at least one uppercase letter".to_string())
}
}
pub fn has_lowercase(password: &str) -> Option<String> {
if password.chars().any(|c| c.is_lowercase()) {
None
} else {
Some("Password must contain at least one lowercase letter".to_string())
}
}
pub fn has_digit(password: &str) -> Option<String> {
if password.chars().any(|c| c.is_ascii_digit()) {
None
} else {
Some("Password must contain at least one digit".to_string())
}
}
pub fn validate_password(password: &str, rules: &[Rule]) -> Result<String, Vec<String>> {
let errors: Vec<String> = rules.iter().filter_map(|rule| rule(password)).collect();
if errors.is_empty() {
Ok(password.to_string())
} else {
Err(errors)
}
}
pub fn validate_with_defaults(password: &str) -> Result<String, Vec<String>> {
let rules: Vec<Rule> = vec![min_length(8), has_uppercase, has_lowercase, has_digit];
validate_password(password, &rules)
}
TDD のベストプラクティス¶
1. 小さなステップで進む¶
- 一度に1つのテストだけを追加
- テストが通ったら次のテストへ
2. テスト名は仕様として読める¶
#[test]
fn orders_over_10000_yen_have_free_shipping() { ... }
#[test]
fn negative_numbers_cause_panic() { ... }
#[test]
fn perfect_game_scores_300() { ... }
3. 純粋関数を優先¶
- 副作用を持つ関数は最小限に
- 副作用は境界に追い出す
4. エッジケースをテスト¶
#[test]
fn empty_string_returns_0() { ... }
#[test]
fn empty_list_returns_empty_list() { ... }
#[test]
fn boundary_values_work_correctly() { ... }
Clojure/Scala との比較¶
| 概念 | Clojure | Scala | Rust |
|---|---|---|---|
| テストフレームワーク | speclj, clojure.test | ScalaTest | #[cfg(test)], proptest |
| テスト構文 | (it "..." (should= ...)) |
test("...") { ... shouldBe ... } |
#[test] fn ...() { assert_eq!(...) } |
| 例外テスト | (should-throw ...) |
intercept[...] { ... } |
#[should_panic(expected = "...")] |
| データ構造 | 永続化データ構造(デフォルト) | case class + copy |
struct + clone |
| ループ | loop/recur |
@annotation.tailrec |
ループ / 再帰 |
| パターンマッチ | cond, case |
match |
match |
| 型システム | 動的型付け | 静的型付け | 静的型付け + 所有権 |
Rust 特有のパターン¶
#[should_panic] による例外テスト¶
Rust では #[should_panic] アトリビュートでパニックをテストできる:
#[test]
#[should_panic(expected = "negatives not allowed")]
fn negative_numbers_cause_panic() {
string_calculator_add("1,-2,3");
}
所有権と不変性のコンパイル時保証¶
pub fn pop(&self) -> Option<(T, Self)> {
// self は不変参照のため、元のスタックは変更されない
// 新しいスタックを返すことで不変性を保証
}
イテレータチェーンによる関数型パイプライン¶
pub fn fizzbuzz_sequence(n: u32) -> Vec<String> {
(1..=n).map(fizzbuzz).collect()
}
まとめ¶
本章では、TDD と関数型プログラミングについて学びました:
- Red-Green-Refactor: 基本サイクル
- FizzBuzz: 典型的な TDD 例
- ローマ数字: データ駆動の実装
- ボウリング: 複雑なビジネスロジック
- 素数: シンプルな関数設計
- スタック/キュー: 不変データ構造
- 文字列電卓: 段階的な要件追加
- 純粋関数: テスト容易性
- リファクタリング: 条件分岐の整理
- パスワードバリデーター: ルールの合成
関数型プログラミングと TDD の組み合わせにより、信頼性の高いコードを効率的に開発できます。
次章予告¶
次章から第3部「デザインパターン - 構造パターン」に入ります。Composite パターンを関数型スタイルで実装する方法を学びます。