EP.26React 實戰系列

React 測試實戰:
Vitest + Testing Library

unit test、user-event 互動、mock API、coverage — 讓你的元件可測試、可維護、讓 CI 不再擋你路

Joseph Chen 2026 17 min read Vitest · Testing Library · MSW

Section 1:為什麼要寫測試(以及為什麼大家都不寫)

先承認現實。大多數工程師不寫測試,因為三個非常合理的理由:

  1. 1感覺很費時間 — 「我直接跑起來看一下不就好了?」
  2. 2不知道要測什麼 — 測元件的 className?還是測業務邏輯?邊界在哪?
  3. 3測試本身很難維護 — 改了一個 prop 名稱,20 個測試全部失敗

這些都是真實的痛點,但有個更殘酷的真相: 你其實每次改完 code 都在「手動測試」——只是你的手動測試每次都要重做一次。

核心洞察

寫測試是把「手動測試」的步驟自動化。你打開瀏覽器、點幾下、確認畫面——這些步驟可以寫成程式碼, 讓電腦幫你每次 commit 前都執行一遍。

不寫測試的代價,在專案成長後才會真正浮現:

蝴蝶效應

修了一個 bug,結果壞了三個功能。等 QA 或用戶回報才發現。

重構恐懼症

不敢重構,因為不知道會不會壞。技術債越堆越高。

新人地雷區

新人不敢改舊程式碼,只能用複製貼上繞過去。

有了測試之後,重構變得安全,新人可以放心修改,CI 自動幫你把關——這才是測試真正的價值。

Section 2:測試工具選擇

2026 年 React 測試的最佳組合,四個工具各司其職:

Vitest

比 Jest 快 10x,零配置,原生 ESM 支援。Vite 生態系的測試框架, 在 Next.js 專案中也可以獨立使用。

@testing-library/react

模擬用戶行為,不測實作細節。核心哲學:「像用戶一樣使用元件,而不是測試 state 的值」。

@testing-library/user-event

fireEvent 更真實的事件模擬。 模擬完整的瀏覽器事件序列,包含 focus、keydown、input、keyup 等。

msw (Mock Service Worker)

攔截 fetch 請求,在 Node 環境中模擬 API。測試 API 整合, 不需要真實 server,也不需要 jest.mock。

安裝所有依賴:

bash
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom msw

vitest.config.ts — 測試框架設定:

typescript
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',     // 模擬瀏覽器 DOM 環境
    globals: true,            // 不需要手動 import describe/test/expect
    setupFiles: ['./src/test/setup.ts'],
  },
});

src/test/setup.ts — 全域 setup:

typescript
import '@testing-library/jest-dom';
// 讓 expect() 可以使用 .toBeInTheDocument()、.toBeDisabled() 等 DOM matchers

為什麼不用 Jest? Vitest 和 Jest API 幾乎完全相同(所以遷移成本極低), 但 Vitest 利用 Vite 的 HMR 機制,watch mode 下只重跑受影響的測試, 大型專案中速度差異非常明顯。

Section 3:第一個測試 — 渲染測試

Testing Library 的核心哲學

像用戶一樣測試,而不是測試實作細節。 不要測 state 的值、不要測 CSS class name——測「用戶看到什麼」、「用戶可以做什麼」。

UserCard 元件與對應測試:

tsx
// components/UserCard.tsx
type User = {
  id: string;
  name: string;
  email: string;
  isAdmin: boolean;
};

function UserCard({ user }: { user: User }) {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      {user.isAdmin && <span>管理員</span>}
    </div>
  );
}

export default UserCard;

UserCard.test.tsx:

tsx
// components/UserCard.test.tsx
import { render, screen } from '@testing-library/react';
import UserCard from './UserCard';

const mockUser = {
  id: '1',
  name: 'Joseph Chen',
  email: 'joseph@example.com',
  isAdmin: false,
};

describe('UserCard', () => {
  test('顯示用戶名稱和 Email', () => {
    render(<UserCard user={mockUser} />);

    expect(screen.getByText('Joseph Chen')).toBeInTheDocument();
    expect(screen.getByText('joseph@example.com')).toBeInTheDocument();
  });

  test('管理員標籤只在 isAdmin 時顯示', () => {
    render(<UserCard user={{ ...mockUser, isAdmin: true }} />);
    expect(screen.getByText('管理員')).toBeInTheDocument();
  });

  test('非管理員不顯示管理員標籤', () => {
    render(<UserCard user={mockUser} />);
    // queryByText 找不到時回傳 null,不會拋錯
    expect(screen.queryByText('管理員')).not.toBeInTheDocument();
  });
});

常用 Query 速查表:

Query用途找不到時
getBy*找單一元素(確定存在)拋出錯誤
queryBy*找單一元素(可能不存在)回傳 null
findBy*等待非同步出現拋出錯誤
getAllBy*找多個元素拋出錯誤

常見搭配:getByTextgetByRolegetByPlaceholderTextgetByTestId。 優先使用 getByRole,最接近無障礙語義。

Section 4:互動測試 — userEvent

userEventfireEvent 更真實: 它會觸發完整的事件序列(hover → focus → keydown → input → keyup → blur), 更貼近真實瀏覽器行為,也更容易發現潛在問題。

Counter 元件與互動測試:

tsx
// components/Counter.tsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <span data-testid="count">{count}</span>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <button onClick={() => setCount(c => c - 1)} disabled={count === 0}>
        -1
      </button>
    </div>
  );
}

export default Counter;

Counter.test.tsx — 點擊與狀態驗證:

tsx
// components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter', () => {
  test('點擊 +1 按鈕增加計數', async () => {
    // 必須呼叫 userEvent.setup() 初始化
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText('+1'));
    expect(screen.getByTestId('count')).toHaveTextContent('1');

    await user.click(screen.getByText('+1'));
    expect(screen.getByTestId('count')).toHaveTextContent('2');
  });

  test('count 為 0 時 -1 按鈕 disabled', () => {
    render(<Counter />);
    // 不需要互動,直接驗證初始狀態
    expect(screen.getByText('-1')).toBeDisabled();
  });

  test('點擊一次 +1 後 -1 按鈕解除 disabled', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText('+1'));
    expect(screen.getByText('-1')).not.toBeDisabled();
  });
});

表單輸入測試 — user.type() 模擬鍵盤輸入:

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

describe('LoginForm', () => {
  test('輸入資料並送出表單', async () => {
    const user = userEvent.setup();
    render(<LoginForm />);

    // user.type 模擬逐鍵輸入(包含 focus、keystroke 事件)
    await user.type(
      screen.getByPlaceholderText('Email'),
      'test@example.com'
    );
    await user.type(
      screen.getByPlaceholderText('密碼'),
      'password123'
    );

    // 使用 getByRole 找按鈕,更接近無障礙語義
    await user.click(screen.getByRole('button', { name: '登入' }));

    expect(screen.getByText('登入成功')).toBeInTheDocument();
  });

  test('密碼不足 8 字元顯示錯誤提示', async () => {
    const user = userEvent.setup();
    render(<LoginForm />);

    await user.type(screen.getByPlaceholderText('密碼'), '123');
    await user.click(screen.getByRole('button', { name: '登入' }));

    expect(screen.getByText('密碼至少需要 8 個字元')).toBeInTheDocument();
  });

  test('清除輸入框', async () => {
    const user = userEvent.setup();
    render(<LoginForm />);

    const emailInput = screen.getByPlaceholderText('Email');
    await user.type(emailInput, 'test@example.com');

    // 清除再重新輸入
    await user.clear(emailInput);
    await user.type(emailInput, 'new@example.com');

    expect(emailInput).toHaveValue('new@example.com');
  });
});

userEvent vs fireEvent: 如果你用 fireEvent.click(), 它只觸發 click 事件。真實的點擊還包含 pointerover、pointerenter、mouseover、mouseenter、 pointermove、mousemove、pointerdown、mousedown、focus、pointerup、mouseup、click。 userEvent 會完整模擬這個序列,讓測試更可靠。

Section 5:Mock API — msw

測試「有 API 請求的元件」是最常見的需求。msw 的做法不是 mock 函數, 而是在 Node 層攔截真實的 fetch 請求,讓元件完全不需要知道自己在被測試。

src/test/handlers.ts — 定義 API mock:

typescript
// src/test/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // GET /api/users — 回傳用戶列表
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: '1', name: 'Joseph Chen' },
      { id: '2', name: 'Jane Doe' },
    ]);
  }),

  // POST /api/login — 模擬登入,支援成功與失敗情境
  http.post('/api/login', async ({ request }) => {
    const { email, password } = await request.json() as {
      email: string;
      password: string;
    };

    if (password === 'wrong') {
      return HttpResponse.json(
        { error: '密碼錯誤' },
        { status: 401 }
      );
    }

    return HttpResponse.json({ token: 'fake-jwt-token' });
  }),

  // DELETE /api/users/:id
  http.delete('/api/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({ deleted: id });
  }),
];

src/test/setup.ts — 啟動 msw server:

typescript
// src/test/setup.ts
import '@testing-library/jest-dom';
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

// 在 Node 環境啟動 msw(不是 Service Worker 模式)
const server = setupServer(...handlers);

// 每個測試檔執行前啟動 server
beforeAll(() => server.listen());

// 每個 test 後重置 handler(避免測試間互相影響)
afterEach(() => server.resetHandlers());

// 所有測試結束後關閉 server
afterAll(() => server.close());

export { server };

UserList.test.tsx — 測試 API 呼叫:

typescript
// components/UserList.test.tsx
import { render, screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/setup';
import UserList from './UserList';

describe('UserList', () => {
  test('成功載入並顯示用戶列表', async () => {
    render(<UserList />);

    // 剛渲染時顯示 loading 狀態
    expect(screen.getByText('載入中...')).toBeInTheDocument();

    // findBy* 會等待非同步資料出現(預設超時 1000ms)
    const josephElement = await screen.findByText('Joseph Chen');
    expect(josephElement).toBeInTheDocument();
    expect(screen.getByText('Jane Doe')).toBeInTheDocument();
  });

  test('API 失敗時顯示錯誤訊息', async () => {
    // 在這個 test 中暫時覆蓋 handler
    // afterEach 會自動還原,不影響其他測試
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json(
          { error: 'Server Error' },
          { status: 500 }
        );
      })
    );

    render(<UserList />);
    expect(await screen.findByText('載入失敗,請重試')).toBeInTheDocument();
  });

  test('點擊刪除後用戶從列表消失', async () => {
    const user = userEvent.setup();
    render(<UserList />);

    await screen.findByText('Joseph Chen');

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

    // 刪除後 Joseph Chen 應該消失
    expect(screen.queryByText('Joseph Chen')).not.toBeInTheDocument();
  });
});

Section 6:Custom Hook 的測試

Hook 不能直接呼叫,需要用renderHook包裝在一個虛擬元件中執行。 修改 Hook 狀態的操作需要用act()包裝,確保 React 的 re-render 完成後再驗證結果。

useCounter Hook 測試:

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

test('useCounter 初始值為 0', () => {
  const { result } = renderHook(() => useCounter());
  expect(result.current.count).toBe(0);
});

test('increment 增加計數', () => {
  const { result } = renderHook(() => useCounter());

  // 修改 hook 狀態必須用 act 包裝
  act(() => {
    result.current.increment();
  });

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

test('decrement 不低於 0', () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.decrement();
  });

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

test('reset 回到初始值', () => {
  const { result } = renderHook(() => useCounter(10));

  act(() => {
    result.current.increment();
    result.current.increment();
  });

  expect(result.current.count).toBe(12);

  act(() => {
    result.current.reset();
  });

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

useLocalStorage Hook 測試 — 需要 mock localStorage:

typescript
// hooks/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';

// 每個測試前清空 localStorage
beforeEach(() => {
  localStorage.clear();
});

test('useLocalStorage 無預存值時使用 defaultValue', () => {
  const { result } = renderHook(() => useLocalStorage('theme', 'light'));
  expect(result.current[0]).toBe('light');
});

test('useLocalStorage 從 localStorage 讀取已存在的值', () => {
  // 先設定初始值(注意:值需要是 JSON 字串)
  localStorage.setItem('theme', JSON.stringify('dark'));

  const { result } = renderHook(() => useLocalStorage('theme', 'light'));
  expect(result.current[0]).toBe('dark');
});

test('setValue 更新值並同步到 localStorage', () => {
  const { result } = renderHook(() => useLocalStorage('lang', 'zh'));

  act(() => {
    result.current[1]('en');
  });

  expect(result.current[0]).toBe('en');
  expect(JSON.parse(localStorage.getItem('lang')!)).toBe('en');
});

test('帶有 initializer 的 hook 應傳入初始值', () => {
  const { result } = renderHook(() => useCounter(5));
  expect(result.current.count).toBe(5);
});

什麼時候用 renderHook vs render? 如果 Hook 的邏輯可以獨立測試(不依賴 UI),用 renderHook。 如果你想測試「Hook 和 UI 的整合行為」,直接 render一個使用該 Hook 的元件就好。

Section 7:Coverage 與 CI 整合

Coverage 報告告訴你哪些程式碼「被測試跑到過」。 它是輔助工具,不是目標——100% coverage 不等於沒有 bug。

常用指令:

bash
# 啟動 watch mode(開發時使用)
npx vitest

# 執行一次並產生 coverage 報告(需要安裝 @vitest/coverage-v8)
npx vitest --coverage

# CI 模式:執行一次,不進 watch mode,失敗時回傳非零 exit code
npx vitest run

# 只跑符合條件的測試
npx vitest run --reporter=verbose UserCard

package.json scripts 建議配置:

json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

< 60%

危險

核心業務邏輯可能完全沒有測試保護

80%

合理目標

重要路徑有測試,非核心 UI 可以跳過

100%

不是目標

維護成本可能超過帶來的安全感

Coverage 的優先順序

一定要測:業務邏輯(計算、驗證、狀態機)
一定要測:邊界條件(空值、最大值、異常輸入)
一定要測:錯誤處理路徑(API 失敗、網路斷線)
可以跳過:純展示元件、第三方函式庫的行為

GitHub Actions CI 整合 — 只需加 3 行:

yaml
# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      # 加入這三行,測試失敗時 CI 自動擋住 merge
      - name: Run tests
        run: npm run test:run

      # 選配:上傳 coverage 報告到 Codecov
      - name: Upload coverage
        uses: codecov/codecov-action@v4

CI 的真正價值:不是「測試本身」,而是「強制執行」。 沒有 CI,測試可以被跳過;有了 CI,每個 PR 在 merge 前都必須通過測試。 這是讓測試文化在團隊中落地的關鍵機制。

Tags

Vitest
Testing Library
MSW
Unit Test
React Testing
CI/CD