React 測試 是做什麼的?
全面的 React 測試模式,專注於行為導向的元件測試、自訂 Hook 測試、無障礙斷言以及網路層級的模擬。
啟用時機
- 為 React 元件、自訂 Hook 或頁面編寫測試
- 為舊的、未經測試的元件增加測試覆蓋率
- 從 Enzyme 或類元件時代的模式遷移到 React Testing Library
- 為新的 React 專案設定 Vitest 或 Jest
- 在測試中模擬 HTTP 請求
- 斷言無障礙違規
- 決定哪些測試屬於 RTL、Playwright 元件測試還是完整的 E2E 測試
核心原則
測試使用者看到和操作的行為,而不是實作細節。
一個測試應該:
- 使用與生產環境相同的 Provider 來渲染元件
- 透過可存取的查詢方式(role, label)和
userEvent與元件互動 - 斷言可見的輸出和可觀察的副作用(回呼被呼叫、請求被發送)
一個測試不應該:
- 檢查元件的 state、傳遞給子元件的 props,或哪些 hooks 被呼叫
- 模擬 React 本身或框架的 hooks
- 斷言渲染次數或超出使用者影響範圍的 DOM 結構
測試函式庫選擇
| 執行器 | 使用時機 | 備註 |
|---|---|---|
| Vitest | Vite, Remix, 現代化設定 | 更快、原生 ESM、與 Jest 相容的 API |
| Jest | Next.js, CRA, 既有專案 | 許多 React 專案的預設選擇 |
| Playwright 元件測試 | 需要真實瀏覽器引擎 | 當 JSDOM 缺乏必要功能時使用 |
| Cypress 元件測試 | 需要真實瀏覽器,且已在使用 Cypress | Playwright CT 的替代方案 |
選擇一個即可。除非有明確的職責分離,否則不要在同一个倉庫中同時執行 RTL + Vitest 和 Playwright CT。
查詢優先級
React Testing Library 提供了三個層級的查詢方式 — 請由上而下使用:
- 所有人都可存取:
getByRole、getByLabelText、getByPlaceholderText、getByText、getByDisplayValue - 語意化:
getByAltText、getByTitle - 測試 ID(逃生門):
getByTestId
// 最佳
screen.getByRole("button", { name: /save/i });
// 對輸入框來說可以
screen.getByLabelText("Email");
// 最後手段
screen.getByTestId("save-btn");
變體:
getBy*— 若無匹配則拋出錯誤queryBy*— 返回null(用於「斷言不存在」)findBy*— 非同步,返回一個 Promise(用於非同步操作後出現的元素)
使用 userEvent 進行使用者互動
import userEvent from "@testing-library/user-event";
test("提交表單", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]" });
});
- 務必
awaituserEvent 的呼叫 - 每個測試呼叫一次
userEvent.setup(),並重複使用回傳的user userEvent模擬真實的瀏覽器事件序列;fireEvent則只觸發單一合成事件 — 優先使用userEvent
非同步模式
// 非同步工作後出現的元素
expect(await screen.findByText("已載入")).toBeInTheDocument();
// 副作用斷言
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// 應該消失的元素
await waitForElementToBeRemoved(() => screen.queryByText("載入中"));
永遠不要使用 setTimeout + 斷言 — 這會導致測試不穩定。請使用上述的匹配器。
使用 MSW 進行網路模擬
Mock Service Worker 在網路層級進行模擬。元件、Hooks 和 fetch 函式庫的行為都與生產環境完全相同。
設定
// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Alice" }),
),
http.post("/api/users", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
}),
];
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
設定 onUnhandledRequest: "error",讓任何未模擬的請求在測試中明確失敗 — 靜默通過比測試紅字更糟糕。
每個測試的覆寫
test("在 500 錯誤時渲染錯誤訊息", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/發生錯誤/i)).toBeInTheDocument();
});
Provider 封裝
在 test-utils.tsx 中一次性封裝所有 Provider:
// test-utils.tsx
import { render, RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions,
) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>{ui}</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>,
options,
);
}
export * from "@testing-library/react";
然後在每個測試檔案中 import { renderWithProviders, screen } from "test-utils"。
自訂 Hook 測試
import { renderHook, act } from "@testing-library/react";
test("useCounter 可以增加和減少", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
test("useCounter 可以接受初始值", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser 取得使用者資料", async () => {
// 每次測試在 wrapper 外部建立一次 QueryClient,使其可以在重新渲染時存活。
// 在 wrapper 閉包內建立它會導致每次渲染時重置快取狀態,產生不穩定的測試。
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser("1"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: "1", name: "Alice" });
});
- 將改變狀態的呼叫包裹在
act中 - 僅透過 Hook 的公開 API 進行測試
- 對於使用 context 的 Hook,請傳入
wrapper
無障礙斷言
import { axe, toHaveNoViolations } from "jest-axe"; // 或 vitest-axe
expect.extend(toHaveNoViolations);
test("UserCard 沒有無障礙違規", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});
在元件測試中對每個互動元件執行 axe。可以檢測:
- 表單輸入缺少標籤
- 無效的 ARIA 使用
- 顏色對比度不足(有限制 — JSDOM 沒有真正的 CSS 引擎,因此僅對內聯樣式有效;視覺對比度屬於 Playwright 的範疇)
- 圖片缺少替代文字
- 標題順序違規
交叉連結:skills/accessibility/SKILL.md 了解更廣泛的無障礙測試手冊。
何時不該使用快照測試
渲染輸出的快照:
- 每次樣式變更都會中斷
- 在程式碼審查時常被草率通過
- 測試的是實作細節(DOM 結構),而非行為
可接受的快照使用場景:
- 純資料序列化函式(
formatInvoice(invoice)-> 穩定字串) - 生成的設定檔(例如 webpack 設定輸出)
對於元件的視覺回歸測試,請使用 Playwright/Cypress 的螢幕截圖或 Percy/Chromatic — 真正的視覺差異比對,而非 DOM 字串。
何時使用 Playwright / Cypress
JSDOM(由 Vitest/Jest 使用)無法:
- 渲染真實佈局(flexbox, grid, viewport 查詢)
- 執行原生瀏覽器動畫、CSS 過渡效果
- 測試滾動行為、拖放、從剪貼簿貼上
- 處理 iframe、彈出視窗、下載、跨域流程
- 在受控環境中運行真實網路並支援完整的 DevTools
對於上述任何情況,請使用 Playwright 元件測試(在真實瀏覽器中測試元件)或完整的 E2E 測試。請參閱 e2e-testing skill。
決策邊界:
- Hook、純展示元件、包含邏輯的表單 -> RTL
- 佈局至關重要或使用 JSDOM 未提供的瀏覽器 API 的元件 -> Playwright CT
- 跨越多個頁面的完整使用者流程 -> Playwright/Cypress E2E
覆蓋率目標
| 層級 | 目標 |
|---|---|
| 純工具函式 | >=90% |
| 自訂 Hook | >=85% |
| 展示元件 | >=80% — 行為,而非程式碼行數 |
| 容器元件 | >=70% — 主要路徑 + 錯誤狀態 |
| 頁面 | E2E 獨立覆蓋;最少要進行冒煙測試 |
透過 vitest.config.ts / jest.config.js 設定:
// vitest.config.ts
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
}
反模式
container.querySelector("...")— 繞過無障礙查詢,讓測試在真實使用者會失敗的情況下通過- 斷言渲染次數 — 這是實作細節
jest.mock("react", ...)— 永遠不要模擬 React。請重構元件- 預設模擬子元件 — 測試的是整合,而非隔離。僅在子元件有嚴重副作用時才進行模擬
- 忽略
act()警告 — 它們標示了真實的錯誤(unmount 後的狀態更新、缺少非同步包裹) - 跨測試共享可變狀態 — 當測試順序改變時會導致不穩定
- 移除
it.skip()後通過的測試 — 你的測試並未真正斷言你認為的內容
TDD 工作流程
紅燈 -> 為下一個需求編寫失敗的測試
綠燈 -> 編寫最少的元件程式碼使其通過
重構 -> 改進元件,測試保持綠燈
重複 -> 下一個需求
對於新元件:
- 定義元件的 prop 類型和簽章
- 為最簡單的情況編寫第一個測試
- 驗證測試因正確的原因失敗
- 實作足以通過的程式碼
- 加入下一個測試案例
- 當第三個類似的測試揭示了某種模式時進行重構
測試指令
# Vitest
vitest # 監聽模式
vitest run # 單次執行
vitest run --coverage # 搭配覆蓋率
vitest run path/to/file.test.tsx # 單一檔案
# Jest
jest --watch
jest --coverage
jest path/to/file.test.tsx
# CI 模式
CI=true vitest run --coverage
相關資源
- 規則:rules/react/testing.md
- 技能:react-patterns, accessibility, e2e-testing, tdd-workflow
- 代理:
react-reviewer(在程式碼審查時審查測試品質)、tdd-guide(強制執行 TDD 流程) - 指令:
/react-test,/react-review
範例
使用 MSW 和 userEvent 進行表單提交
test("提交使用者表單並顯示成功訊息", async () => {
server.use(
http.post("/api/users", () =>
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }),
),
);
const user = userEvent.setup();
renderWithProviders(<UserForm />);
await user.type(screen.getByLabelText("姓名"), "Alice");
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /儲存/i }));
expect(await screen.findByText(/儲存成功/i)).toBeInTheDocument();
});
測試錯誤邊界
function Broken() {
throw new Error("爆炸");
}
test("錯誤邊界渲染備用 UI", () => {
// 抑制 React 對於預期拋出的 console.error 雜訊,然後恢復,
// 這樣 spy 就不會洩漏到測試之外並隱藏其他地方的真正錯誤。
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>發生錯誤</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("發生錯誤")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
測試 Suspense 邊界
test("顯示載入中然後顯示內容", async () => {
renderWithProviders(
<Suspense fallback={<div>載入中...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("載入中...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});