今天進入測試基礎概念的第八天,我們要學習如何測試程式在錯誤情況下的行為。
昨天我們學會了測試替身,解決了外部依賴的測試問題。今天面對一個新的挑戰:「如何測試程式在出錯時的行為?」
想像這個場景:當使用者輸入無效資料時(字串、負數、空值),你的程式會如何反應?
很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理,確保程式在各種錯誤情況下都能優雅處理。
真實案例:一個支付處理器只考慮成功情況,忽略了 API 錯誤、網路問題、資料格式錯誤等異常狀況。
未處理的例外可能導致:應用程式崩潰 💥、資料不一致、使用者體驗極差 😤、安全漏洞 🔓
例外處理測試確保:系統穩定性、良好的使用者體驗、快速除錯、資料完整性
建立 src/day08/validator.ts
export class ValidationError extends Error {
constructor(message: string, public field: string | null = null) {
super(message)
this.name = 'ValidationError'
}
}
export function validateEmail(email: unknown): boolean {
if (!email || typeof email !== 'string') {
throw new ValidationError('Email is required', 'email')
}
if (!email.includes('@')) {
throw new ValidationError('Email must contain @ symbol', 'email')
}
return true
}
export function divide(a: unknown, b: unknown): number {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('Both arguments must be numbers')
}
if (b === 0) {
throw new Error('Division by zero is not allowed')
}
return a / b
}
建立 tests/day08/sync-exceptions.test.ts
import { describe, it, expect } from 'vitest'
import { validateEmail, ValidationError, divide } from '../../src/day08/validator'
describe('sync exception tests', () => {
it('throwsForMissingEmail', () => {
expect(() => validateEmail()).toThrow('Email is required')
expect(() => validateEmail('')).toThrow('Email is required')
})
it('throwsForInvalidEmail', () => {
expect(() => validateEmail('invalid')).toThrow('@ symbol')
})
it('validatesErrorType', () => {
try {
validateEmail('invalid')
} catch (error) {
expect(error).toBeInstanceOf(ValidationError)
expect((error as ValidationError).field).toBe('email')
}
})
it('passesValidEmail', () => {
expect(() => validateEmail('user@example.com')).not.toThrow()
})
it('handlesDivisionErrors', () => {
expect(() => divide(10, 0)).toThrow('Division by zero')
expect(() => divide('10', 2)).toThrow(TypeError)
expect(divide(10, 2)).toBe(5)
})
})
建立 src/day08/user-service.ts
interface HttpClient {
get(url: string): Promise<Response>
}
export class UserService {
constructor(private httpClient: HttpClient) {}
async fetchUser(userId?: number) {
if (!userId || userId <= 0) {
throw new Error('Invalid user ID')
}
const response = await this.httpClient.get(`/users/${userId}`)
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`)
}
const user = await response.json()
if (!user.id || !user.name) {
throw new Error('Invalid user data received')
}
return user
}
}
建立 tests/day08/async-exceptions.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserService } from '../../src/day08/user-service'
describe('async exception tests', () => {
let service: UserService
let mockHttp: { get: ReturnType<typeof vi.fn> }
beforeEach(() => {
mockHttp = { get: vi.fn() }
service = new UserService(mockHttp)
})
it('rejectsInvalidUserId', async () => {
await expect(service.fetchUser()).rejects.toThrow('Invalid user ID')
})
it('rejectsHttpErrors', async () => {
mockHttp.get.mockResolvedValue({ ok: false, status: 404 })
await expect(service.fetchUser(1)).rejects.toThrow('404')
})
it('resolvesValidUser', async () => {
mockHttp.get.mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'John' })
})
await expect(service.fetchUser(1)).resolves.toHaveProperty('id', 1)
})
})
完整實作 tests/day08/order-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
class OrderService {
constructor(private inventory: any, private payment: any) {}
async createOrder(items: any[], paymentInfo: any) {
if (!items?.length) {
throw new Error('Items required')
}
// 檢查庫存
for (const item of items) {
const stock = await this.inventory.check(item.id)
if (stock < item.quantity) {
throw new Error(`Out of stock: ${item.id}`)
}
}
// 支付
const result = await this.payment.process(paymentInfo)
if (!result.success) {
throw new Error('Payment failed')
}
return { orderId: result.txId, status: 'ok' }
}
}
describe('order service exceptions', () => {
let service: OrderService
let mockInventory: any
let mockPayment: any
beforeEach(() => {
mockInventory = { check: vi.fn() }
mockPayment = { process: vi.fn() }
service = new OrderService(mockInventory, mockPayment)
})
it('requiresItems', async () => {
await expect(service.createOrder([], {}))
.rejects.toThrow('Items required')
})
it('checksInventory', async () => {
mockInventory.check.mockResolvedValue(0)
await expect(
service.createOrder([{ id: 'A1', quantity: 5 }], {})
).rejects.toThrow('Out of stock: A1')
})
it('handlesPaymentFailure', async () => {
mockInventory.check.mockResolvedValue(10)
mockPayment.process.mockResolvedValue({ success: false })
await expect(
service.createOrder([{ id: 'A1', quantity: 1 }], {})
).rejects.toThrow('Payment failed')
})
it('successfulOrder', async () => {
mockInventory.check.mockResolvedValue(10)
mockPayment.process.mockResolvedValue({ success: true, txId: 'TX1' })
const result = await service.createOrder(
[{ id: 'A1', quantity: 2 }],
{ card: '1234' }
)
expect(result).toEqual({ orderId: 'TX1', status: 'ok' })
})
})
// ✅ 好:驗證錯誤類型和內容
expect(() => validateEmail('invalid')).toThrow(ValidationError)
expect(() => validateEmail('invalid')).toThrow('@ symbol')
// ❌ 壞:只檢查有錯誤
expect(() => validateEmail('invalid')).toThrow()
// ❌ 錯誤:沒有等待異步
it('handleAsyncError', () => {
expect(() => asyncFunction()).toThrow() // 錯誤!
})
// ✅ 正確:使用 rejects
it('handleAsyncErrorCorrectly', async () => {
await expect(asyncFunction()).rejects.toThrow('Expected error')
})
基礎概念 (1-10)
├── Day 1: 環境設定 ✅
├── Day 2: 基本斷言 ✅
├── Day 3: TDD 循環 ✅
├── Day 4: 測試結構 ✅
├── Day 5: 生命週期 ✅
├── Day 6: 參數化測試 ✅
├── Day 7: 測試替身 ✅
├── Day 8: 例外處理測試 📍 今天
├── 下階段: 更多測試技巧
└── 最終階段: 實戰整合
透過今天的學習,我們掌握了:
toThrow()
和 rejects.toThrow()
今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!
明天我們將繼續探索測試的進階主題,讓我們的測試技能更加完整。