EXERCISE 90分

ストーリー

高橋アーキテクト
これは実際のプロジェクトで見つかったコードを簡略化したものだ
あなた
テストを書き、コードスメルを見つけ、段階的にリファクタリングしてほしい。一気に全部変えるのではなく、小さなステップで進めることを忘れずに

ミッション概要

ミッションテーマテクニック目安時間
Mission 1特性テストを書こうCharacterization Test15分
Mission 2長いメソッドを分割せよExtract Method15分
Mission 3プリミティブ執着を解消せよValue Object15分
Mission 4条件分岐をポリモーフィズムにReplace Conditional20分
Mission 5クラスを抽出せよExtract Class10分
Mission 6総合リファクタリング全テクニック15分

対象コード

以下のレガシーコードをミッション全体を通じてリファクタリングします。

class PayrollSystem {
  calculatePayslip(employeeData: any): string {
    let result = '';
    let basePay = 0;
    let overtime = 0;
    let deductions = 0;
    let bonus = 0;

    // 基本給の計算
    if (employeeData.type === 'fulltime') {
      basePay = employeeData.annualSalary / 12;
      if (employeeData.hoursWorked > 160) {
        overtime = (employeeData.hoursWorked - 160) * (employeeData.annualSalary / 12 / 160) * 1.25;
      }
    } else if (employeeData.type === 'parttime') {
      basePay = employeeData.hourlyRate * employeeData.hoursWorked;
      if (employeeData.hoursWorked > 80) {
        overtime = (employeeData.hoursWorked - 80) * employeeData.hourlyRate * 1.25;
      }
    } else if (employeeData.type === 'contractor') {
      basePay = employeeData.dailyRate * employeeData.daysWorked;
    }

    // 控除の計算
    if (employeeData.type !== 'contractor') {
      deductions += (basePay + overtime) * 0.1;  // 社会保険
      deductions += (basePay + overtime) * 0.05; // 年金
      if (basePay + overtime > 500000) {
        deductions += (basePay + overtime - 500000) * 0.1; // 高所得者追加税
      }
    }

    // ボーナスの計算
    if (employeeData.type === 'fulltime' && employeeData.performanceRating) {
      if (employeeData.performanceRating === 'excellent') {
        bonus = basePay * 0.3;
      } else if (employeeData.performanceRating === 'good') {
        bonus = basePay * 0.15;
      } else if (employeeData.performanceRating === 'average') {
        bonus = basePay * 0.05;
      }
    }

    // 給与明細の生成
    const netPay = basePay + overtime + bonus - deductions;
    result += `=== Pay Slip ===\n`;
    result += `Employee: ${employeeData.name}\n`;
    result += `Type: ${employeeData.type}\n`;
    result += `Base Pay: ${basePay}\n`;
    result += `Overtime: ${overtime}\n`;
    result += `Bonus: ${bonus}\n`;
    result += `Deductions: ${deductions}\n`;
    result += `Net Pay: ${netPay}\n`;
    result += `================\n`;
    return result;
  }
}

Mission 1: 特性テストを書こう(15分)

要件

上記の calculatePayslip メソッドの現在の振る舞いを記録する特性テストを書いてください。全従業員タイプについてテストケースを作成してください。

期待される動作

  • フルタイム従業員(残業あり/なし、ボーナスあり/なし)
  • パートタイム従業員(残業あり/なし)
  • 契約社員
解答
describe('PayrollSystem - Characterization Tests', () => {
  const system = new PayrollSystem();

  describe('fulltime employee', () => {
    test('basic pay without overtime', () => {
      const data = {
        name: 'Alice', type: 'fulltime',
        annualSalary: 6000000, hoursWorked: 160,
        performanceRating: null,
      };
      const result = system.calculatePayslip(data);
      expect(result).toContain('Base Pay: 500000');
      expect(result).toContain('Overtime: 0');
      expect(result).toContain('Bonus: 0');
      expect(result).toContain('Deductions: 75000'); // 500000 * 0.15
      expect(result).toContain('Net Pay: 425000');
    });

    test('with overtime and excellent rating', () => {
      const data = {
        name: 'Bob', type: 'fulltime',
        annualSalary: 6000000, hoursWorked: 180,
        performanceRating: 'excellent',
      };
      const result = system.calculatePayslip(data);
      expect(result).toContain('Base Pay: 500000');
      // overtime = 20 * (500000/160) * 1.25 = 78125
      expect(result).toContain('Overtime: 78125');
      expect(result).toContain('Bonus: 150000'); // 500000 * 0.3
    });
  });

  describe('parttime employee', () => {
    test('without overtime', () => {
      const data = {
        name: 'Charlie', type: 'parttime',
        hourlyRate: 2000, hoursWorked: 60,
      };
      const result = system.calculatePayslip(data);
      expect(result).toContain('Base Pay: 120000');
      expect(result).toContain('Overtime: 0');
    });

    test('with overtime', () => {
      const data = {
        name: 'Diana', type: 'parttime',
        hourlyRate: 2000, hoursWorked: 100,
      };
      const result = system.calculatePayslip(data);
      expect(result).toContain('Base Pay: 200000');
      // overtime = 20 * 2000 * 1.25 = 50000
      expect(result).toContain('Overtime: 50000');
    });
  });

  describe('contractor', () => {
    test('basic pay no deductions', () => {
      const data = {
        name: 'Eve', type: 'contractor',
        dailyRate: 50000, daysWorked: 20,
      };
      const result = system.calculatePayslip(data);
      expect(result).toContain('Base Pay: 1000000');
      expect(result).toContain('Deductions: 0');
      expect(result).toContain('Net Pay: 1000000');
    });
  });
});

Mission 2: 長いメソッドを分割せよ(15分)

要件

calculatePayslip メソッドを Extract Method で分割してください。テストが通り続けることを確認しながら進めてください。

解答
class PayrollSystem {
  calculatePayslip(employeeData: any): string {
    const basePay = this.calculateBasePay(employeeData);
    const overtime = this.calculateOvertime(employeeData);
    const deductions = this.calculateDeductions(employeeData, basePay, overtime);
    const bonus = this.calculateBonus(employeeData, basePay);
    const netPay = basePay + overtime + bonus - deductions;

    return this.formatPayslip(employeeData, basePay, overtime, bonus, deductions, netPay);
  }

  private calculateBasePay(data: any): number {
    if (data.type === 'fulltime') return data.annualSalary / 12;
    if (data.type === 'parttime') return data.hourlyRate * data.hoursWorked;
    if (data.type === 'contractor') return data.dailyRate * data.daysWorked;
    return 0;
  }

  private calculateOvertime(data: any): number {
    if (data.type === 'fulltime' && data.hoursWorked > 160) {
      return (data.hoursWorked - 160) * (data.annualSalary / 12 / 160) * 1.25;
    }
    if (data.type === 'parttime' && data.hoursWorked > 80) {
      return (data.hoursWorked - 80) * data.hourlyRate * 1.25;
    }
    return 0;
  }

  private calculateDeductions(data: any, basePay: number, overtime: number): number {
    if (data.type === 'contractor') return 0;
    const gross = basePay + overtime;
    let deductions = gross * 0.15; // 社会保険 + 年金
    if (gross > 500000) {
      deductions += (gross - 500000) * 0.1;
    }
    return deductions;
  }

  private calculateBonus(data: any, basePay: number): number {
    if (data.type !== 'fulltime' || !data.performanceRating) return 0;
    const rates: Record<string, number> = { excellent: 0.3, good: 0.15, average: 0.05 };
    return basePay * (rates[data.performanceRating] ?? 0);
  }

  private formatPayslip(
    data: any, basePay: number, overtime: number,
    bonus: number, deductions: number, netPay: number
  ): string {
    return [
      '=== Pay Slip ===',
      `Employee: ${data.name}`,
      `Type: ${data.type}`,
      `Base Pay: ${basePay}`,
      `Overtime: ${overtime}`,
      `Bonus: ${bonus}`,
      `Deductions: ${deductions}`,
      `Net Pay: ${netPay}`,
      '================',
    ].join('\n') + '\n';
  }
}

Mission 3: プリミティブ執着を解消せよ(15分)

要件

employeeData: any をプリミティブから型安全なオブジェクトに変更してください。値オブジェクトを導入してください。

解答
type EmployeeType = 'fulltime' | 'parttime' | 'contractor';
type PerformanceRating = 'excellent' | 'good' | 'average';

class Money {
  constructor(readonly amount: number) {
    if (amount < 0) throw new Error('Money cannot be negative');
  }
  add(other: Money): Money { return new Money(this.amount + other.amount); }
  subtract(other: Money): Money { return new Money(this.amount - other.amount); }
  multiply(factor: number): Money { return new Money(Math.round(this.amount * factor * 100) / 100); }
  toString(): string { return this.amount.toString(); }
}

interface FulltimeEmployee {
  name: string;
  type: 'fulltime';
  annualSalary: number;
  hoursWorked: number;
  performanceRating?: PerformanceRating;
}

interface ParttimeEmployee {
  name: string;
  type: 'parttime';
  hourlyRate: number;
  hoursWorked: number;
}

interface ContractorEmployee {
  name: string;
  type: 'contractor';
  dailyRate: number;
  daysWorked: number;
}

type Employee = FulltimeEmployee | ParttimeEmployee | ContractorEmployee;

Mission 4: 条件分岐をポリモーフィズムに(20分)

要件

従業員タイプごとの条件分岐を、ポリモーフィズムに置き換えてください。各従業員タイプのクラスを作成してください。

解答
interface PayCalculator {
  calculateBasePay(): number;
  calculateOvertime(): number;
  calculateDeductions(grossPay: number): number;
  calculateBonus(basePay: number): number;
}

class FulltimePayCalculator implements PayCalculator {
  constructor(private employee: FulltimeEmployee) {}

  calculateBasePay(): number {
    return this.employee.annualSalary / 12;
  }

  calculateOvertime(): number {
    if (this.employee.hoursWorked <= 160) return 0;
    const hourlyRate = this.calculateBasePay() / 160;
    return (this.employee.hoursWorked - 160) * hourlyRate * 1.25;
  }

  calculateDeductions(grossPay: number): number {
    let deductions = grossPay * 0.15;
    if (grossPay > 500000) {
      deductions += (grossPay - 500000) * 0.1;
    }
    return deductions;
  }

  calculateBonus(basePay: number): number {
    if (!this.employee.performanceRating) return 0;
    const rates: Record<string, number> = { excellent: 0.3, good: 0.15, average: 0.05 };
    return basePay * (rates[this.employee.performanceRating] ?? 0);
  }
}

class ParttimePayCalculator implements PayCalculator {
  constructor(private employee: ParttimeEmployee) {}

  calculateBasePay(): number {
    return this.employee.hourlyRate * this.employee.hoursWorked;
  }

  calculateOvertime(): number {
    if (this.employee.hoursWorked <= 80) return 0;
    return (this.employee.hoursWorked - 80) * this.employee.hourlyRate * 1.25;
  }

  calculateDeductions(grossPay: number): number {
    let deductions = grossPay * 0.15;
    if (grossPay > 500000) {
      deductions += (grossPay - 500000) * 0.1;
    }
    return deductions;
  }

  calculateBonus(_basePay: number): number { return 0; }
}

class ContractorPayCalculator implements PayCalculator {
  constructor(private employee: ContractorEmployee) {}

  calculateBasePay(): number {
    return this.employee.dailyRate * this.employee.daysWorked;
  }

  calculateOvertime(): number { return 0; }
  calculateDeductions(_grossPay: number): number { return 0; }
  calculateBonus(_basePay: number): number { return 0; }
}

// Factory
function createPayCalculator(employee: Employee): PayCalculator {
  switch (employee.type) {
    case 'fulltime': return new FulltimePayCalculator(employee);
    case 'parttime': return new ParttimePayCalculator(employee);
    case 'contractor': return new ContractorPayCalculator(employee);
  }
}

Mission 5: クラスを抽出せよ(10分)

要件

給与明細のフォーマットを独立したクラスに抽出してください。

解答
interface PayslipData {
  employeeName: string;
  employeeType: string;
  basePay: number;
  overtime: number;
  bonus: number;
  deductions: number;
  netPay: number;
}

class PayslipFormatter {
  format(data: PayslipData): string {
    return [
      '=== Pay Slip ===',
      `Employee: ${data.employeeName}`,
      `Type: ${data.employeeType}`,
      `Base Pay: ${data.basePay}`,
      `Overtime: ${data.overtime}`,
      `Bonus: ${data.bonus}`,
      `Deductions: ${data.deductions}`,
      `Net Pay: ${data.netPay}`,
      '================',
    ].join('\n') + '\n';
  }
}

Mission 6: 総合リファクタリング(15分)

要件

Mission 2-5 の成果をすべて統合し、リファクタリング後の完全なコードを完成させてください。元のテストがすべて通ることを確認してください。

解答
class PayrollSystem {
  private formatter = new PayslipFormatter();

  calculatePayslip(employee: Employee): string {
    const calculator = createPayCalculator(employee);

    const basePay = calculator.calculateBasePay();
    const overtime = calculator.calculateOvertime();
    const grossPay = basePay + overtime;
    const deductions = calculator.calculateDeductions(grossPay);
    const bonus = calculator.calculateBonus(basePay);
    const netPay = basePay + overtime + bonus - deductions;

    return this.formatter.format({
      employeeName: employee.name,
      employeeType: employee.type,
      basePay,
      overtime,
      bonus,
      deductions,
      netPay,
    });
  }
}

達成度チェック

ミッションテーマ完了
Mission 1特性テスト
Mission 2Extract Method
Mission 3Value Object 導入
Mission 4Replace Conditional with Polymorphism
Mission 5Extract Class
Mission 6総合リファクタリング

まとめ

ポイント内容
特性テストリファクタリング前に振る舞いを固定
段階的改善小さなステップで安全にリファクタリング
テスト駆動各ステップの後にテスト実行
設計改善SOLID原則とパターンを実践的に適用

チェックリスト

  • 特性テストでレガシーコードの振る舞いを固定できた
  • 段階的にリファクタリングを実施できた
  • リファクタリング後もテストが通り続けることを確認した

次のステップへ

次はチェックポイントクイズです。リファクタリング技法の理解度を確認しましょう。


推定読了時間: 90分