iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0

今天要做什麼?

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

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

  • 函數接收到無效參數
  • 使用者輸入無效資料
  • 陣列索引超出範圍

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

學習目標 🎯

今天結束後,你將學會:

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

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

// 問題:只考慮成功情況的程式碼
class Calculator
{
    public function divide(float $a, float $b): float
    {
        return $a / $b; // 如果 $b 是 0 會怎樣?
    }
    
    public function getArrayValue(array $data, string $key)
    {
        return strtoupper($data[$key]); // 如果 key 不存在會怎樣?
    }
}

例外處理測試確保:

  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('throwsErrorWhenEmailIsMissing', function () {
    expect(fn() => EmailValidator::validate(null))->toThrow('Email is required');
    expect(fn() => EmailValidator::validate(''))->toThrow('Email is required');
});

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

test('throwsValidationErrorWithCorrectField', function () {
    try {
        EmailValidator::validate('invalid');
    } catch (ValidationException $e) {
        expect($e)->toBeInstanceOf(ValidationException::class)
            ->and($e->field)->toBe('email')
            ->and($e->getMessage())->toContain('@ symbol');
    }
});

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

測試異步服務例外 🚀

建立 app/Services/Day08/UserService.php

<?php

namespace App\Services\Day08;

use Exception;

interface HttpClient
{
    public function get(string $url): object;
}

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

class UserService
{
    public function __construct(private readonly HttpClient $httpClient) {}

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

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

        $user = $response->json();
        
        if (!isset($user['id']) || !isset($user['name'])) {
            throw new Exception('Invalid user data received');
        }

        return $user;
    }
}

建立 tests/Feature/Day08/UserServiceTest.php

<?php

use App\Services\Day08\UserService;
use App\Services\Day08\NetworkException;
use App\Services\Day08\HttpClient;

beforeEach(function () {
    $this->mockHttpClient = Mockery::mock(HttpClient::class);
    $this->userService = new UserService($this->mockHttpClient);
});

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

test('rejectsForHttpErrors', function () {
    $mockResponse = (object) ['ok' => false, 'status' => 404];
    $this->mockHttpClient->shouldReceive('get')->andReturn($mockResponse);

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

test('resolvesForValidUserData', function () {
    $mockUser = ['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com'];
    $mockResponse = (object) [
        'ok' => true,
        'json' => fn() => $mockUser
    ];
    
    $this->mockHttpClient->shouldReceive('get')->andReturn($mockResponse);

    $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 UserForm
{
    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';
        }

        if ($formData['password'] !== ($formData['confirmPassword'] ?? '')) {
            $errors['confirmPassword'] = 'Passwords do not match';
        }

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

        return true;
    }
}

建立 tests/Feature/Day08/FormValidatorTest.php

<?php

use App\Services\Day08\UserForm;
use App\Services\Day08\FormValidationException;

test('throwsFormValidationErrorForMissingFields', function () {
    expect(fn() => UserForm::validate([]))->toThrow(FormValidationException::class);

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

test('throwsErrorForInvalidEmail', function () {
    $formData = [
        'email' => 'invalid-email',
        'password' => 'ValidPass123',
        'confirmPassword' => 'ValidPass123'
    ];

    try {
        UserForm::validate($formData);
    } catch (FormValidationException $e) {
        expect($e)->toBeInstanceOf(FormValidationException::class)
            ->and($e->errors['email'])->toBe('Invalid email format');
    }
});

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

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

最佳實踐 ✨

1. 具體的錯誤驗證

// ✅ 好:驗證錯誤類型和內容
try {
    EmailValidator::validate('invalid');
} catch (ValidationException $e) {
    expect($e)->toBeInstanceOf(ValidationException::class)
        ->and($e->field)->toBe('email')
        ->and($e->getMessage())->toContain('@ symbol');
}

// ❌ 壞:只檢查有錯誤
expect(fn() => EmailValidator::validate('invalid'))->toThrow();

2. 測試各種錯誤情況

// ✅ 全面測試錯誤情況
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']);
    }
});

完整實作 🛒

完整實作 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('handlesAllErrorCases', 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(new ECommerceException('Product ID is required', 'INVALID_PRODUCT_ID'));
    
    // 測試商品不存在
    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. Pest 的錯誤斷言toThrow()、例外類型檢查等方法
  2. Laravel HTTP 錯誤測試:使用 Http facade 模擬網路錯誤
  3. 自定義例外類別:創建有意義的錯誤類型和資訊
  4. 最佳實踐:具體的錯誤驗證和全面的測試覆蓋

例外處理測試讓我們能夠確保程式優雅地處理錯誤,提供有用的錯誤訊息,並維持系統的穩定性。

總結

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

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


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

尚未有邦友留言

立即登入留言