ストーリー
ミッション概要
| ミッション | テーマ | テクニック | 目安時間 |
|---|---|---|---|
| Mission 1 | 特性テストを書こう | Characterization Test | 15分 |
| Mission 2 | 長いメソッドを分割せよ | Extract Method | 15分 |
| Mission 3 | プリミティブ執着を解消せよ | Value Object | 15分 |
| Mission 4 | 条件分岐をポリモーフィズムに | Replace Conditional | 20分 |
| Mission 5 | クラスを抽出せよ | Extract Class | 10分 |
| 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 2 | Extract Method | |
| Mission 3 | Value Object 導入 | |
| Mission 4 | Replace Conditional with Polymorphism | |
| Mission 5 | Extract Class | |
| Mission 6 | 総合リファクタリング |
まとめ
| ポイント | 内容 |
|---|---|
| 特性テスト | リファクタリング前に振る舞いを固定 |
| 段階的改善 | 小さなステップで安全にリファクタリング |
| テスト駆動 | 各ステップの後にテスト実行 |
| 設計改善 | SOLID原則とパターンを実践的に適用 |
チェックリスト
- 特性テストでレガシーコードの振る舞いを固定できた
- 段階的にリファクタリングを実施できた
- リファクタリング後もテストが通り続けることを確認した
次のステップへ
次はチェックポイントクイズです。リファクタリング技法の理解度を確認しましょう。
推定読了時間: 90分