iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Modern Web

React TDD 實戰:用 Vitest 打造可靠的前端應用系列 第 11

Day 10 - 重構與測試:讓程式碼持續進化 🔧

  • 分享至 

  • xImage
  •  

還記得第一次接手別人寫的程式碼嗎?那種「這是什麼?」的困惑、「為什麼要這樣寫?」的疑問,以及「我該從哪裡開始改?」的無助感。每個開發者都有過這樣的經歷。

經過前九天的學習,我們掌握了 TDD 的基本工具。今天來學習「重構與測試」:在不改變程式外部行為的前提下,改善程式碼內部結構。這就像整理房間一樣,外觀看起來還是同一個房間,但內部變得井然有序、使用起來更方便。

有了測試作為安全網,重構就變得安全而有信心。測試會告訴你:「重構是否保持了原有的行為」。這就像走鋼索時下面有安全網,讓你可以大膽前進。

今日學習地圖 🗺️

重構之旅啟程!
├── 第一站:理解重構的本質
├── 第二站:測試驅動的重構實戰
├── 第三站:掌握重構技巧
├── 第四站:重構最佳實踐
└── 終點站:10天學習總結與回顧

學習目標 🎯

今天你將學會:

  • 理解重構的概念和重要性
  • 掌握常見的重構技巧
  • 學會在測試保護下進行安全重構
  • 總結前 10 天的 TDD 學習成果

什麼是重構?🔄

重構是透過小步驟改善程式碼結構,同時保持程式的外部行為不變。Martin Fowler 在《Refactoring》一書中說:「重構是在不改變軟體可觀察行為的前提下,改善其內部結構」。

重構 vs 重寫

很多人常把重構和重寫搞混:

特性 重構 重寫
改變外部行為 ❌ 否 ✅ 可能
需要測試保護 ✅ 必須 ⚠️ 不一定
風險程度
進行方式 小步驟 大範圍
時間投入 持續進行 一次性

為什麼要重構? 💡

  1. 提升可讀性:讓程式碼更容易理解

    • 有意義的命名
    • 清晰的結構
    • 適當的抽象層次
  2. 減少重複:遵循 DRY (Don't Repeat Yourself) 原則

    • 消除複製貼上的程式碼
    • 提取共用邏輯
    • 建立可重用元件
  3. 提升維護性:修改和擴展更容易

    • 降低修改成本
    • 減少出錯機會
    • 加快開發速度
  4. 降低複雜度:簡化複雜的邏輯

    • 分解大函數
    • 簡化條件判斷
    • 改善資料結構

何時該重構? ⏰

三法則(Rule of Three):

  1. 第一次做某件事時,直接做
  2. 第二次做類似的事時,會有點不情願但還是做了
  3. 第三次做類似的事時,就該重構了

測試驅動的重構 🚀

重構的黃金法則:在重構之前,你必須有穩固的測試。沒有測試的重構是危險的,就像沒有安全帶就開車一樣。

重構的安全步驟

1. 確認測試都是綠燈 ✅
2. 執行小步驟重構 🔧
3. 執行測試驗證 🧪
4. 如果測試失敗,立即回復 ↩️
5. 重複直到完成 🔄

讓我們透過實際案例來體驗重構的過程:

建立 src/day10/calculator.ts

export class Calculator {
  // 需要重構的複雜函式
  calculate(a: number, b: number, operation: string): number {
    if (operation === 'add') {
      return a + b
    } else if (operation === 'subtract') {
      return a - b
    } else if (operation === 'multiply') {
      return a * b
    } else if (operation === 'divide') {
      if (b === 0) {
        throw new Error('Cannot divide by zero')
      }
      return a / b
    } else {
      throw new Error('Unknown operation')
    }
  }
}

重構前後對比範例

建立 tests/day10/calculator-before-refactor.test.ts

import { describe, it, expect } from 'vitest'
import { Calculator } from '../../src/day10/calculator.js'

describe('Calculator - Before Refactor', () => {
  const calculator = new Calculator()

  it('performs addition correctly', () => {
    expect(calculator.calculate(5, 3, 'add')).toBe(8)
  })

  it('performs subtraction correctly', () => {
    expect(calculator.calculate(5, 3, 'subtract')).toBe(2)
  })

  it('performs multiplication correctly', () => {
    expect(calculator.calculate(5, 3, 'multiply')).toBe(15)
  })

  it('performs division correctly', () => {
    expect(calculator.calculate(6, 2, 'divide')).toBe(3)
  })

  it('throws error when dividing by zero', () => {
    expect(() => calculator.calculate(5, 0, 'divide'))
      .toThrow('Cannot divide by zero')
  })

  it('throws error for unknown operation', () => {
    expect(() => calculator.calculate(5, 3, 'unknown'))
      .toThrow('Unknown operation')
  })
})

執行重構

更新 src/day10/calculator.ts

export class Calculator {
  calculate(a: number, b: number, operation: string): number {
    const operations: Record<string, () => number> = {
      add: () => this.add(a, b),
      subtract: () => this.subtract(a, b),
      multiply: () => this.multiply(a, b),
      divide: () => this.divide(a, b)
    }

    const operationFn = operations[operation]
    if (!operationFn) {
      throw new Error('Unknown operation')
    }

    return operationFn()
  }

  private add(a: number, b: number): number {
    return a + b
  }

  private subtract(a: number, b: number): number {
    return a - b
  }

  private multiply(a: number, b: number): number {
    return a * b
  }

  private divide(a: number, b: number): number {
    if (b === 0) {
      throw new Error('Cannot divide by zero')
    }
    return a / b
  }
}

驗證重構正確性

建立 tests/day10/calculator-after-refactor.test.ts

import { describe, it, expect } from 'vitest'
import { Calculator } from '../../src/day10/calculator.js'

describe('Calculator - After Refactor', () => {
  const calculator = new Calculator()

  it('all operations still work correctly', () => {
    expect(calculator.calculate(5, 3, 'add')).toBe(8)
    expect(calculator.calculate(5, 3, 'subtract')).toBe(2)
    expect(calculator.calculate(5, 3, 'multiply')).toBe(15)
    expect(calculator.calculate(6, 2, 'divide')).toBe(3)
  })

  it('error handling preserved', () => {
    expect(() => calculator.calculate(5, 0, 'divide'))
      .toThrow('Cannot divide by zero')
    expect(() => calculator.calculate(5, 3, 'unknown'))
      .toThrow('Unknown operation')
  })
})

常用重構技巧 🎯

1. 提取方法(Extract Method)

// 重構前:長方法
function processOrder(order: Order): void {
  // 驗證和計算邏輯混在一起
  if (!order.customer) throw new Error('Customer required')
  if (!order.items?.length) throw new Error('Items required')
  
  let total = 0
  for (const item of order.items) {
    total += item.price * item.quantity
  }
  
  if (order.customer.type === 'VIP') {
    total *= 0.9
  }
  
  order.total = total
  order.status = 'processed'
}

// 重構後:提取方法
function processOrder(order: Order): void {
  validateOrder(order)
  const total = calculateTotal(order)
  const discountedTotal = applyDiscount(total, order.customer)
  updateOrder(order, discountedTotal)
}

2. 提取變數(Extract Variable)

建立 tests/day10/extract-variable.test.ts

import { describe, it, expect } from 'vitest'

describe('Extract Variable Refactoring', () => {
  // 重構前:複雜表達式
  function calculatePriceBefore(base: number, type: string, amount: number): number {
    return base * (type === 'VIP' ? 0.8 : 1.0) * (amount > 1000 ? 0.95 : 1.0)
  }

  // 重構後:清晰的變數
  function calculatePriceAfter(base: number, type: string, amount: number): number {
    const customerDiscount = type === 'VIP' ? 0.8 : 1.0
    const volumeDiscount = amount > 1000 ? 0.95 : 1.0
    return base * customerDiscount * volumeDiscount
  }

  it('maintains same behavior after refactoring', () => {
    // 測試各種情況,確保行為一致
    expect(calculatePriceAfter(100, 'VIP', 500))
      .toBe(calculatePriceBefore(100, 'VIP', 500))
    
    expect(calculatePriceAfter(100, 'Regular', 1500))
      .toBe(calculatePriceBefore(100, 'Regular', 1500))
    
    expect(calculatePriceAfter(100, 'VIP', 1500))
      .toBe(calculatePriceBefore(100, 'VIP', 1500))
  })
})

3. 消除重複(Remove Duplication)

建立 tests/day10/remove-duplication.test.ts

import { describe, it, expect } from 'vitest'

describe('Remove Duplication Refactoring', () => {
  // 重構前:重複的驗證邏輯
  class UserServiceBefore {
    updateEmail(userId: string, email: string): void {
      if (!userId || userId.trim() === '') {
        throw new Error('Invalid user ID')
      }
      if (!email || !email.includes('@')) {
        throw new Error('Invalid email')
      }
      // 更新邏輯...
    }

    updatePassword(userId: string, password: string): void {
      if (!userId || userId.trim() === '') {
        throw new Error('Invalid user ID')
      }
      if (!password || password.length < 8) {
        throw new Error('Invalid password')
      }
      // 更新邏輯...
    }
  }

  // 重構後:提取共用驗證
  class UserServiceAfter {
    private validateUserId(userId: string): void {
      if (!userId || userId.trim() === '') {
        throw new Error('Invalid user ID')
      }
    }

    updateEmail(userId: string, email: string): void {
      this.validateUserId(userId)
      if (!email || !email.includes('@')) {
        throw new Error('Invalid email')
      }
      // 更新邏輯...
    }

    updatePassword(userId: string, password: string): void {
      this.validateUserId(userId)
      if (!password || password.length < 8) {
        throw new Error('Invalid password')
      }
      // 更新邏輯...
    }
  }

  it('validates user ID consistently', () => {
    const serviceBefore = new UserServiceBefore()
    const serviceAfter = new UserServiceAfter()

    // 測試無效的 userId
    expect(() => serviceBefore.updateEmail('', 'test@test.com'))
      .toThrow('Invalid user ID')
    expect(() => serviceAfter.updateEmail('', 'test@test.com'))
      .toThrow('Invalid user ID')

    // 測試無效的 email
    expect(() => serviceBefore.updateEmail('user123', 'invalid'))
      .toThrow('Invalid email')
    expect(() => serviceAfter.updateEmail('user123', 'invalid'))
      .toThrow('Invalid email')
  })
})

重構的最佳實踐 ✨

1. 小步驟重構

不要一次性大重構,而是小步驟進行:

🔴 錯誤示範:
「我要花三天時間重構整個模組」

🟢 正確做法:
「我每次只重構一個方法,確保測試通過後再繼續」

執行步驟:

  1. 每次只重構一小部分
  2. 執行測試確保功能正常
  3. 提交版本控制(小步驟提交)
  4. 逐步改善整個程式碼結構

2. 每次重構後都執行測試

確保重構前後行為一致:

# 重構工作流程
$ npm test  # ✅ 確認測試綠燈
$ # 執行重構...
$ npm test  # 🧪 驗證行為未變
$ git commit -m "refactor: extract method for validation"

3. 保持向後相容性

重構時保持函數介面不變,避免破壞現有程式碼:

// ❌ 破壞性變更
getTotal()  // 原本
calculateTotal()  // 直接改名

// ✅ 向後相容
getTotal() {
  // 標記為過時,但保持可用
  console.warn('getTotal() is deprecated, use calculateTotal() instead')
  return this.calculateTotal()
}

calculateTotal() {
  // 新的實作
}

4. 重構的時機選擇

適合重構的時機

  • ✅ 新增功能前
  • ✅ 修復 bug 時
  • ✅ Code Review 發現問題時
  • ✅ 理解程式碼有困難時

不適合重構的時機

  • ❌ 接近截止日期
  • ❌ 沒有測試保護
  • ❌ 程式碼即將被淘汰
  • ❌ 重構的收益不明確

第一階段總結:10 天 TDD 基礎之旅 🎓

恭喜你!完成了 TDD 第一階段的學習。讓我們回顧這 10 天的精彩旅程:

學習軌跡回顧 📈

Day 01-03:奠定基礎
├── Day 01:環境設定與第一個測試
├── Day 02:理解斷言的藝術
└── Day 03:TDD 紅綠重構循環

Day 04-06:深化理解
├── Day 04:測試結構與組織
├── Day 05:測試生命週期
└── Day 06:參數化測試的威力

Day 07-09:測試技巧
├── Day 07:測試替身基礎
├── Day 08:例外處理測試
└── Day 09:測試覆蓋率分析

Day 10:整合提升
└── 重構與測試的完美搭配

技能成長地圖 🗺️

階段 核心技能 實戰能力
入門 (Day 1-3) ✅ Vitest 測試框架✅ 基本斷言✅ 紅綠重構 能寫簡單的單元測試
基礎 (Day 4-6) ✅ 測試組織✅ 生命週期✅ 資料驅動測試 能組織大型測試套件
深化 (Day 7-9) ✅ Mock/Stub✅ 例外測試✅ 覆蓋率分析 能測試複雜場景
整合 (Day 10) ✅ 安全重構✅ 程式碼品質✅ 持續改進 能維護高品質程式碼

關鍵收穫總結 💎

  1. 測試優先思維:先寫測試,後寫程式碼

    • 從需求出發,而非實作
    • 測試即文件,測試即設計
  2. 小步驟開發:每次只改一點點,保持綠燈

    • 降低認知負擔
    • 快速獲得回饋
  3. 重構信心:有測試保護,重構不再恐懼

    • 測試是安全網
    • 持續改進程式碼品質
  4. 品質意識:測試不只是找 bug,更是設計工具

    • 提升程式碼可測試性
    • 促進良好的設計模式

你已經掌握的能力 ⚡

  • ✅ 能夠設定 React Vitest 測試環境
  • ✅ 熟練使用各種斷言方法
  • ✅ 理解並實踐 TDD 紅綠重構循環
  • ✅ 能夠組織和管理測試程式碼
  • ✅ 掌握測試生命週期鉤子
  • ✅ 會使用參數化測試減少重複
  • ✅ 能夠創建和使用測試替身
  • ✅ 知道如何測試例外情況
  • ✅ 理解測試覆蓋率的意義
  • ✅ 能在測試保護下安全重構

第一階段的基礎訓練圓滿完成!🎉

今天學到什麼?📝

透過今天的學習,我們掌握了:

核心概念

  1. 重構的本質:在保持外部行為不變的前提下改善內部結構
  2. 測試安全網:測試讓重構變得安全而有信心
  3. 重構技巧:提取方法、提取變數、消除重複等
  4. 重構流程:小步驟、持續測試、保持向後相容
  5. 10 天總結:回顧學習歷程,建立完整的 TDD 基礎

實戰技能

重構讓我們能夠:

  • 🎯 改善程式碼可讀性和維護性
  • 🔄 減少重複程式碼
  • 📊 降低系統複雜度
  • 🛡️ 在安全的環境下持續改進

明日預告

恭喜你完成了 TDD 基礎訓練的前十天!你已經掌握了 TDD 的核心概念和實踐技巧。

總結 🎊

今天我們學會了測試驅動的重構,這是 TDD 循環中「重構」步驟的深入實踐。有了測試作為安全網,我們可以大膽地改善程式碼結構,讓系統變得更好。

重構不是一次性的大工程,而是持續的小改進。就像園丁修剪花園,每天做一點,最終會有一個美麗的花園。

重要心法

「寫程式」是為了讓機器理解
「重構」是為了讓人類理解
「測試」是為了讓改變安全

第一階段的學習到此圓滿結束!記住 TDD 的精髓:紅 → 綠 → 重構。測試不只是為了找 bug,更是設計工具和重構的安全網!

恭喜你完成了 TDD 基礎學習的前十天!繼續努力,你將能掌握更多 TDD 技巧!🚀


上一篇
Day 10 - 重構與測試:讓程式碼持續進化 🔧
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言