iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Modern Web

React TDD 實戰:用 Vitest 打造可靠的前端應用系列 第 8

Day 08 - 例外處理測試 ⚠️

  • 分享至 

  • xImage
  •  

今天進入測試基礎概念的第八天,我們要學習如何測試程式在錯誤情況下的行為。

挑戰的開始 🎯

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

想像這個場景:當使用者輸入無效資料時(字串、負數、空值),你的程式會如何反應?

很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理,確保程式在各種錯誤情況下都能優雅處理。

為什麼例外處理測試如此重要? 💡

真實案例:一個支付處理器只考慮成功情況,忽略了 API 錯誤、網路問題、資料格式錯誤等異常狀況。

未處理的例外可能導致:應用程式崩潰 💥、資料不一致使用者體驗極差 😤、安全漏洞 🔓

例外處理測試確保:系統穩定性良好的使用者體驗快速除錯資料完整性

Vitest 例外測試基礎 ⚙️

測試同步錯誤

建立 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' })
  })
})

最佳實踐 ✨

1. 具體的錯誤驗證

// ✅ 好:驗證錯誤類型和內容
expect(() => validateEmail('invalid')).toThrow(ValidationError)
expect(() => validateEmail('invalid')).toThrow('@ symbol')

// ❌ 壞:只檢查有錯誤  
expect(() => validateEmail('invalid')).toThrow()

2. 異步錯誤處理

// ❌ 錯誤:沒有等待異步
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: 例外處理測試 📍 今天
├── 下階段: 更多測試技巧
└── 最終階段: 實戰整合

今天學到什麼? 📚

透過今天的學習,我們掌握了:

  1. 錯誤斷言方法toThrow()rejects.toThrow()
  2. 同步與異步錯誤:不同錯誤類型的測試策略
  3. 自定義錯誤類別:創建有意義的錯誤訊息
  4. 完整的錯誤覆蓋:確保所有錯誤路徑都被測試

總結 ✨

今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!

明天我們將繼續探索測試的進階主題,讓我們的測試技能更加完整。


上一篇
Day 07 - 測試替身基礎 🎭
下一篇
Day 09 - 測試覆蓋率:你的測試真的夠完整嗎? 📊
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言