昨天我們學會了測試替身,解決了外部依賴的測試問題。今天面對一個新的挑戰:「如何測試程式在出錯時的行為?」
想像一個場景:你的應用需要處理各種錯誤情況:
很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理。
今天結束後,你將學會:
toThrow()
和 rejects.toThrow()
斷言// 問題:只考慮成功情況的程式碼
class UserService {
getUserProfile(userId) {
const response = fetch(`/api/users/${userId}`)
const user = response.json() // 如果不是 JSON 格式會怎樣?
return {
id: user.id,
name: user.name.toUpperCase() // 如果 name 是 null 會怎樣?
}
}
}
例外處理測試確保:
建立 src/day08/validator.js
export class ValidationError extends Error {
constructor(message, field = null) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
export function validateEmail(email) {
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, b) {
if (b === 0) {
throw new Error('Division by zero is not allowed');
}
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('Both arguments must be numbers');
}
return a / b;
}
建立 tests/day08/sync-exceptions.test.js
import { describe, it, expect } from 'vitest'
import { validateEmail, ValidationError, divide } from '../../src/day08/validator.js'
describe('synchronous exception handling tests', () => {
describe('validateEmail', () => {
it('throwsErrorWhenEmailIsMissing', () => {
expect(() => validateEmail()).toThrow('Email is required')
expect(() => validateEmail('')).toThrow('Email is required')
})
it('throwsErrorWhenEmailFormatIsInvalid', () => {
expect(() => validateEmail('invalid-email')).toThrow('Email must contain @ symbol')
})
it('throwsValidationErrorWithCorrectField', () => {
try {
validateEmail('invalid')
} catch (error) {
expect(error).toBeInstanceOf(ValidationError)
expect(error.field).toBe('email')
expect(error.message).toContain('@ symbol')
}
})
it('doesNotThrowWhenEmailIsValid', () => {
expect(() => validateEmail('user@example.com')).not.toThrow()
})
})
describe('divide function', () => {
it('throwsErrorForDivisionByZero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero is not allowed')
})
it('doesNotThrowForValidInputs', () => {
expect(() => divide(10, 2)).not.toThrow()
expect(divide(15, 3)).toBe(5)
})
})
})
建立 src/day08/user-service.js
export class UserService {
constructor(httpClient) {
this.httpClient = httpClient
}
async fetchUser(userId) {
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.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserService } from '../../src/day08/user-service.js'
describe('asynchronous exception handling tests', () => {
let userService, mockHttpClient
beforeEach(() => {
mockHttpClient = { get: vi.fn() }
userService = new UserService(mockHttpClient)
})
it('rejectsWithErrorForInvalidUserId', async () => {
await expect(userService.fetchUser()).rejects.toThrow('Invalid user ID')
})
it('rejectsForHttpErrors', async () => {
mockHttpClient.get.mockResolvedValue({ ok: false, status: 404 })
await expect(userService.fetchUser(1))
.rejects.toThrow('Failed to fetch user: 404')
})
it('resolvesForValidUserData', async () => {
const mockUser = { id: 1, name: 'John Doe' }
mockHttpClient.get.mockResolvedValue({
ok: true,
json: async () => mockUser
})
await expect(userService.fetchUser(1)).resolves.toEqual(mockUser)
})
})
建立 src/day08/form-validator.js
export class FormValidationError extends Error {
constructor(errors) {
super('Form validation failed')
this.name = 'FormValidationError'
this.errors = errors
}
}
export class UserForm {
static validate(formData) {
const errors = {}
if (!formData.email) {
errors.email = 'Email is required'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Invalid email format'
}
if (!formData.password) {
errors.password = 'Password is required'
} else if (formData.password.length < 8) {
errors.password = 'Password must be at least 8 characters'
}
if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = 'Passwords do not match'
}
if (Object.keys(errors).length > 0) {
throw new FormValidationError(errors)
}
return true
}
}
建立 tests/day08/form-validator.test.js
import { describe, it, expect } from 'vitest'
import { UserForm, FormValidationError } from '../../src/day08/form-validator.js'
describe('form validator exception tests', () => {
it('throwsFormValidationErrorForMissingFields', () => {
expect(() => UserForm.validate({})).toThrow(FormValidationError)
try {
UserForm.validate({})
} catch (error) {
expect(error.errors).toHaveProperty('email', 'Email is required')
expect(error.errors).toHaveProperty('password', 'Password is required')
}
})
it('throwsErrorForInvalidEmail', () => {
const formData = {
email: 'invalid-email',
password: 'ValidPass123',
confirmPassword: 'ValidPass123'
}
expect(() => UserForm.validate(formData))
.toThrow(expect.objectContaining({
errors: expect.objectContaining({
email: 'Invalid email format'
})
}))
})
it('doesNotThrowForValidFormData', () => {
const validFormData = {
email: 'user@example.com',
password: 'ValidPass123',
confirmPassword: 'ValidPass123'
}
expect(() => UserForm.validate(validFormData)).not.toThrow()
})
})
// ✅ 具體的錯誤驗證
it('throwsValidationErrorWithFieldInformation', () => {
expect(() => validateEmail('invalid'))
.toThrow(expect.objectContaining({
name: 'ValidationError',
field: 'email',
message: expect.stringContaining('@ symbol')
}))
})
// ✅ 全面測試錯誤情況
it('handlesAllInvalidInputs', () => {
const cases = [
{ input: null, error: 'Email is required' },
{ input: 'invalid', error: 'Invalid email format' }
]
cases.forEach(({ input, error }) => {
expect(() => validateEmail(input)).toThrow(error)
})
})
// 問題:沒有等待異步錯誤
it('handleAsyncError', () => {
expect(() => asyncFunction()).toThrow() // 不會工作!
})
// 解決:正確處理異步錯誤
it('handleAsyncErrorCorrectly', async () => {
await expect(asyncFunction()).rejects.toThrow('Expected error message')
})
我們現在在測試基礎概念的倒數第三天,已經掌握了大部分核心測試技術:
基礎概念 (1-10)
├── Day 1: 環境設定 ✅
├── Day 2: 基本斷言 ✅
├── Day 3: TDD 循環 ✅
├── Day 4: 測試結構 ✅
├── Day 5: 生命週期 ✅
├── Day 6: 參數化測試 ✅
├── Day 7: 測試替身 ✅
├── Day 8: 例外處理測試 📍 今天
├── Day 9: 測試覆蓋率
└── Day 10: 重構技巧
透過今天的學習,我們掌握了:
toThrow()
、rejects.toThrow()
等方法例外處理測試讓我們能夠確保程式優雅地處理錯誤,提供有用的錯誤訊息,並維持系統的穩定性。
今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。明天我們將學習「測試覆蓋率」,了解如何衡量測試的完整性。
記住:好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!