TDD 重構 - Refactor With TDD 實戰範例
「如果我改了這段程式碼,會不會弄壞其他地方?」這是重構 legacy code 時最大的恐懼。本範例展示如何使用 Superpowers 的 TDD skill 為舊程式碼「穿上測試保護衣」,在沒有破壞既有行為的前提下進行重構。
專案概述
情境:一個「使用者通知服務」的 God Class,400 行的單一檔案混雜了 email、SMS、推播、頻率限制、偏好設定、重試邏輯。團隊想要拆成多個小服務,但沒人敢動。
目標:把 NotificationService 拆成 EmailSender、SmsSender、PushSender、RateLimiter、PreferenceChecker 等獨立類別,並保證對外 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 循環抽取其他子服務
對 PreferenceChecker、EmailSender、SmsSender、PushSender 重複相同流程:
- 寫新子服務的獨立測試(紅)
- 實作子服務並測試(綠)
- 修改
NotificationService注入新子服務 - 執行 characterization tests 確認全綠
- 提交一個獨立的 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 收尾。重構不再賭博,而是一門可重複的工程。