第6回:テストとリファクタリング

関連記事

1. はじめに

みなさん、こんにちは!前回は大規模アプリケーションの構造化について学びました。今日はリファクタリングを安全に行うための重要な技術、「テストとリファクタリング」について学んでいきます。

コードをリファクタリングするのは、家の中の配線や水道管を直すようなものです。外から見た機能は変わらないけれど、内部はより良くなっている—それがリファクタリングの本質です。でも、うっかり間違えると、水漏れや停電が起きるかもしれません。そこで、テストという「安全確認」が必要になるのです。

今日の講義では、安全にリファクタリングを進めるためのテスト戦略と、リファクタリングの実践的な進め方について学びます。

2. リファクタリング前のテスト作成

2.1. なぜテストが必要か?

リファクタリングとは「外部から見た動作を変えずに、内部構造を改善すること」です。では、「外部から見た動作が変わっていないか」をどうやって確認するのでしょうか?それがテストの役割です。

テストがないと、リファクタリング後に「動いているように見えるけど、実は特定の条件で動かなくなっている」という事態が起こりがちです。これは非常に危険です。

2.2. テストの種類

主なテストの種類には以下があります:

  1. 単体テスト(ユニットテスト):個々の関数やクラスが正しく動作するかをテスト
  2. 統合テスト(インテグレーションテスト):複数のコンポーネントが連携して動作するかをテスト
  3. エンドツーエンドテスト:ユーザーの視点でアプリケーション全体の動作をテスト

リファクタリングでは、特に単体テストと統合テストが重要です。

2.3. テスト作成の基本手順

  1. 現在の動作を理解する:リファクタリング対象のコードが何をしているのか把握する
  2. ケースを洗い出す:正常ケース、エラーケース、境界値ケースなど
  3. 期待される結果を定義する:入力に対してどんな出力や状態変化があるべきか
  4. テストコードを書く:テストフレームワークを使ってテストを記述
  5. テストを実行して合格することを確認:リファクタリング前の段階でテストが通ることを確認

例えば、割引計算をする関数のテスト例を見てみましょう:

// テスト対象の関数
function calculateDiscount(price, userType) {
  if (userType === 'premium') {
    return price * 0.2; // 20%割引
  } else if (userType === 'regular') {
    return price * 0.1; // 10%割引
  } else {
    return 0; // 割引なし
  }
}

// Jestを使ったテスト例
describe('calculateDiscount', () => {
  test('プレミアムユーザーは20%割引', () => {
    expect(calculateDiscount(1000, 'premium')).toBe(200);
  });
  
  test('一般ユーザーは10%割引', () => {
    expect(calculateDiscount(1000, 'regular')).toBe(100);
  });
  
  test('その他のユーザーは割引なし', () => {
    expect(calculateDiscount(1000, 'guest')).toBe(0);
  });
  
  test('価格0円の場合は割引も0円', () => {
    expect(calculateDiscount(0, 'premium')).toBe(0);
  });
});
Code language: PHP (php)

この例では、さまざまなケースをテストしています。これで、リファクタリング後も正しく動作するかを確認できます。

2.4. カバレッジとは

テストカバレッジとは、コードのどれだけの部分がテストされているかを示す指標です。例えば:

  • 行カバレッジ:テストが実行したコードの行数の割合
  • 分岐カバレッジ:条件分岐の選択肢がテストでカバーされた割合
  • 関数カバレッジ:テストされた関数の割合

100%のカバレッジを目指すのは現実的ではないことが多いですが、重要なコアロジックは高いカバレッジを保つことが望ましいです。

3. 安全なリファクタリングの進め方

3.1. リファクタリングの基本ステップ

  1. テストが通ることを確認する:まず既存のテストが通ることを確認
  2. 小さな変更を加える:一度に大きな変更を加えず、小さなステップで進める
  3. テストを実行する:各ステップごとにテストを実行して、動作が壊れていないことを確認
  4. コミットする:テストが通ったら変更をコミット
  5. 繰り返す:次の小さな変更へ進む

このサイクルを繰り返すことで、安全にリファクタリングを進められます。

3.2. リファクタリングの例

先ほどの割引計算関数をリファクタリングしてみましょう:

// リファクタリング前
function calculateDiscount(price, userType) {
  if (userType === 'premium') {
    return price * 0.2; // 20%割引
  } else if (userType === 'regular') {
    return price * 0.1; // 10%割引
  } else {
    return 0; // 割引なし
  }
}

// リファクタリング後
function calculateDiscount(price, userType) {
  const discountRates = {
    'premium': 0.2,
    'regular': 0.1,
    'guest': 0
  };
  
  const rate = discountRates[userType] || 0;
  return price * rate;
}
Code language: JavaScript (javascript)

このリファクタリングでは、if-elseの連鎖を辞書(オブジェクト)を使った参照に変更しました。これで、新しいユーザータイプを追加するのも簡単になりました。

リファクタリング後、すべてのテストが通ることを確認します。これで安全に構造を改善できました。

3.3. リファクタリングの種類

主なリファクタリングパターンには以下があります:

  1. 抽出:メソッドの抽出、クラスの抽出など
  2. 移動:メソッドの移動、フィールドの移動など
  3. 整理:条件の単純化、メソッド名の変更など
  4. 一般化:インターフェースの抽出、スーパークラスの抽出など

それぞれの状況に応じて適切なパターンを選び、テストを行いながら進めることが大切です。

4. テスト駆動開発とモジュール設計

4.1. テスト駆動開発(TDD)とは

テスト駆動開発は「テストを先に書いてから、それを満たすコードを実装する」という開発手法です。基本的なサイクルは:

  1. テストを書く:まだ存在しない機能のテストを書く(テストは失敗する)
  2. 実装する:テストが通るように、最小限のコードを書く
  3. リファクタリングする:テストが通ることを確認しながら、コードを整理する

4.2. TDDのメリット

  1. 設計の明確化:テストを先に書くことで、機能の要件とインターフェースが明確になる
  2. 過剰な実装の防止:必要なことだけを実装するよう導いてくれる
  3. テストカバレッジの確保:自然と高いテストカバレッジが達成される

4.3. TDDとモジュール設計

TDDは良いモジュール設計にも役立ちます。テストしやすいコードを書くには:

  1. 依存関係の注入:直接依存するのではなく、依存を外から渡す
  2. インターフェースの利用:実装ではなくインターフェースに依存する
  3. 単一責任の徹底:一つのクラスや関数は一つのことだけを行う

例えば、データベースに依存するクラスをテストしやすく設計するには:

// テストしにくい設計
class UserService {
  constructor() {
    this.db = new Database(); // 直接依存
  }
  
  getUser(id) {
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

// テストしやすい設計
class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository; // 依存性の注入
  }
  
  getUser(id) {
    return this.userRepository.findById(id);
  }
}

// テスト例
describe('UserService', () => {
  test('ユーザーを取得できる', () => {
    // モックのリポジトリを作成
    const mockRepo = {
      findById: jest.fn().mockReturnValue({ id: 1, name: 'Test User' })
    };
    
    const service = new UserService(mockRepo);
    const user = service.getUser(1);
    
    expect(user).toEqual({ id: 1, name: 'Test User' });
    expect(mockRepo.findById).toHaveBeenCalledWith(1);
  });
});
Code language: JavaScript (javascript)

このようにすると、実際のデータベースに依存せずにテストできるようになります。

5. 実習:テストを書きながらコードを改善する

では、実際にテストを書きながらコードをリファクタリングする実習を行いましょう。

次のような注文処理クラスがあるとします:

class OrderProcessor {
  constructor() {
    this.orders = [];
  }
  
  processOrder(products, customerInfo) {
    // 注文のバリデーション
    if (!products || products.length === 0) {
      throw new Error('商品が選択されていません');
    }
    
    if (!customerInfo || !customerInfo.name || !customerInfo.address) {
      throw new Error('お客様情報が不完全です');
    }
    
    // 合計金額計算
    let total = 0;
    for (const product of products) {
      total += product.price;
    }
    
    // 送料計算
    let shippingFee = 500;
    if (total >= 5000) {
      shippingFee = 0;
    }
    
    // 注文情報の作成
    const order = {
      id: `ORDER-${Date.now()}`,
      products: products,
      customer: customerInfo,
      total: total,
      shippingFee: shippingFee,
      status: 'created',
      createdAt: new Date()
    };
    
    // 注文の保存
    this.orders.push(order);
    
    // 注文確認メールの送信
    this.sendConfirmationEmail(order);
    
    return order;
  }
  
  sendConfirmationEmail(order) {
    // 実際にはメール送信のロジックがここにある
    console.log(`${order.customer.name}様、ご注文ありがとうございます。注文ID: ${order.id}`);
  }
}
Code language: JavaScript (javascript)

5.1. ステップ1:テストを書く

まず、このOrderProcessorクラスのテストを書きましょう:

describe('OrderProcessor', () => {
  let orderProcessor;
  
  beforeEach(() => {
    orderProcessor = new OrderProcessor();
    // メール送信のモック化
    orderProcessor.sendConfirmationEmail = jest.fn();
  });
  
  test('有効な注文を処理できる', () => {
    const products = [
      { id: 1, name: 'ノートPC', price: 80000 },
      { id: 2, name: 'マウス', price: 3000 }
    ];
    
    const customerInfo = {
      name: '山田太郎',
      address: '東京都渋谷区...'
    };
    
    const order = orderProcessor.processOrder(products, customerInfo);
    
    expect(order.products).toEqual(products);
    expect(order.customer).toEqual(customerInfo);
    expect(order.total).toBe(83000);
    expect(order.shippingFee).toBe(0); // 5000円以上なので送料無料
    expect(order.status).toBe('created');
    expect(orderProcessor.orders.length).toBe(1);
    expect(orderProcessor.sendConfirmationEmail).toHaveBeenCalledWith(order);
  });
  
  test('商品が選択されていない場合はエラー', () => {
    const customerInfo = { name: '山田太郎', address: '東京都渋谷区...' };
    
    expect(() => {
      orderProcessor.processOrder([], customerInfo);
    }).toThrow('商品が選択されていません');
  });
  
  test('顧客情報が不完全な場合はエラー', () => {
    const products = [{ id: 1, name: 'ノートPC', price: 80000 }];
    
    expect(() => {
      orderProcessor.processOrder(products, { name: '山田太郎' });
    }).toThrow('お客様情報が不完全です');
  });
  
  test('5000円未満の注文には送料がかかる', () => {
    const products = [{ id: 3, name: 'ペン', price: 300 }];
    const customerInfo = { name: '山田太郎', address: '東京都渋谷区...' };
    
    const order = orderProcessor.processOrder(products, customerInfo);
    
    expect(order.total).toBe(300);
    expect(order.shippingFee).toBe(500);
  });
});
Code language: PHP (php)

これで、現在のコードの動作をテストで保証できました。

5.2. ステップ2:リファクタリング

次に、このコードをリファクタリングしていきます。主な改善点は:

  1. 責任の分離:バリデーション、価格計算、注文作成を別々の関数に分ける
  2. 依存性の注入:メール送信を外部から注入できるようにする

リファクタリング後のコード:

class OrderValidator {
  validate(products, customerInfo) {
    if (!products || products.length === 0) {
      throw new Error('商品が選択されていません');
    }
    
    if (!customerInfo || !customerInfo.name || !customerInfo.address) {
      throw new Error('お客様情報が不完全です');
    }
  }
}

class PriceCalculator {
  calculateTotal(products) {
    return products.reduce((total, product) => total + product.price, 0);
  }
  
  calculateShippingFee(total) {
    return total >= 5000 ? 0 : 500;
  }
}

class OrderProcessor {
  constructor(emailService) {
    this.orders = [];
    this.validator = new OrderValidator();
    this.priceCalculator = new PriceCalculator();
    this.emailService = emailService || {
      sendConfirmationEmail: (order) => {
        console.log(`${order.customer.name}様、ご注文ありがとうございます。注文ID: ${order.id}`);
      }
    };
  }
  
  processOrder(products, customerInfo) {
    // バリデーション
    this.validator.validate(products, customerInfo);
    
    // 金額計算
    const total = this.priceCalculator.calculateTotal(products);
    const shippingFee = this.priceCalculator.calculateShippingFee(total);
    
    // 注文情報の作成
    const order = this.createOrder(products, customerInfo, total, shippingFee);
    
    // 注文の保存
    this.orders.push(order);
    
    // 注文確認メールの送信
    this.emailService.sendConfirmationEmail(order);
    
    return order;
  }
  
  createOrder(products, customerInfo, total, shippingFee) {
    return {
      id: `ORDER-${Date.now()}`,
      products: products,
      customer: customerInfo,
      total: total,
      shippingFee: shippingFee,
      status: 'created',
      createdAt: new Date()
    };
  }
}
Code language: JavaScript (javascript)

5.3. ステップ3:テストの更新

リファクタリングに伴い、テストも少し更新する必要があります:

describe('OrderProcessor', () => {
  let orderProcessor;
  let mockEmailService;
  
  beforeEach(() => {
    mockEmailService = {
      sendConfirmationEmail: jest.fn()
    };
    orderProcessor = new OrderProcessor(mockEmailService);
  });
  
  // 以下、テストケースは同じ...
});
Code language: JavaScript (javascript)

このように、外部からメールサービスを注入できるようになりました。

5.4. ステップ4:新しいテストを追加

分割したクラスに対する単体テストも追加しましょう:

describe('OrderValidator', () => {
  let validator;
  
  beforeEach(() => {
    validator = new OrderValidator();
  });
  
  test('有効な入力を検証できる', () => {
    const products = [{ id: 1, name: 'ノートPC', price: 80000 }];
    const customerInfo = { name: '山田太郎', address: '東京都渋谷区...' };
    
    // エラーが発生しないことを検証
    expect(() => {
      validator.validate(products, customerInfo);
    }).not.toThrow();
  });
  
  // 他のテストケース...
});

describe('PriceCalculator', () => {
  let calculator;
  
  beforeEach(() => {
    calculator = new PriceCalculator();
  });
  
  test('商品の合計金額を計算できる', () => {
    const products = [
      { id: 1, price: 1000 },
      { id: 2, price: 2000 },
      { id: 3, price: 3000 }
    ];
    
    expect(calculator.calculateTotal(products)).toBe(6000);
  });
  
  test('5000円以上なら送料無料', () => {
    expect(calculator.calculateShippingFee(5000)).toBe(0);
    expect(calculator.calculateShippingFee(10000)).toBe(0);
  });
  
  test('5000円未満なら送料500円', () => {
    expect(calculator.calculateShippingFee(4999)).toBe(500);
    expect(calculator.calculateShippingFee(1000)).toBe(500);
  });
});
Code language: PHP (php)

6. まとめ

今日の講義では、テストとリファクタリングについて学びました:

  1. リファクタリング前のテスト作成:変更前の動作を保証するテストを書く
  2. 安全なリファクタリングの進め方:小さなステップで進め、都度テストする
  3. テスト駆動開発とモジュール設計:テストを先に書くことで、良い設計を導く

テストは「保険」のようなものです。最初は手間に感じるかもしれませんが、変更が必要になったときに大きな安心を与えてくれます。リファクタリングを行う際は、必ずテストを用意してから始めましょう。

次回は「設計パターンとモジュール分割」について学びます。今日学んだテストの知識も活かしながら、より堅牢なコードを書く方法を探っていきましょう。

7. 質問タイム

みなさん、今日の講義内容について質問はありますか?

8. 質問1:中村さんからの質問

質問:「テストを書くのは大事だと分かりましたが、既存の大きなプロジェクトにはテストがほとんどありません。どこから始めるべきでしょうか?全部にテストを書くのは現実的ではないと思うのですが…」

回答: とても現実的な質問ですね、中村さん。既存のプロジェクトにテストを導入する場合は、次のように段階的にアプローチするのがおすすめです:

  1. 変更するコードから始める:これから修正や機能追加をするコードに対してまずテストを書きましょう。「テストカバレッジ負債」と呼びますが、すべてを一度に返済する必要はありません。
  2. 重要なビジネスロジックを優先する:例えば、決済処理や会員登録など、バグが大きな問題になる部分を優先してテストしましょう。
  3. バグ修正時にテストを追加する:バグを修正するときは、そのバグを再現するテストを先に書いてから修正します。これにより、同じバグが再発するのを防げます。
  4. テストしやすい部分から始める:外部依存が少ないコアロジックは比較的テストしやすいので、そこから始めると効率的です。
  5. 新しいコードには必ずテストを書く:少なくとも新しく書くコードにはテストを追加するルールを作りましょう。

時間をかけて少しずつテストカバレッジを上げていくことが大切です。完璧を目指すより、リスクの高い部分から着実にカバーしていく方が現実的です。

9. 質問2:吉田さんからの質問

質問:「テストとリファクタリングの関係がよく分かりました。ただ、モックやスタブの使い方がまだ理解できていません。外部サービスに依存するコードをテストする具体的な方法を教えていただけますか?」

回答: 素晴らしい質問です、吉田さん。モックとスタブは外部依存のあるコードをテストする重要な技術です。

モックとスタブの違い

  • スタブ:テスト用に固定の応答を返す単純なオブジェクト
  • モック:呼び出されたかどうかや、どのように呼び出されたかを検証できるオブジェクト

外部サービスへの依存をテストする例: データベースに依存するユーザー登録サービスを考えてみましょう。

// 本番コード
class UserRegistrationService {
  constructor(userRepository, emailService) {
    this.userRepository = userRepository;
    this.emailService = emailService;
  }
  
  registerUser(userData) {
    // メールアドレスの重複チェック
    if (this.userRepository.findByEmail(userData.email)) {
      throw new Error('このメールアドレスは既に登録されています');
    }
    
    // ユーザー登録
    const user = this.userRepository.save({
      ...userData,
      createdAt: new Date()
    });
    
    // 確認メール送信
    this.emailService.sendWelcomeEmail(user.email, user.name);
    
    return user;
  }
}

// テストコード
describe('UserRegistrationService', () => {
  test('新規ユーザーを登録できる', () => {
    // スタブ: 固定の応答を返す
    const stubRepository = {
      findByEmail: (email) => null, // メールアドレスの重複はない
      save: (userData) => ({...userData, id: 123}) // 保存して ID を付与
    };
    
    // モック: 呼び出されたかを検証できる
    const mockEmailService = {
      sendWelcomeEmail: jest.fn() // Jestのモック関数
    };
    
    const service = new UserRegistrationService(stubRepository, mockEmailService);
    
    // テスト実行
    const result = service.registerUser({
      name: '山田太郎',
      email: 'taro@example.com',
      password: 'password123'
    });
    
    // アサーション(検証)
    expect(result.id).toBe(123);
    expect(result.name).toBe('山田太郎');
    
    // モックが正しく呼び出されたか検証
    expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
      'taro@example.com',
      '山田太郎'
    );
  });
});
Code language: JavaScript (javascript)

実際のアプリケーションでの実装方法

  1. 依存性の注入:テストしやすくするため、外部サービスを constructor で注入するパターンを使いましょう
  2. インターフェース定義:依存するサービスのインターフェースを明確に定義しておくと、モックが作りやすくなります
  3. テスト用ダブルの作成:本番環境では実際のサービス、テスト環境ではモック/スタブを使うよう切り替えます

この方法を使えば、実際のデータベースやメールサーバーがなくても、ロジックをテストできるようになります。

10. 質問3:伊藤さんからの質問

質問:「TDDは良いと理解できましたが、実際に使うとかなり時間がかかりそうです。チームの開発速度を落とさずにTDDを導入する方法はありますか?また、どのレベル(単体/統合/E2E)のテストにどれくらいリソースを割くべきでしょうか?」

回答: 実践的で重要な質問ですね、伊藤さん。TDDの導入と効率的なテスト戦略について考えていきましょう。

TDDを効率的に導入する方法

  1. 段階的に導入する:最初から全てのコードにTDDを適用するのではなく、重要な部分や複雑なロジックから始めましょう。
  2. ペアプログラミングを活用する:TDD経験者と未経験者がペアを組むことで、効率よく学習できます。
  3. 短いフィードバックサイクルを維持する:テストの実行が速いことが重要です。時間のかかるテストは別のタイミングで実行するよう設定しましょう。
  4. テストファーストの習慣づけ:「テストを書いてからコードを書く」という習慣が身につくまでは意識的に行う必要があります。

テストピラミッドとリソース配分

一般的には次のような「テストピラミッド」が推奨されています:

    /\     E2Eテスト (10%)
   /  \    
  /    \   統合テスト (20%)
 /      \  
/________\ 単体テスト (70%)
  • 単体テスト:コストが低く、実行が速い。最も多く書くべきテスト。
  • 統合テスト:複数のコンポーネントの連携を確認。
  • E2Eテスト:実際のユーザー操作を模したテスト。重要なフローのみに限定。

効率化のコツ

  1. テストしやすいコードを書く:依存性の注入や、関心の分離を意識すると、テストの作成が楽になります。
  2. テストデータの共有:テストごとにデータを作るのではなく、テスト用のファクトリー関数やフィクスチャを用意しましょう。
  3. 自動化の徹底:CI/CDパイプラインでテストを自動実行し、常にフィードバックを得られるようにします。

導入初期は確かに開発速度が落ちることがありますが、長期的には:

  • バグの早期発見
  • リファクタリングの安全性向上
  • 設計の改善 により、総合的な開発速度は向上することが多いです。

短期的なスピードを犠牲にしないよう、段階的に導入して、チームが習熟していくことが大切です。