還記得第一次接手別人寫的程式碼嗎?那種「這是什麼?」的困惑、「為什麼要這樣寫?」的疑問,以及「我該從哪裡開始改?」的無助感。每個開發者都有過這樣的經歷。
經過前九天的學習,我們掌握了 TDD 的基本工具。今天來學習「重構與測試」:在不改變程式外部行為的前提下,改善程式碼內部結構。這就像整理房間一樣,外觀看起來還是同一個房間,但內部變得井然有序、使用起來更方便。
有了測試作為安全網,重構就變得安全而有信心。測試會告訴你:「重構是否保持了原有的行為」。這就像走鋼索時下面有安全網,讓你可以大膽前進。
重構之旅啟程!
├── 第一站:理解重構的本質
├── 第二站:測試驅動的重構實戰
├── 第三站:掌握重構技巧
├── 第四站:重構最佳實踐
└── 終點站:10天學習總結與回顧
今天你將學會:
重構是透過小步驟改善程式碼結構,同時保持程式的外部行為不變。Martin Fowler 在《Refactoring》一書中說:「重構是在不改變軟體可觀察行為的前提下,改善其內部結構」。
很多人常把重構和重寫搞混:
特性 | 重構 | 重寫 |
---|---|---|
改變外部行為 | ❌ 否 | ✅ 可能 |
需要測試保護 | ✅ 必須 | ⚠️ 不一定 |
風險程度 | 低 | 高 |
進行方式 | 小步驟 | 大範圍 |
時間投入 | 持續進行 | 一次性 |
<?php
// 重構前:重複的程式碼
class OrderService
{
public function calculateOrderTotal(array $order): float
{
$total = 0.0;
foreach ($order['items'] as $item) {
$total += $item['price'] * $item['quantity'];
}
if ($order['customer']['type'] === 'VIP') {
$total = $total * 0.9;
}
return $total;
}
}
// 重構後:提取共通邏輯
class OrderService
{
public function calculateOrderTotal(array $order): float
{
$baseTotal = $this->calculateBaseTotal($order);
return $this->applyDiscounts($baseTotal, $order['customer']);
}
private function calculateBaseTotal(array $order): float
{
return array_reduce($order['items'], function ($total, $item) {
return $total + ($item['price'] * $item['quantity']);
}, 0.0);
}
private function applyDiscounts(float $total, array $customer): float
{
return $customer['type'] === 'VIP' ? $total * 0.9 : $total;
}
}
三法則(Rule of Three):
重構的黃金法則:在重構之前,你必須有穩固的測試。沒有測試的重構是危險的,就像沒有安全帶就開車一樣。
1. 確認測試都是綠燈 ✅
2. 執行小步驟重構 🔧
3. 執行測試驗證 🧪
4. 如果測試失敗,立即回復 ↩️
5. 重複直到完成 🔄
讓我們透過實際案例來體驗重構的過程:
建立 app/Services/Calculator.php
<?php
namespace App\Services;
use InvalidArgumentException;
class Calculator
{
public function calculate(float $a, float $b, string $operation): float
{
if ($operation === 'add') {
return $a + $b;
} elseif ($operation === 'subtract') {
return $a - $b;
} elseif ($operation === 'multiply') {
return $a * $b;
} elseif ($operation === 'divide') {
if ($b === 0.0) {
throw new InvalidArgumentException('Cannot divide by zero');
}
return $a / $b;
} else {
throw new InvalidArgumentException('Unknown operation');
}
}
}
建立 tests/Unit/Day10/CalculatorBeforeTest.php
<?php
use App\Services\Calculator;
describe('Calculator - Before Refactor', function () {
beforeEach(function () {
$this->calculator = new Calculator();
});
it('performs addition', function () {
expect($this->calculator->calculate(5.0, 3.0, 'add'))->toBe(8.0);
});
it('performs subtraction', function () {
expect($this->calculator->calculate(5.0, 3.0, 'subtract'))->toBe(2.0);
});
it('throws error when dividing by zero', function () {
expect(fn() => $this->calculator->calculate(5.0, 0.0, 'divide'))
->toThrow(InvalidArgumentException::class);
});
});
現在讓我們執行重構,將 if-elseif 結構改為更優雅的 match 表達式(PHP 8 新特性):
更新 app/Services/Calculator.php
<?php
namespace App\Services;
use InvalidArgumentException;
class Calculator
{
public function calculate(float $a, float $b, string $operation): float
{
return match ($operation) {
'add' => $this->add($a, $b),
'subtract' => $this->subtract($a, $b),
'multiply' => $this->multiply($a, $b),
'divide' => $this->divide($a, $b),
default => throw new InvalidArgumentException('Unknown operation')
};
}
private function add(float $a, float $b): float
{
return $a + $b;
}
private function subtract(float $a, float $b): float
{
return $a - $b;
}
private function multiply(float $a, float $b): float
{
return $a * $b;
}
private function divide(float $a, float $b): float
{
if ($b === 0.0) {
throw new InvalidArgumentException('Cannot divide by zero');
}
return $a / $b;
}
}
建立 tests/Unit/Day10/CalculatorAfterTest.php
<?php
use App\Services\Calculator;
describe('Calculator - After Refactor', function () {
beforeEach(function () {
$this->calculator = new Calculator();
});
it('still performs addition correctly', function () {
expect($this->calculator->calculate(5.0, 3.0, 'add'))->toBe(8.0);
});
it('still performs subtraction correctly', function () {
expect($this->calculator->calculate(5.0, 3.0, 'subtract'))->toBe(2.0);
});
it('still throws error when dividing by zero', function () {
expect(fn() => $this->calculator->calculate(5.0, 0.0, 'divide'))
->toThrow(InvalidArgumentException::class);
});
});
將長方法分解為小方法,讓程式碼更易讀:
// 重構前:一個做太多事的方法
public function processOrder($orderId) {
// 驗證訂單
$order = Order::find($orderId);
if (!$order) throw new Exception('Order not found');
if ($order->status !== 'pending') throw new Exception('Order already processed');
// 計算價格
$total = 0;
foreach ($order->items as $item) {
$total += $item->price * $item->quantity;
}
// ... 更多程式碼
}
// 重構後:分解為多個小方法
public function processOrder($orderId) {
$order = $this->validateOrder($orderId);
$total = $this->calculateTotal($order);
$this->processPayment($order, $total);
}
將複雜表達式提取為有意義的變數名稱:
// 重構前:難以理解的複雜表達式
if ($user->age >= 18 && $user->hasVerifiedEmail() && $user->paymentMethods->count() > 0) {
// 處理邏輯
}
// 重構後:使用有意義的變數
$isAdult = $user->age >= 18;
$hasVerifiedAccount = $user->hasVerifiedEmail();
$hasPaymentMethod = $user->paymentMethods->count() > 0;
if ($isAdult && $hasVerifiedAccount && $hasPaymentMethod) {
// 處理邏輯
}
遵循 DRY 原則,將重複的邏輯提取到共用方法:
// 重構前:重複的驗證邏輯
public function updateEmail($email) {
if (empty($email)) throw new ValidationException('Email is required');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new ValidationException('Invalid email format');
// 更新邏輯
}
public function registerUser($email, $password) {
if (empty($email)) throw new ValidationException('Email is required');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new ValidationException('Invalid email format');
// 註冊邏輯
}
// 重構後:提取共用驗證方法
private function validateEmail($email) {
if (empty($email)) throw new ValidationException('Email is required');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new ValidationException('Invalid email format');
}
public function updateEmail($email) {
$this->validateEmail($email);
// 更新邏輯
}
public function registerUser($email, $password) {
$this->validateEmail($email);
// 註冊邏輯
}
## 重構的最佳實踐 ✨
### 1. 小步驟重構
不要一次性大重構,而是小步驟進行:
- 每次只重構一小部分
- 執行測試確保功能正常
- 提交版本控制(小步驟提交)
- 逐步改善整個程式碼結構
### 2. 每次重構後都執行測試
```bash
# 重構工作流程
$ pest tests/Unit/Day10/ # ✅ 確認測試綠燈
$ # 執行重構...
$ pest tests/Unit/Day10/ # 🧪 驗證行為未變
$ git commit -m "refactor: extract method for validation"
// ✅ 向後相容
public function getTotal() {
trigger_error('getTotal() is deprecated, use calculateTotal() instead', E_USER_DEPRECATED);
return $this->calculateTotal();
}
public function calculateTotal() {
// 新的實作
}
適合重構的時機:新增功能前、修復 bug 時、Code Review 發現問題時
不適合重構的時機:接近截止日期、沒有測試保護、程式碼即將被淘汰
恭喜你!完成了 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) | ✅ Pest 測試框架✅ 基本斷言✅ 紅綠重構 | 能寫簡單的單元測試 |
基礎 (Day 4-6) | ✅ 測試組織✅ 生命週期✅ 資料驅動測試 | 能組織大型測試套件 |
深化 (Day 7-9) | ✅ Mock/Stub✅ 例外測試✅ 覆蓋率分析 | 能測試複雜場景 |
整合 (Day 10) | ✅ 安全重構✅ 程式碼品質✅ 持續改進 | 能維護高品質程式碼 |
測試優先思維:先寫測試,後寫程式碼
小步驟開發:每次只改一點點,保持綠燈
重構信心:有測試保護,重構不再恐懼
品質意識:測試不只是找 bug,更是設計工具
第一階段的基礎訓練圓滿完成!🎉
透過今天的學習,我們掌握了:
重構讓我們能夠:
恭喜你完成了 TDD 基礎訓練的前十天!你已經掌握了 TDD 的核心概念和實踐技巧。
今天我們學會了測試驅動的重構,這是 TDD 循環中「重構」步驟的深入實踐。有了測試作為安全網,我們可以大膽地改善程式碼結構,讓系統變得更好。
重構不是一次性的大工程,而是持續的小改進。就像園丁修剪花園,每天做一點,最終會有一個美麗的花園。
「寫程式」是為了讓機器理解
「重構」是為了讓人類理解
「測試」是為了讓改變安全
第一階段的學習到此圓滿結束!記住 TDD 的精髓:紅 → 綠 → 重構。測試不只是為了找 bug,更是設計工具和重構的安全網!
恭喜你完成了 TDD 基礎學習的前十天!繼續努力,你將能掌握更多 TDD 技巧!🚀