iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Modern Web

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

Day 08 - 例外處理測試 ⚠️

  • 分享至 

  • xImage
  •  

今天要做什麼?

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

想像一個場景:你的應用需要處理各種錯誤情況:

  • API 回傳錯誤狀態碼
  • 使用者輸入無效資料
  • 網路連線失敗

很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理。

學習目標 🎯

今天結束後,你將學會:

  • 掌握 Vitest 的 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 會怎樣?
    }
  }
}

例外處理測試確保:

  1. 系統穩定性:避免應用程式崩潰
  2. 使用者體驗:提供有意義的錯誤訊息
  3. 除錯效率:快速定位問題根源

Vitest 例外測試基礎 ⚙️

測試同步錯誤

建立 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: 重構技巧

今天學到什麼?

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

  1. Vitest 的錯誤斷言toThrow()rejects.toThrow() 等方法
  2. 同步和異步錯誤測試:不同類型錯誤的測試技巧
  3. 自定義錯誤類別:創建有意義的錯誤類型和資訊
  4. 最佳實踐:具體的錯誤驗證和全面的測試覆蓋

例外處理測試讓我們能夠確保程式優雅地處理錯誤,提供有用的錯誤訊息,並維持系統的穩定性。

總結

今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。明天我們將學習「測試覆蓋率」,了解如何衡量測試的完整性。

記住:好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!


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

尚未有邦友留言

立即登入留言