iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Software Development

Laravel Pest TDD 實戰:從零開始的測試驅動開發系列 第 10

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

  • 分享至 

  • xImage
  •  

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

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

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

今日學習地圖 🗺️

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

學習目標 🎯

今天你將學會:

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

什麼是重構?🔄

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

重構 vs 重寫

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

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

為什麼要重構? 💡

  1. 提升可讀性:讓程式碼更容易理解
  2. 減少重複:遵循 DRY (Don't Repeat Yourself) 原則
  3. 提升維護性:修改和擴展更容易
  4. 降低複雜度:簡化複雜的邏輯

何時該重構? ⏰

三法則(Rule of Three):

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

測試驅動的重構 🚀

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

重構的安全步驟

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);
    });
});

常見重構技巧 🛠️

1. 提取方法(Extract Method)

將長方法分解為小方法,讓程式碼更易讀:

// 重構前:一個做太多事的方法
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);
}

2. 提取變數(Extract Variable)

將複雜表達式提取為有意義的變數名稱:

// 重構前:難以理解的複雜表達式
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) {
    // 處理邏輯
}

3. 消除重複(Remove Duplication)

遵循 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"

3. 保持向後相容性

// ✅ 向後相容
public function getTotal() {
    trigger_error('getTotal() is deprecated, use calculateTotal() instead', E_USER_DEPRECATED);
    return $this->calculateTotal();
}

public function 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) ✅ Pest 測試框架✅ 基本斷言✅ 紅綠重構 能寫簡單的單元測試
基礎 (Day 4-6) ✅ 測試組織✅ 生命週期✅ 資料驅動測試 能組織大型測試套件
深化 (Day 7-9) ✅ Mock/Stub✅ 例外測試✅ 覆蓋率分析 能測試複雜場景
整合 (Day 10) ✅ 安全重構✅ 程式碼品質✅ 持續改進 能維護高品質程式碼

關鍵收穫總結 💎

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

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

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

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

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

你已經掌握的能力 ⚡

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

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

今天學到什麼?📝

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

核心概念

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

實戰技能

重構讓我們能夠:

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

明日預告

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

總結 🎊

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

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

重要心法

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

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

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


上一篇
Day 09 - 測試覆蓋率:你的測試真的夠完整嗎? 📊
下一篇
Day 10 - 重構與測試:讓程式碼持續進化 🔧
系列文
Laravel Pest TDD 實戰:從零開始的測試驅動開發11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言