React Testing Library 與 Vitest 單元測試

測試是軟體開發中不可或缺的一環。在 React 生態系中,React Testing Library (RTL) 已經成為測試 UI 元件的標準。它的設計哲學是:

"The more your tests resemble the way your software is used, the more confidence they can give you." (你的測試越像使用者使用軟體的方式,它們就越能給你信心。)

這意味著我們不應該測試元件的實作細節(例如 state 變數、內部函式),而應該測試使用者看到的內容(例如按鈕文字、標題)。

現代 React 開發通常搭配 Vitest 作為測試執行器 (Test Runner),因為它與 Vite 的整合性最好且速度比 Jest 快。

安裝

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
  • vitest: 測試執行器(取代 Jest)。
  • jsdom: 在 Node.js 環境中模擬瀏覽器 DOM API。
  • @testing-library/react: 核心測試庫。
  • @testing-library/jest-dom: 提供額外的 DOM 斷言(如 toBeInTheDocument)。
  • @testing-library/user-event: 模擬使用者操作(點擊、輸入)。

Setup

vite.config.js 中設定 test 環境:

/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true, // 允許全域使用 describe, it, expect
    environment: 'jsdom',
    setupFiles: './src/test/setup.js', // 設定檔路徑
  },
});

建立 src/test/setup.js

import '@testing-library/jest-dom';

測試檔案結構 (Basic Syntax)

在開始測試 Component 之前,我們先了解測試檔案的基本結構。Vitest (相容 Jest) 提供了以下全域函式:

  • describe(name, fn): 將相關的測試分組 (Suite)。例如把「計數器元件」的所有測試包在一起。
  • it(name, fn)test(name, fn): 定義一個獨立的測試案例 (Test Case)。描述應該要具體,例如「點擊按鈕時數字應該加一」。
  • expect(value): 斷言 (Assertion) 的起點。用來驗證結果是否符合預期。

斷言配對 (Matchers)

expect 後面會接 Matcher 來進行驗證。常用的包括:

  • toBe(value): 嚴格相等檢查 (===)。
  • toEqual(obj): 物件內容檢查 (Deep Equality)。
  • toBeTruthy(), toBeFalsy(): 檢查 Boolean 真假值。
  • toContain(item): 檢查陣列或字串是否包含某內容。
  • toBeInTheDocument(): (RTL 專用) 檢查元素是否存在於 DOM 中。
describe('Math Operations', () => {
  it('1 + 1 應該等於 2', () => {
    expect(1 + 1).toBe(2);
  });

  it('物件內容應該相同', () => {
    const data = { id: 1 };
    expect(data).toEqual({ id: 1 }); // 成功
    // expect(data).toBe({ id: 1 }); // 失敗,因為記憶體位置不同
  });
});

基本測試範例

假設我們有一個簡單的計數器元件:

// Counter.jsx
import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <h1>目前計數: {count}</h1>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  );
}

撰寫測試檔案 Counter.test.jsx

import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

describe('Counter Component', () => {
  it('應該渲染初始計數', () => {
    render(<Counter />);
    // 透過文字內容尋找元素 (就像使用者用眼睛找一樣)
    expect(screen.getByText('目前計數: 0')).toBeInTheDocument();
  });

  it('點擊按鈕後數字應該增加', () => {
    render(<Counter />);

    // 找到按鈕
    const button = screen.getByText('增加');

    // 模擬點擊事件
    fireEvent.click(button);

    // 驗證結果
    expect(screen.getByText('目前計數: 1')).toBeInTheDocument();
  });
});

查詢元素 (Queries)

RTL 提供了三種主要的查詢類型,區別在於找不到元素時的行為不同:

  1. getBy...

    • 用途:斷言元素一定存在
    • 行為:找不到時會直接拋出錯誤 (Test Failed)。
    • 範例getByText, getByRole, getByLabelText
  2. queryBy...

    • 用途:斷言元素不存在
    • 行為:找不到時回傳 null,不會拋錯。
    • 範例expect(screen.queryByText(/loading/i)).toBeNull()
  3. findBy... (Async)

    • 用途:斷言元素會非同步出現 (例如 API 回傳後)。
    • 行為:會回傳一個 Promise,並在預設 1000ms 內不斷重試 (Retry),直到找到或 Timeout。
    • 範例await screen.findByRole('button')

優先順序 (Priority)

  1. getByRole: 最推薦,能確保元件對螢幕閱讀器友善 (Accessibility)。
  2. getByLabelText: 表單輸入欄位推薦使用。
  3. getByPlaceholderText: 用於無 Label 的輸入框。
  4. getByText: 用於非互動的文字內容 (div, span)。
  5. getByTestId: 最後手段,需在 code 中加 data-testid 屬性。

模擬使用者操作 (User Event)

官方強烈推薦使用 user-event 庫取代 fireEvent,因為它能更真實地模擬瀏覽器行為(例如:點擊輸入框會觸發 focus, keydown, keyup, click 等一連串事件,而不僅僅是 click)。

常用操作

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

it('複雜互動測試', async () => {
  const user = userEvent.setup(); // 必須先 setup
  render(<LoginForm />);

  // 1. 輸入文字 (Type)
  const input = screen.getByLabelText(/email/i);
  await user.type(input, 'test@example.com');

  // 2. 鍵盤操作 (Keyboard) - 例如按下 Enter 送出
  await user.keyboard('{Enter}');

  // 3. 滑鼠懸停 (Hover)
  const tooltipTarget = screen.getByText('說明');
  await user.hover(tooltipTarget);
  expect(screen.getByRole('tooltip')).toBeInTheDocument();

  // 4. 上傳檔案 (Upload)
  const file = new File(['hello'], 'hello.png', { type: 'image/png' });
  const uploader = screen.getByLabelText(/大頭貼/i);
  await user.upload(uploader, file);

  // 驗證 input.files
  expect(uploader.files[0]).toBe(file);
});

測試非同步操作 (Async)

當畫面更新涉及 API 呼叫或非同步狀態時,使用 findBy...waitFor

import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';

// Mock fetch
global.fetch = vi.fn();

it('載入並顯示使用者列表', async () => {
  // 模擬 API 回傳
  fetch.mockResolvedValueOnce({
    json: async () => [{ id: 1, name: 'John Doe' }],
  });

  render(<UserList />);

  // 一開始顯示 Loading
  expect(screen.getByText('載入中...')).toBeInTheDocument();

  // 等待元素出現 (findBy 內部有 retry 機制)
  const userItem = await screen.findByText('John Doe');
  expect(userItem).toBeInTheDocument();

  // 或者使用 waitFor 等待某個斷言通過
  /*
  await waitFor(() => {
    expect(screen.queryByText('載入中...')).not.toBeInTheDocument()
  })
  */
});

測試 Custom Hooks

測試純邏輯的 Custom Hook (例如 useCounter) 時,不能直接呼叫,必須使用 renderHook

import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

it('測試 useCounter', () => {
  const { result } = renderHook(() => useCounter());

  // 驗證初始值
  expect(result.current.count).toBe(0);

  // 觸發狀態更新必須包在 act() 裡面
  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

Mocking & Spying (使用 Vitest)

在寫單元測試時,我們常需要模擬外部依賴 (API, Context, Props) 來隔離測試目標。Vitest 提供了強大的 Mock 功能。

模擬函式 (Mock Functions)

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';

it('點擊時應該呼叫 onClick prop', async () => {
  const handleClick = vi.fn(); // 建立一個模擬函式
  const user = userEvent.setup();

  render(<Button onClick={handleClick}>Click Me</Button>);

  await user.click(screen.getByRole('button'));

  expect(handleClick).toHaveBeenCalledTimes(1); // 驗證是否被呼叫
});

模擬模組 (Mock Modules)

import * as api from './api';

// 監聽 api.getUser
const spy = vi.spyOn(api, 'getUser');

it('應該呼叫 API', async () => {
  // 假造回傳值
  spy.mockResolvedValue({ name: 'Mock User' });

  // ... 執行測試 ...

  expect(spy).toHaveBeenCalled();
});

除錯技巧 (Debugging)

當測試失敗卻不知道為什麼時,可以使用以下技巧:

  • screen.debug(): 會將當前的 DOM 結構 (格式化後) 印在 Console 中,讓你檢查元素是否真的存在。

    render(<App />);
    screen.debug(); // 印出整個 body
    
  • logRoles: 不確定 getByRole 要用什麼 role 嗎?用這個:

    import { logRoles } from '@testing-library/react';
    // ...
    const { container } = render(<App />);
    logRoles(container); // 印出所有可用的 role 和 name
    

總結

React Testing Library 引導開發者撰寫「以使用者為中心」的測試。透過 Vitest 快速的回饋循環,以及 user-event 的真實模擬,我們可以對 React 應用程式的品質更有信心。