ストーリー
リファクタリングにおけるテストの役割
リファクタリングは「外部の振る舞いを変えずに内部構造を改善する」作業です。テストは「外部の振る舞いが変わっていないこと」を証明する手段です。
リファクタリング前のテスト結果: ALL PASS ✓
↓ リファクタリング実施
リファクタリング後のテスト結果: ALL PASS ✓
→ 振る舞いが保持されている!
テストがない場合の第一歩
レガシーコードにはテストがないことが多いです。その場合、まずテストを書いてからリファクタリングします。
特性テスト(Characterization Test)
既存の振る舞いを「そのまま」記録するテストです。正しさではなく、現状の振る舞いを固定します。
// リファクタリング対象のレガシーコード
function calculateDiscount(price: number, customerType: string, quantity: number): number {
let discount = 0;
if (customerType === 'vip') {
discount = price * 0.2;
if (quantity > 10) {
discount += price * 0.05;
}
} else if (customerType === 'regular') {
if (quantity > 20) {
discount = price * 0.1;
} else if (quantity > 10) {
discount = price * 0.05;
}
}
if (price > 10000) {
discount += 500;
}
return discount;
}
// 特性テスト:現在の振る舞いを記録する
describe('calculateDiscount (characterization)', () => {
// VIP顧客
test('VIP, quantity <= 10, price <= 10000', () => {
expect(calculateDiscount(5000, 'vip', 5)).toBe(1000);
});
test('VIP, quantity > 10, price <= 10000', () => {
expect(calculateDiscount(5000, 'vip', 15)).toBe(1250);
});
test('VIP, quantity > 10, price > 10000', () => {
expect(calculateDiscount(20000, 'vip', 15)).toBe(5500);
});
// 一般顧客
test('regular, quantity <= 10', () => {
expect(calculateDiscount(5000, 'regular', 5)).toBe(0);
});
test('regular, quantity 11-20', () => {
expect(calculateDiscount(5000, 'regular', 15)).toBe(250);
});
test('regular, quantity > 20, price > 10000', () => {
expect(calculateDiscount(20000, 'regular', 25)).toBe(2500);
});
// 不明な顧客タイプ
test('unknown type, price > 10000', () => {
expect(calculateDiscount(15000, 'new', 5)).toBe(500);
});
});
Red-Green-Refactor サイクル
テスト駆動リファクタリングは以下のサイクルで進めます。
ステップ 1: テストを書く(あるいは確認する)
// 既存のテストが通ることを確認
// npm test → ALL PASS
ステップ 2: 小さなリファクタリングを実施
// Extract Method: 割引計算を分離
function calculateDiscount(price: number, customerType: string, quantity: number): number {
const typeDiscount = calculateTypeDiscount(price, customerType, quantity);
const bulkDiscount = calculateBulkDiscount(price);
return typeDiscount + bulkDiscount;
}
function calculateTypeDiscount(price: number, customerType: string, quantity: number): number {
if (customerType === 'vip') {
return calculateVipDiscount(price, quantity);
}
if (customerType === 'regular') {
return calculateRegularDiscount(price, quantity);
}
return 0;
}
function calculateVipDiscount(price: number, quantity: number): number {
const baseDiscount = price * 0.2;
const volumeBonus = quantity > 10 ? price * 0.05 : 0;
return baseDiscount + volumeBonus;
}
function calculateRegularDiscount(price: number, quantity: number): number {
if (quantity > 20) return price * 0.1;
if (quantity > 10) return price * 0.05;
return 0;
}
function calculateBulkDiscount(price: number): number {
return price > 10000 ? 500 : 0;
}
ステップ 3: テストを実行
// npm test → ALL PASS ✓
// 振る舞いが保持されている!
ステップ 4: さらにリファクタリング(ポリモーフィズム導入)
interface DiscountStrategy {
calculate(price: number, quantity: number): number;
}
class VipDiscount implements DiscountStrategy {
calculate(price: number, quantity: number): number {
const baseDiscount = price * 0.2;
const volumeBonus = quantity > 10 ? price * 0.05 : 0;
return baseDiscount + volumeBonus;
}
}
class RegularDiscount implements DiscountStrategy {
calculate(price: number, quantity: number): number {
if (quantity > 20) return price * 0.1;
if (quantity > 10) return price * 0.05;
return 0;
}
}
class NoDiscount implements DiscountStrategy {
calculate(_price: number, _quantity: number): number {
return 0;
}
}
class DiscountCalculator {
private strategies: Map<string, DiscountStrategy> = new Map([
['vip', new VipDiscount()],
['regular', new RegularDiscount()],
]);
calculate(price: number, customerType: string, quantity: number): number {
const strategy = this.strategies.get(customerType) ?? new NoDiscount();
const typeDiscount = strategy.calculate(price, quantity);
const bulkDiscount = price > 10000 ? 500 : 0;
return typeDiscount + bulkDiscount;
}
}
ステップ 5: テストを実行して確認
// テストを新しいクラスに適合させる
describe('DiscountCalculator', () => {
const calculator = new DiscountCalculator();
test('VIP, quantity <= 10, price <= 10000', () => {
expect(calculator.calculate(5000, 'vip', 5)).toBe(1000);
});
// ... 同じテストケースがすべて通る
});
テストで守るべきルール
| ルール | 説明 |
|---|---|
| 1変更1テスト | リファクタリングの各ステップ後にテスト実行 |
| テスト失敗時は巻き戻し | 失敗したら直前の変更を元に戻す |
| テスト追加はリファクタリング前に | テストが足りないと気づいたら先に追加 |
| テスト自体のリファクタリングは別で | テストとコードのリファクタリングを同時にしない |
Seam(継ぎ目)を見つける
レガシーコードにテストを書くとき、「Seam(継ぎ目)」を見つけることが重要です。Seam とは、コードの振る舞いを変えずに差し替え可能なポイントです。
// Seam がない(テスト困難)
class ReportService {
generateReport(): string {
const db = new PostgresDatabase();
const data = db.query('SELECT * FROM reports');
const smtp = new SmtpClient('smtp.example.com');
smtp.send('admin@example.com', data.toString());
return data.toString();
}
}
// Seam を作る(テスト可能に)
class ReportService {
constructor(
private database: Database, // Seam: 注入で差し替え可能
private mailer: Mailer // Seam: 注入で差し替え可能
) {}
generateReport(): string {
const data = this.database.query('SELECT * FROM reports');
this.mailer.send('admin@example.com', data.toString());
return data.toString();
}
}
// テスト時
const mockDb = { query: jest.fn().mockReturnValue([{ id: 1 }]) };
const mockMailer = { send: jest.fn() };
const service = new ReportService(mockDb as any, mockMailer as any);
高橋アーキテクトのアドバイス:
「テストがあるからこそ”大胆に”リファクタリングできる。テストがないからといってリファクタリングを諦めるのではなく、まずテストを書くことから始めよう。それ自体がコードの理解を深めるプロセスだ」
まとめ
| ポイント | 内容 |
|---|---|
| テストの役割 | 外部の振る舞いが変わっていないことを保証 |
| 特性テスト | 既存の振る舞いをそのまま記録するテスト |
| Red-Green-Refactor | テスト確認→小さな変更→テスト確認の繰り返し |
| Seam | テスト可能にするための差し替えポイント |
チェックリスト
- 特性テストを書いてレガシーコードの振る舞いを固定できる
- Red-Green-Refactor サイクルを実践できる
- Seam を見つけてテスト可能にできる
次のステップへ
次は演習です。実際にレガシーコードをテストを書きながらリファクタリングしてみましょう。
推定読了時間: 30分