iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0

昨天凌晨三點,手機響了。是值班同事:「購物車結帳功能掛了!」明明單元測試都通過,為什麼還是出問題?因為我們測試了每個零件,卻忘了測試它們組裝起來是否正常運作。這就是整合測試要解決的問題。

本日學習地圖 🗺️

前置基礎(Day 1-10)
├── 測試框架設置 ✅
├── 斷言與匹配器 ✅
├── TDD 循環 ✅
└── 測試組織 ✅

Kata 實戰(Day 11-17)
├── Roman Numerals ✅
├── 重構技巧 ✅
└── 進階 TDD ✅

框架特化(Day 18-27)
├── React 測試基礎 ✅
├── 元件測試 ✅
├── Hook 測試 ✅
├── 狀態管理測試 ✅
├── 路由測試 ✅
├── API 整合測試 ✅
└── 整合測試 📍  <-- 我們在這裡!

為什麼需要整合測試? 🤔

想像你正在組裝一台電腦。每個零件(CPU、記憶體、硬碟)單獨測試都沒問題,但裝在一起卻開不了機。這就是只做單元測試的盲點。

單元測試 vs 整合測試

特性 單元測試 整合測試
範圍 單一函數/元件 多個元件協作
速度 快速 ⚡ 較慢 🐢
隔離性 完全隔離 部分真實環境
維護成本 中等
信心程度 局部信心 整體信心

今日實作:Todo App 整合測試 🏗️

讓我們為 Todo 應用寫一個完整的整合測試,測試從輸入到顯示的完整流程。

建立 tests/day25/TodoApp.test.tsx

import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import TodoApp from '../../src/todo/TodoApp'

const server = setupServer(
  http.get('/api/todos', () => {
    return HttpResponse.json([
      { id: 1, title: '學習 React', completed: false },
      { id: 2, title: '寫測試', completed: true }
    ])
  }),
  
  http.post('/api/todos', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json({
      id: 3,
      title: body.title,
      completed: false
    }, { status: 201 })
  }),
  
  http.put('/api/todos/:id', async ({ params, request }) => {
    const body = await request.json()
    return HttpResponse.json({
      id: Number(params.id),
      ...body
    })
  }),
  
  http.delete('/api/todos/:id', ({ params }) => {
    return HttpResponse.json(
      { message: `Todo ${params.id} deleted` },
      { status: 200 }
    )
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('TodoApp Integration', () => {
  let queryClient

  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
        mutations: { retry: false }
      }
    })
  })

  it('displaysInitialTodosFromServer', async () => {
    render(
      <QueryClientProvider client={queryClient}>
        <TodoApp />
      </QueryClientProvider>
    )

    await waitFor(() => {
      expect(screen.getByText('學習 React')).toBeInTheDocument()
      expect(screen.getByText('寫測試')).toBeInTheDocument()
    })
  })

  it('addsNewTodoAndDisplaysIt', async () => {
    const user = userEvent.setup()
    
    render(
      <QueryClientProvider client={queryClient}>
        <TodoApp />
      </QueryClientProvider>
    )

    // 等待初始載入
    await waitFor(() => {
      expect(screen.getByText('學習 React')).toBeInTheDocument()
    })

    // 輸入新 todo
    const input = screen.getByPlaceholderText('新增待辦事項...')
    await user.type(input, '整合測試')
    await user.keyboard('{Enter}')

    // 驗證新增成功
    await waitFor(() => {
      expect(screen.getByText('整合測試')).toBeInTheDocument()
    })
    
    // 輸入欄位應該被清空
    expect(input).toHaveValue('')
  })

  it('togglesTodoCompletionStatus', async () => {
    const user = userEvent.setup()
    
    render(
      <QueryClientProvider client={queryClient}>
        <TodoApp />
      </QueryClientProvider>
    )

    await waitFor(() => {
      expect(screen.getByText('學習 React')).toBeInTheDocument()
    })

    // 找到未完成的 todo 並點擊 checkbox
    const checkbox = screen.getByRole('checkbox', { name: /學習 React/i })
    expect(checkbox).not.toBeChecked()
    
    await user.click(checkbox)

    await waitFor(() => {
      expect(checkbox).toBeChecked()
    })
  })

  it('deletesTodoFromList', async () => {
    const user = userEvent.setup()
    
    render(
      <QueryClientProvider client={queryClient}>
        <TodoApp />
      </QueryClientProvider>
    )

    await waitFor(() => {
      expect(screen.getByText('學習 React')).toBeInTheDocument()
    })

    // 找到刪除按鈕並點擊
    const deleteButtons = screen.getAllByRole('button', { name: /刪除/i })
    await user.click(deleteButtons[0])

    // 確認 todo 被移除
    await waitFor(() => {
      expect(screen.queryByText('學習 React')).not.toBeInTheDocument()
    })
  })

  it('filtersTodosByStatus', async () => {
    const user = userEvent.setup()
    
    render(
      <QueryClientProvider client={queryClient}>
        <TodoApp />
      </QueryClientProvider>
    )

    await waitFor(() => {
      expect(screen.getByText('學習 React')).toBeInTheDocument()
      expect(screen.getByText('寫測試')).toBeInTheDocument()
    })

    // 點擊「已完成」篩選
    const completedFilter = screen.getByRole('button', { name: /已完成/i })
    await user.click(completedFilter)

    // 只顯示已完成項目
    expect(screen.queryByText('學習 React')).not.toBeInTheDocument()
    expect(screen.getByText('寫測試')).toBeInTheDocument()

    // 點擊「未完成」篩選
    const activeFilter = screen.getByRole('button', { name: /未完成/i })
    await user.click(activeFilter)

    // 只顯示未完成項目
    expect(screen.getByText('學習 React')).toBeInTheDocument()
    expect(screen.queryByText('寫測試')).not.toBeInTheDocument()
  })
})

測試錯誤處理 ⚠️

整合測試也要考慮異常情況:

建立 tests/day25/ErrorHandling.test.tsx

import { describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import TodoApp from '../../src/todo/TodoApp'

const server = setupServer()

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('Error Handling', () => {
  it('showsErrorWhenServerDown', async () => {
    server.use(
      http.get('/api/todos', () => {
        return HttpResponse.error()
      })
    )

    const queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false }
      }
    })

    render(
      <QueryClientProvider client={queryClient}>
        <TodoApp />
      </QueryClientProvider>
    )

    await waitFor(() => {
      expect(screen.getByText(/載入失敗/i)).toBeInTheDocument()
    })
  })

  it('handlesNetworkTimeout', async () => {
    server.use(
      http.get('/api/todos', async () => {
        await new Promise(resolve => setTimeout(resolve, 5000))
        return HttpResponse.json([])
      })
    )

    const queryClient = new QueryClient({
      defaultOptions: {
        queries: { 
          retry: false,
          gcTime: 0,
          staleTime: 0
        }
      }
    })

    render(
      <QueryClientProvider client={queryClient}>
        <TodoApp />
      </QueryClientProvider>
    )

    expect(screen.getByText(/載入中/i)).toBeInTheDocument()
  })

  it('retriesFailedRequests', async () => {
    let attemptCount = 0
    
    server.use(
      http.get('/api/todos', () => {
        attemptCount++
        if (attemptCount < 3) {
          return HttpResponse.error()
        }
        return HttpResponse.json([
          { id: 1, title: '成功載入', completed: false }
        ])
      })
    )

    const queryClient = new QueryClient({
      defaultOptions: {
        queries: { 
          retry: 2,
          retryDelay: 0
        }
      }
    })

    render(
      <QueryClientProvider client={queryClient}>
        <TodoApp />
      </QueryClientProvider>
    )

    await waitFor(() => {
      expect(screen.getByText('成功載入')).toBeInTheDocument()
    }, { timeout: 3000 })
    
    expect(attemptCount).toBe(3)
  })
})

整合測試的層級 📊

完整實作 tests/day25/TestLevels.test.tsx

import { describe, it, expect } from 'vitest'

describe('Testing Pyramid', () => {
  it('understandsDifferentTestLevels', () => {
    const testPyramid = {
      unit: {
        count: 100,
        speed: 'fast',
        scope: 'single function/component',
        confidence: 60
      },
      integration: {
        count: 20,
        speed: 'medium',
        scope: 'multiple components',
        confidence: 80
      },
      e2e: {
        count: 5,
        speed: 'slow',
        scope: 'full user journey',
        confidence: 95
      }
    }

    // 單元測試最多
    expect(testPyramid.unit.count).toBeGreaterThan(
      testPyramid.integration.count
    )
    
    // 整合測試居中
    expect(testPyramid.integration.count).toBeGreaterThan(
      testPyramid.e2e.count
    )
    
    // 信心程度遞增
    expect(testPyramid.e2e.confidence).toBeGreaterThan(
      testPyramid.integration.confidence
    )
  })
})

實戰練習:購物車整合測試 🛒

建立 tests/day25/ShoppingCart.test.tsx

import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import ShoppingCart from '../../src/cart/ShoppingCart'

const mockProducts = [
  { id: 1, name: 'TypeScript 書', price: 500 },
  { id: 2, name: '測試課程', price: 1200 }
]

const server = setupServer(
  http.get('/api/products', () => {
    return HttpResponse.json(mockProducts)
  }),
  
  http.post('/api/cart', async ({ request }) => {
    const body = await request.json()
    return HttpResponse.json({
      ...body,
      addedAt: new Date().toISOString()
    })
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('Shopping Cart Integration', () => {
  it('completesPurchaseFlow', async () => {
    const user = userEvent.setup()
    
    render(<ShoppingCart />)

    // 等待產品載入
    await waitFor(() => {
      expect(screen.getByText('TypeScript 書')).toBeInTheDocument()
    })

    // 加入購物車
    const addButtons = screen.getAllByRole('button', { name: /加入購物車/i })
    await user.click(addButtons[0])

    // 檢查購物車數量
    await waitFor(() => {
      expect(screen.getByText(/購物車 \(1\)/i)).toBeInTheDocument()
    })

    // 檢查總金額
    expect(screen.getByText(/總計: 500/i)).toBeInTheDocument()
  })
})

小挑戰 🎯

請為你的 Todo App 加入以下整合測試:

  1. 批次操作測試:選擇多個 todos 並批次刪除
  2. 搜尋功能測試:輸入關鍵字過濾 todos
  3. 拖放排序測試:拖動 todo 項目改變順序

提示:整合測試要測試完整的用戶操作流程!

本日重點回顧 📝

今天我們學習了整合測試的重要概念:

  1. ✅ 理解整合測試的價值與定位
  2. ✅ 實作完整的用戶流程測試
  3. ✅ 處理異步操作與錯誤情況
  4. ✅ 平衡不同層級的測試

整合測試就像品管的最後一道關卡,確保所有零件組合後能正常運作。記住:好的整合測試能抓到單元測試漏掉的問題!

明日預告 🚀

明天我們將探討「效能測試」,學習如何確保應用的回應速度!

記住:整合測試是信心的來源,它證明你的程式真的能用! 💪


上一篇
Day 24 - 測試生命週期 Hook 🔄
下一篇
Day 26 - 效能測試 ⚡
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言