iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0

今天要做什麼?

昨天我們學會了測試替身,解決了外部依賴的測試問題。今天面對一個新的挑戰:「如何測試程式在出錯時的行為?」

想像一個場景:你的應用需要處理各種錯誤情況:

  • API 回傳錯誤狀態碼
  • 使用者輸入無效資料
  • 資料庫連線失敗

很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理。

學習目標 🎯

今天結束後,你將學會:

  • 掌握 Pest 的 toThrow() 例外斷言
  • 學會測試 Laravel 的例外處理
  • 理解錯誤測試的最佳實踐

為什麼要測試例外處理? 🤔

// 問題:只考慮成功情況的程式碼
class UserService
{
    public function getUserProfile(int $userId)
    {
        $response = Http::get("/api/users/{$userId}");
        $user = $response->json(); // 如果不是 JSON 格式會怎樣?
        
        return [
            'id' => $user['id'],
            'name' => strtoupper($user['name']), // 如果 name 是 null 會怎樣?
        ];
    }
}

例外處理測試確保:

  1. 系統穩定性:避免應用程式崩潰
  2. 使用者體驗:提供有意義的錯誤訊息
  3. 除錯效率:快速定位問題根源

Pest 中的例外測試 🐛

基本語法

建立 app/Services/Day08/EmailValidator.php

<?php

namespace App\Services\Day08;

use Exception;

class ValidationException extends Exception
{
    public function __construct(string $message, public readonly ?string $field = null)
    {
        parent::__construct($message);
    }
}

class EmailValidator
{
    public static function validate(mixed $email): bool
    {
        if (!$email || !is_string($email)) {
            throw new ValidationException('Email is required', 'email');
        }
        
        if (!str_contains($email, '@')) {
            throw new ValidationException('Email must contain @ symbol', 'email');
        }
        
        return true;
    }
}

建立 tests/Feature/Day08/BasicExceptionsTest.php

<?php

use App\Services\Day08\EmailValidator;
use App\Services\Day08\ValidationException;

test('emailValidatorThrowsErrorWhenEmailIsMissing', function () {
    expect(fn() => EmailValidator::validate(null))->toThrow('Email is required');
    expect(fn() => EmailValidator::validate(123))->toThrow(ValidationException::class);
});

test('emailValidatorThrowsErrorForInvalidEmailFormat', function () {
    expect(fn() => EmailValidator::validate('invalid-email'))
        ->toThrow('Email must contain @ symbol');
});

test('emailValidatorAcceptsValidEmails', function () {
    expect(fn() => EmailValidator::validate('user@example.com'))->not->toThrow();
});

測試 Laravel HTTP 服務例外 🚀

建立 app/Services/Day08/UserService.php

<?php

namespace App\Services\Day08;

use Exception;
use Illuminate\Support\Facades\Http;

class NetworkException extends Exception
{
    public function __construct(string $message, public readonly ?int $statusCode = null)
    {
        parent::__construct($message);
    }
}

class UserService
{
    public function fetchUser(int $userId): array
    {
        if ($userId <= 0) {
            throw new Exception('Invalid user ID');
        }

        $response = Http::get("/users/{$userId}");
        
        if (!$response->successful()) {
            throw new NetworkException(
                "Failed to fetch user: {$response->status()}", 
                $response->status()
            );
        }

        return $response->json();
    }
}

建立 tests/Feature/Day08/UserServiceTest.php

<?php

use App\Services\Day08\UserService;
use App\Services\Day08\NetworkException;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    $this->userService = new UserService();
});

test('fetchUserThrowsErrorForInvalidUserId', function () {
    expect(fn() => $this->userService->fetchUser(0))
        ->toThrow('Invalid user ID');
});

test('fetchUserThrowsNetworkExceptionForHttpErrors', function () {
    Http::fake([
        'users/1' => Http::response([], 404)
    ]);

    try {
        $this->userService->fetchUser(1);
    } catch (NetworkException $e) {
        expect($e->getMessage())->toBe('Failed to fetch user: 404')
            ->and($e->statusCode)->toBe(404);
    }
});

test('fetchUserReturnsValidUserData', function () {
    $mockUser = ['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com'];
    
    Http::fake([
        'users/1' => Http::response($mockUser)
    ]);

    $result = $this->userService->fetchUser(1);
    
    expect($result)->toBe($mockUser);
});

實戰演練:表單驗證測試

建立 app/Services/Day08/FormValidator.php

<?php

namespace App\Services\Day08;

class FormValidationException extends \Exception
{
    public function __construct(public readonly array $errors)
    {
        parent::__construct('Form validation failed');
    }
}

class UserRegistrationForm
{
    public static function validate(array $formData): bool
    {
        $errors = [];

        if (empty($formData['email'])) {
            $errors['email'] = 'Email is required';
        } elseif (!filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
            $errors['email'] = 'Invalid email format';
        }

        if (empty($formData['password'])) {
            $errors['password'] = 'Password is required';
        } elseif (strlen($formData['password']) < 8) {
            $errors['password'] = 'Password must be at least 8 characters long';
        }

        if (!empty($errors)) {
            throw new FormValidationException($errors);
        }

        return true;
    }
}

建立 tests/Feature/Day08/FormValidatorTest.php

<?php

use App\Services\Day08\UserRegistrationForm;
use App\Services\Day08\FormValidationException;

test('throwsFormValidationExceptionForMissingRequiredFields', function () {
    $invalidForm = [];

    expect(fn() => UserRegistrationForm::validate($invalidForm))
        ->toThrow(FormValidationException::class);

    try {
        UserRegistrationForm::validate($invalidForm);
    } catch (FormValidationException $e) {
        expect($e->errors)->toHaveKey('email', 'Email is required')
            ->and($e->errors)->toHaveKey('password', 'Password is required');
    }
});

test('acceptsValidFormData', function () {
    $validFormData = [
        'email' => 'user@example.com',
        'password' => 'validpass123'
    ];

    expect(fn() => UserRegistrationForm::validate($validFormData))->not->toThrow();
    expect(UserRegistrationForm::validate($validFormData))->toBe(true);
});

最佳實踐 ✨

1. 測試各種錯誤情況

// 全面測試錯誤情況
test('handlesAllTypesOfInvalidInput', function () {
    $invalidInputs = [
        ['input' => null, 'expectedError' => 'Email is required'],
        ['input' => '', 'expectedError' => 'Email is required'],
        ['input' => 'invalid', 'expectedError' => 'Email must contain @ symbol']
    ];

    foreach ($invalidInputs as $case) {
        expect(fn() => EmailValidator::validate($case['input']))
            ->toThrow($case['expectedError']);
    }
});

2. 使用具體的錯誤斷言

// 具體的錯誤驗證
test('throwsValidationErrorWithFieldInformation', function () {
    try {
        EmailValidator::validate('invalid-email');
    } catch (ValidationException $e) {
        expect($e->field)->toBe('email')
            ->and($e->getMessage())->toContain('@ symbol');
    }
});

完整實作 🛒

完整實作 tests/Feature/Day08/ComprehensiveTest.php

<?php

class ECommerceException extends \Exception
{
    public function __construct(string $message, public readonly ?string $code = null)
    {
        parent::__construct($message);
    }
}

class ShoppingCart
{
    private array $items = [];
    
    public function __construct(private readonly object $inventory) {}

    public function addItem(mixed $productId, int $quantity = 1): void
    {
        if (!$productId) {
            throw new ECommerceException('Product ID is required', 'INVALID_PRODUCT_ID');
        }

        if ($quantity <= 0) {
            throw new ECommerceException('Quantity must be positive', 'INVALID_QUANTITY');
        }

        $product = $this->inventory->getProduct($productId);
        if (!$product) {
            throw new ECommerceException("Product not found: {$productId}", 'PRODUCT_NOT_FOUND');
        }

        if (!$this->inventory->isAvailable($productId, $quantity)) {
            throw new ECommerceException('Insufficient stock', 'INSUFFICIENT_STOCK');
        }

        $this->items[] = ['productId' => $productId, 'quantity' => $quantity];
    }

    public function getItems(): array
    {
        return $this->items;
    }
}

test('shoppingCartExceptionHandling', function () {
    $mockInventory = (object)[
        'getProduct' => fn($id) => $id === 'valid' ? (object)['price' => 10] : null,
        'isAvailable' => fn($id, $qty) => $id === 'valid' && $qty <= 5
    ];
    
    $cart = new ShoppingCart($mockInventory);

    // 測試無效商品 ID
    expect(fn() => $cart->addItem(null))
        ->toThrow(ECommerceException::class, 'Product ID is required');
    
    // 測試商品不存在
    expect(fn() => $cart->addItem('nonexistent'))
        ->toThrow('Product not found');
    
    // 測試庫存不足
    expect(fn() => $cart->addItem('valid', 10))
        ->toThrow('Insufficient stock');

    // 測試成功添加
    expect(fn() => $cart->addItem('valid', 2))->not->toThrow();
    expect($cart->getItems())->toHaveCount(1);
});

今日學習地圖 🗺️

我們現在在測試基礎概念的倒數第三天,已經掌握了大部分核心測試技術:

基礎概念 (1-10)
├── Day 1: 環境設定 ✅
├── Day 2: 基本斷言 ✅
├── Day 3: TDD 循環 ✅
├── Day 4: 測試結構 ✅
├── Day 5: 生命週期 ✅
├── Day 6: 參數化測試 ✅
├── Day 7: 測試替身 ✅
├── Day 8: 例外處理測試 📍 今天
├── Day 9: 測試覆蓋率
└── Day 10: 重構技巧

今天學到什麼?

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

  1. 例外處理測試的重要性:確保程式在錯誤情況下的穩定性
  2. Pest 的錯誤斷言toThrow()、例外類型檢查等方法
  3. Laravel HTTP 錯誤測試:使用 Http facade 模擬網路錯誤
  4. 自定義例外類別:創建有意義的錯誤類型和資訊

例外處理測試讓我們能夠:

  • 確保程式優雅地處理錯誤
  • 提供有用的錯誤訊息給使用者
  • 維持系統的穩定性和可靠性

總結

今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。明天我們將學習「測試覆蓋率」,了解如何衡量測試的完整性。

記住:好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!


上一篇
Day 07 - 測試替身基礎 🎭
下一篇
Day 09 - 測試覆蓋率:你的測試真的夠完整嗎? 📊
系列文
Laravel Pest TDD 實戰:從零開始的測試驅動開發9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言