TTechSpecs

TDD 重構 - Refactor With TDD 實戰範例

「如果我改了這段程式碼,會不會弄壞其他地方?」這是重構 legacy code 時最大的恐懼。本範例展示如何使用 Superpowers 的 TDD skill 為舊程式碼「穿上測試保護衣」,在沒有破壞既有行為的前提下進行重構。

專案概述

情境:一個「使用者通知服務」的 God Class,400 行的單一檔案混雜了 email、SMS、推播、頻率限制、偏好設定、重試邏輯。團隊想要拆成多個小服務,但沒人敢動。

目標:把 NotificationService 拆成 EmailSenderSmsSenderPushSenderRateLimiterPreferenceChecker 等獨立類別,並保證對外 API 與行為完全不變。

技術棧:TypeScript + Node.js + Jest

核心挑戰

  • 400 行類別、15 個公開方法、20+ 個私有方法
  • 沒有任何單元測試
  • 與資料庫、第三方 API 直接耦合
  • 方法之間互相呼叫,拆解困難

完整重構流程

第 1 步:使用 Brainstorming Skill 探索重構策略

觸發

使用 brainstorming skill 探索 NotificationService 重構的可能方向

AI 回應摘要

## 重構策略探索

### 方向 A:大爆炸式重寫
- 優點:一次到位、架構乾淨
- 缺點:風險極高、無 rollback 點、難以並行

### 方向 B:Strangler Fig Pattern
- 優點:漸進替代、可隨時停止、風險可控
- 缺點:過渡期架構混亂、需維護兩套

### 方向 C:先測試、再拆解(保護式重構)
- 優點:行為可驗證、安全網完整
- 缺點:成本高、前期無產出

### 建議
採用「C + B」混合:
1. 先用「保護式重構」建立測試網
2. 用「Strangler Fig」逐步抽取子服務
3. 每抽取一個子服務,就更新測試對應到新結構

第 2 步:使用 Writing Plans Skill 制定重構計畫

觸發

使用 writing-plans skill 制定 NotificationService 的重構計畫

重構計畫

## 階段 1:Characterization Tests(行為快照測試)(3 天)
目標:在動任何程式碼前,把目前行為「凍結」成測試
- [ ] 列出所有公開方法與預期行為
- [ ] 用 Jest 寫 characterization tests
- [ ] 涵蓋率目標:80%+ 公開方法

## 階段 2:抽取 RateLimiter(1.5 天)
目標:把頻率限制邏輯抽成獨立類別
- [ ] 建立 RateLimiter 類別與介面
- [ ] NotificationService 改為依賴注入
- [ ] 所有測試仍應通過

## 階段 3:抽取 PreferenceChecker(1 天)
目標:把偏好設定檢查抽成獨立類別
- [ ] 建立 PreferenceChecker
- [ ] 重構 NotificationService 使用它
- [ ] 測試全綠

## 階段 4:抽取三個 Sender(2.5 天)
目標:EmailSender、SmsSender、PushSender 各自獨立
- [ ] 每個 Sender 有獨立介面與測試
- [ ] NotificationService 變成 orchestrator

## 階段 5:NotificationService 瘦身(1 天)
- [ ] 確認它只剩 orchestration 邏輯
- [ ] 行數從 400 行降到 100 行以內
- [ ] 整體測試套件全綠

## 驗收標準
- [ ] 所有 characterization tests 通過
- [ ] 對外 API 完全不變
- [ ] 每個子服務有獨立的單元測試
- [ ] CI 全綠、無效能退化

第 3 步:用 TDD 寫 Characterization Tests

Characterization test 的目標不是「驗證是否正確」,而是「記錄目前實際行為」(無論多奇怪)。

觸發

使用 test-driven-development skill 為 NotificationService 建立行為快照測試

範例測試(記錄現況):

// notification-service.characterization.test.ts
describe('NotificationService 行為快照(Characterization Tests)', () => {
  let service: NotificationService;
  
  beforeEach(() => {
    service = new NotificationService(/* 真實相依的 mock */);
  });
  
  test('當使用者關閉 email 通知時,send 應跳過 email 但仍送 SMS', () => {
    const user = { id: 'u1', emailPref: false, smsPref: true };
    
    const result = service.send(user, 'hello');
    
    expect(result.emailSent).toBe(false);
    expect(result.smsSent).toBe(true);
    // 重點:是記錄現況,不是判斷對錯
  });
  
  test('當 1 分鐘內發送超過 5 次,第 6 次應被 rate limit', () => {
    const user = { id: 'u2' };
    
    for (let i = 0; i < 5; i++) {
      service.send(user, `msg ${i}`);
    }
    const result = service.send(user, 'msg 5');
    
    expect(result.rateLimited).toBe(true);
  });
  
  test('當 email 發送失敗,應重試 3 次後放棄', async () => {
    mockEmailProvider.failNext(3);  // 讓前 3 次失敗
    
    const result = await service.sendEmail('u3', 'hello');
    
    expect(result.attempts).toBe(3);
    expect(result.success).toBe(false);
  });
  
  // ... 60+ characterization tests
});

第 4 步:執行 TDD 紅→綠→重構循環抽取 RateLimiter

第一步:紅燈(先寫測試)

// rate-limiter.test.ts(新檔案)
describe('RateLimiter', () => {
  test('同一使用者在 1 分鐘內可發送 5 次', () => {
    const limiter = new RateLimiter({ windowMs: 60_000, max: 5 });
    
    for (let i = 0; i < 5; i++) {
      expect(limiter.canSend('user1')).toBe(true);
    }
  });
  
  test('第 6 次應回傳 false', () => {
    const limiter = new RateLimiter({ windowMs: 60_000, max: 5 });
    
    for (let i = 0; i < 5; i++) limiter.canSend('user1');
    
    expect(limiter.canSend('user1')).toBe(false);
  });
  
  test('不同使用者有獨立的額度', () => {
    const limiter = new RateLimiter({ windowMs: 60_000, max: 5 });
    
    for (let i = 0; i < 5; i++) limiter.canSend('user1');
    
    expect(limiter.canSend('user2')).toBe(true);
  });
});

第二步:綠燈(最小實作)

// rate-limiter.ts(新檔案)
interface RateLimitOptions {
  windowMs: number;
  max: number;
}

interface SendRecord {
  userId: string;
  timestamp: number;
}

export class RateLimiter {
  private records: Map<string, number[]> = new Map();
  
  constructor(private options: RateLimitOptions) {}
  
  canSend(userId: string): boolean {
    const now = Date.now();
    const windowStart = now - this.options.windowMs;
    
    const userRecords = (this.records.get(userId) ?? [])
      .filter(ts => ts > windowStart);
    
    if (userRecords.length >= this.options.max) {
      return false;
    }
    
    userRecords.push(now);
    this.records.set(userId, userRecords);
    return true;
  }
}

第三步:重構 NotificationService 使用 RateLimiter

在保持 characterization tests 全綠的前提下,把原本內嵌的 rate limit 邏輯替換成 RateLimiter 實例:

// notification-service.ts(修改後)
export class NotificationService {
  constructor(
    // ...其他相依
    private rateLimiter: RateLimiter = new RateLimiter({
      windowMs: 60_000,
      max: 5,
    }),
  ) {}
  
  send(user: User, message: string): SendResult {
    if (!this.rateLimiter.canSend(user.id)) {
      return { rateLimited: true };
    }
    // ...原本的 email / sms 邏輯
  }
}

驗證:執行 notification-service.characterization.test.ts,60+ 個測試全綠 → 行為沒變。

第 5 步:重複 TDD 循環抽取其他子服務

PreferenceCheckerEmailSenderSmsSenderPushSender 重複相同流程:

  1. 寫新子服務的獨立測試(紅)
  2. 實作子服務並測試(綠)
  3. 修改 NotificationService 注入新子服務
  4. 執行 characterization tests 確認全綠
  5. 提交一個獨立的 commit

每個抽取出來的子服務都有:

  • 獨立的單元測試
  • 清晰的介面
  • 可以單獨 mock 與替換

第 6 步:使用 Verification Skill 確認重構完整性

觸發

使用 verification-before-completion skill 驗證 NotificationService 重構是否完成

驗證清單

### 行為一致性
- [x] 60+ characterization tests 全數通過 ✓
- [x] 對外 API 方法簽章未變動 ✓
- [x] 整合測試套件全綠 ✓
- [x] 在 staging 環境跑 24h,error rate 與重構前持平 ✓

### 架構改善
- [x] NotificationService 從 400 行 → 87 行 ✓
- [x] 拆出 5 個獨立子服務 ✓
- [x] 每個子服務有獨立測試(共 120+ 個測試)✓
- [x] 子服務可獨立替換/單測 ✓

### 程式碼品質
- [x] TypeScript 嚴格模式編譯通過 ✓
- [x] ESLint 無新警告 ✓
- [x] 圈複雜度從平均 18 → 平均 4 ✓
- [x] 每個檔案單一職責 ✓

### 文件與溝通
- [x] 更新 ADR 記錄重構決策 ✓
- [x] commit history 清楚可追溯每個子服務的抽取過程 ✓
- [x] 更新 README 的新架構示意圖 ✓

關鍵學習點

1. 先寫「保護網」再動刀

Characterization tests 是保護式重構的基石。它們不是「驗證應該怎樣」,而是「記錄現在怎樣」。有了這層保護,你才能放心地移動、抽取、重組程式碼。

2. TDD 不是「先寫測試」,而是「小步前進」

每抽一個子服務就是一個紅→綠→重構循環:

  • 紅燈:寫新測試(小、明確、單一行為)
  • 綠燈:最小實作(不過度設計)
  • 重構:改善可讀性、命名、結構

每個循環都不超過 10 分鐘,每個循環都是有價值的 commit。

3. Strangler Fig 比 Big Bang 重寫安全

與其一次取代整個系統,不如漸進地抽取子服務。優勢:

  • 可隨時停止:若中途發現問題,可停在穩定狀態
  • 可獨立驗證:每抽一個子服務就能驗證
  • 風險分散:不會「全有或全無」

4. Verification 比起「跑測試」更全面

修完程式碼只是完成一半。真正的完成包含:

  • 行為驗證:characterization + integration tests
  • 生產驗證:staging 跑一段時間、生產監控
  • 品質驗證:圈複雜度、lint、型別檢查
  • 文件驗證:ADR、commit、README

工具使用摘要

Skill 用途 在本例的作用
Brainstorming 探索方向 在 Big Bang / Strangler / 保護式之間做選擇
Writing Plans 制定計畫 把重構分解成 5 個可執行階段
Test-Driven Development 測試驅動 每個子服務用紅→綠→重構循環抽出
Verification Before Completion 完成前驗證 確認行為、架構、品質、文件四個面向

總結

TDD 重構的核心思想是:「讓測試成為安全網,讓重構成為日常」

Legacy code 之所以可怕,是因為缺乏回饋機制。當你不知道「動了這行會影響什麼」時,自然不敢動。 Superpowers 的 TDD skill 提供:

  • 紀律:未測試先重構 = 自殺
  • 順序:characterization → 抽取 → 驗證
  • 可逆:每個循環都是可 commit、可 rollback 的小步
  • 可證:驗證清單讓「自以為完成」轉為「可證明完成」

下次面對 legacy code,請記得:不要直接動手、先寫行為快照、用 TDD 抽小塊、用 verification 收尾。重構不再賭博,而是一門可重複的工程。