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