昨天我們學會了測試替身,解決了外部依賴的測試問題。今天面對一個新的挑戰:「如何測試程式在出錯時的行為?」
想像一個場景:你的應用需要處理各種錯誤情況:
很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理。
今天結束後,你將學會:
toThrow()
例外斷言// 問題:只考慮成功情況的程式碼
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 會怎樣?
];
}
}
例外處理測試確保:
建立 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();
});
建立 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);
});
// 全面測試錯誤情況
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']);
}
});
// 具體的錯誤驗證
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: 重構技巧
透過今天的學習,我們掌握了:
toThrow()
、例外類型檢查等方法例外處理測試讓我們能夠:
今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。明天我們將學習「測試覆蓋率」,了解如何衡量測試的完整性。
記住:好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!