iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
Modern Web

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

React Testing Library 入門 🌐

  • 分享至 

  • xImage
  •  

昨天我們完成了 Roman Numeral Kata,現在進入框架特定測試的新階段!想像一個場景:專案上線前夕,PM 緊張地問:「UI 組件都測試過了嗎?」你自信地回答:「每個組件都有完整的測試覆蓋!」這就是今天要學習的 React Testing Library。

本日學習地圖 🗺️

基礎階段             Kata 階段            框架特定測試
Days 1-10           Days 11-17           Days 18-27
   ✅                  ✅                  📍 Day 18

                                        [RTL 測試基礎] <- 今天在這
                                              ⬇️
                                        下一階段:更多測試技巧

為什麼需要 React Testing Library?🤔

在 React 中,組件測試允許我們:

  • 測試完整的用戶互動流程
  • 驗證組件渲染和狀態管理
  • 確保 UI 行為符合預期
  • 不需啟動真實瀏覽器

環境準備

安裝必要套件

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

建立測試結構

// 建立 tests/day18/test_rtl_basics.tsx
import { describe, test, expect } from 'vitest'
import { render, screen } from '@testing-library/react'

function HealthCheck() {
  return <div>Status: OK</div>
}

test('makes successful render', () => {
  render(<HealthCheck />)
  expect(screen.getByText('Status: OK')).toBeInTheDocument()
})

React Testing Library 基礎 💡

1. 基本測試方法

// 更新 tests/day18/test_rtl_methods.tsx
test('supports different interactions', () => {
  // Render 組件
  const { getByText } = render(<Button>Click me</Button>)
  expect(getByText('Click me')).toBeInTheDocument()
  
  // 使用 screen 查詢
  render(<Input placeholder="Enter text" />)
  expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument()
  
  // 查詢角色
  render(<button>Submit</button>)
  expect(screen.getByRole('button')).toBeInTheDocument()
})

2. 用戶互動測試

test('handles user events', async () => {
  const user = userEvent.setup()
  const handleClick = vi.fn()
  
  render(<button onClick={handleClick}>Click me</button>)
  await user.click(screen.getByRole('button'))
  
  expect(handleClick).toHaveBeenCalledTimes(1)
})

3. 驗證組件狀態

test('validates component state changes', async () => {
  const user = userEvent.setup()
  render(<Counter />)
  
  expect(screen.getByText('Count: 0')).toBeInTheDocument()
  
  await user.click(screen.getByRole('button', { name: 'Increment' }))
  expect(screen.getByText('Count: 1')).toBeInTheDocument()
})

實戰範例:建立簡單的應用 🚀

建立 Task 組件

// 建立 src/components/Day18/TaskList.tsx
import { useState } from 'react'

interface Task {
  id: number
  title: string
  completed: boolean
}

export function TaskList() {
  const [tasks, setTasks] = useState<Task[]>([])
  const [input, setInput] = useState('')
  
  const addTask = () => {
    if (input.trim()) {
      setTasks([...tasks, {
        id: Date.now(),
        title: input,
        completed: false
      }])
      setInput('')
    }
  }
  
  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Add task"
      />
      <button onClick={addTask}>Add</button>
      <ul>
        {tasks.map(task => (
          <li key={task.id}>{task.title}</li>
        ))}
      </ul>
    </div>
  )
}

完整測試套件

// 建立 tests/day18/test_task_list.tsx
import { describe, test, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TaskList } from '../../src/components/Day18/TaskList'

test('lists all tasks', () => {
  render(<TaskList />)
  expect(screen.getByPlaceholderText('Add task')).toBeInTheDocument()
})

test('creates new task', async () => {
  const user = userEvent.setup()
  render(<TaskList />)
  
  const input = screen.getByPlaceholderText('Add task')
  const button = screen.getByRole('button', { name: 'Add' })
  
  await user.type(input, 'Learn RTL Testing')
  await user.click(button)
  
  expect(screen.getByText('Learn RTL Testing')).toBeInTheDocument()
})

test('validates task creation input', async () => {
  const user = userEvent.setup()
  render(<TaskList />)
  
  const button = screen.getByRole('button', { name: 'Add' })
  await user.click(button)
  
  const tasks = screen.queryAllByRole('listitem')
  expect(tasks).toHaveLength(0)
})

test('manages multiple tasks', async () => {
  const user = userEvent.setup()
  render(<TaskList />)
  
  const input = screen.getByPlaceholderText('Add task')
  const button = screen.getByRole('button', { name: 'Add' })
  
  // 添加多個任務
  await user.type(input, 'Task 1')
  await user.click(button)
  await user.type(input, 'Task 2')
  await user.click(button)
  
  // 驗證任務存在且輸入已清空
  expect(screen.getByText('Task 1')).toBeInTheDocument()
  expect(screen.getByText('Task 2')).toBeInTheDocument()
  expect((input as HTMLInputElement).value).toBe('')
})

進階技巧 ⚡

錯誤處理與非同步測試

test('handles async operations', async () => {
  const user = userEvent.setup()
  render(<AsyncComponent />)
  
  await user.click(screen.getByRole('button'))
  
  // 等待元素出現
  const result = await screen.findByText('Success!')
  expect(result).toBeInTheDocument()
})

test('validates error handling', () => {
  render(<FormWithValidation />)
  
  const submitButton = screen.getByRole('button', { name: 'Submit' })
  fireEvent.click(submitButton)
  
  expect(screen.getByRole('alert')).toHaveTextContent('Required field')
})

小挑戰 🎯

試著實作這些功能的測試:

  1. 表單驗證測試:測試各種輸入驗證規則
  2. 條件渲染測試:測試不同狀態下的 UI 變化
  3. 事件處理測試:測試複雜的用戶互動流程

完整實作:TaskList 組件與測試

完整實作 src/components/TaskList.tsx

import { useState } from 'react'

interface Task {
  id: string
  title: string
  completed: boolean
}

export function TaskList() {
  const [tasks, setTasks] = useState<Task[]>([])
  const [newTask, setNewTask] = useState('')
  const [error, setError] = useState('')

  const handleAddTask = () => {
    if (!newTask.trim()) {
      setError('Required field')
      return
    }
    
    const task: Task = {
      id: Date.now().toString(),
      title: newTask,
      completed: false
    }
    
    setTasks([...tasks, task])
    setNewTask('')
    setError('')
  }

  return (
    <div>
      <h2>Task List</h2>
      {error && <div role="alert">{error}</div>}
      
      <div>
        <input
          type="text"
          aria-label="New task"
          value={newTask}
          onChange={(e) => setNewTask(e.target.value)}
          placeholder="Enter a task"
        />
        <button onClick={handleAddTask}>Add</button>
      </div>
      
      <ul>
        {tasks.map(task => (
          <li key={task.id}>
            <span>{task.title}</span>
          </li>
        ))}
      </ul>
    </div>
  )
}

本日重點回顧 📝

今天我們學習了 React Testing Library 的基礎:

  1. 基本測試方法:render、screen、userEvent
  2. 組件互動測試:點擊、輸入、表單提交
  3. 查詢優先級:role、label、text、testId
  4. 完整應用測試:實作 TaskList 組件測試
  5. 進階技巧:非同步操作、錯誤處理

React Testing Library 是組件測試的基石,讓我們能從用戶角度測試組件行為,而不是實作細節。

明天我們將學習更多框架特定的測試技巧,包括如何測試更複雜的應用場景。準備好繼續深入探索了嗎?明天見!🚀


上一篇
Day 17 - 程式碼整理與回顧 🏁
下一篇
Day 19 - MSW 設置與基礎 🎭
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言