React 測試 是做什麼的?
全面的 React 測試模式,專注於行為驅動的元件測試、自訂 Hook 測試、無障礙斷言以及網路層模擬。
何時啟用
- 為 React 元件、自訂 Hook 或頁面撰寫測試
- 為未受測試的舊元件增加測試覆蓋率
- 從 Enzyme 或類別元件時代的模式遷移至 React Testing Library
- 為新的 React 專案設定 Vitest 或 Jest
- 在測試中模擬 HTTP 請求
- 斷言無障礙違規
- 決定哪些測試應屬於 RTL、Playwright Component Testing 或完整的 E2E
核心原則
測試使用者所見與所為,而非實作細節。
一個測試應該:
- 使用與正式環境相同的 Provider 渲染元件
- 透過可存取的查詢(角色、標籤)及
userEvent進行互動 - 斷言可見的輸出與可觀察的 side effect(回呼函式被呼叫、請求已發送)
一個測試不應該:
- 檢查元件狀態、傳遞給子元件的 Props 或被呼叫的 Hook
- 模擬 React 本身或框架 Hook
- 斷言渲染次數或超出影響使用者範圍的 DOM 結構
函式庫選擇
| 執行器 | 使用時機 | 備註 |
|---|---|---|
| Vitest | Vite、Remix、現代專案設定 | 更快、原生 ESM、與 Jest 相容的 API |
| Jest | Next.js、CRA、既有專案 | 許多 React 專案的預設選項 |
| Playwright Component Testing | 需要真實瀏覽器引擎 | 當 JSDOM 缺少必要功能時使用 |
| Cypress Component Testing | 真實瀏覽器,且已在使用 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("submits the form", 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("Loaded")).toBeInTheDocument();
// Side effect 斷言
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
// 應該消失的元素
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
切勿使用 setTimeout 加上斷言——這會造成不穩定的測試。請使用上述的匹配器。
使用 MSW 進行網路模擬
Mock Service Worker 在網路層進行模擬。元件、Hook 和 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("renders error on 500", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
);
render(<UserPage id="1" />);
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});
Provider 包裝
將 Provider 包裝在一個 test-utils.tsx 中:
// 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 increments and decrements", () => {
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 accepts initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test("useUser fetches user data", 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 has no a11y violations", async () => {
const { container } = render(<UserCard user={mockUser} />);
expect(await axe(container)).toHaveNoViolations();
});
在每個互動元件的元件測試中執行 axe。它可檢測:
- 表單輸入缺少標籤
- 無效的 ARIA 使用
- 色彩對比不足(有限——JSDOM 沒有真正的 CSS 引擎,因此僅適用於行內樣式;視覺對比應屬於 Playwright)
- 圖片缺少替代文字
- 標題順序違規
交叉連結:skills/accessibility/SKILL.md 參閱更廣泛的無障礙測試手冊。
何時不該使用快照測試
渲染輸出的快照:
- 每次樣式變更都會中斷
- 在 code review 時容易被橡皮圖章式地通過
- 測試的是實作細節(DOM 結構),而非行為
可接受的快照用途:
- 純資料序列化函式(
formatInvoice(invoice)-> 穩定的字串) - 產生的設定檔(例如 webpack 設定輸出)
若要進行元件的視覺回歸測試,請使用 Playwright/Cypress 螢幕截圖或 Percy/Chromatic——實際的視覺差異比對,而非 DOM 字串。
何時使用 Playwright / Cypress
JSDOM(由 Vitest/Jest 使用)無法:
- 渲染真實版面(flexbox、grid、viewport 查詢)
- 執行原生瀏覽器動畫、CSS 轉場效果
- 測試滾動行為、拖放、從剪貼簿貼上
- 處理 iframe、彈出視窗、下載、跨來源流程
- 在受控環境中執行真實網路並擁有完整的 DevTools 支援
對於上述任何情況,請使用 Playwright Component Testing(在真實瀏覽器中測試元件)或完整的 E2E。請參閱 e2e-testing 技能。
決策邊界:
- 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。請重構元件- 預設情況下模擬子元件 — 應該測試整合,而非隔離。僅在子元件有大量 side effect 時才模擬
- 忽略
act()警告 — 它們標示了真正的錯誤(解除掛載後更新狀態、缺少非同步包裝) - 跨測試共用可變狀態 — 當測試順序改變時會造成不穩定
- 移除
it.skip()後仍可通過的測試 — 你的測試實際上並未如你所想的那樣進行斷言
TDD 工作流程
RED -> 為下一個需求撰寫會失敗的測試
GREEN -> 撰寫最簡的元件程式碼使其通過
REFACTOR -> 改善元件,測試保持綠色
REPEAT -> 下一個需求
對於新元件:
- 定義元件的 props 類型和簽章
- 為最簡單的情境撰寫第一個測試
- 確認它因為正確的原因而失敗
- 實作剛好足夠通過的內容
- 加入下一個測試案例
- 當第三個類似的測試顯露出模式時進行重構
測試指令
# 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(在 code review 期間審查測試品質)、tdd-guide(強制執行 TDD 流程) - 指令:
/react-test、/react-review
範例
使用 MSW 和 userEvent 進行表單提交
test("submits user form and shows success", 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("Name"), "Alice");
await user.type(screen.getByLabelText("Email"), "[email protected]");
await user.click(screen.getByRole("button", { name: /save/i }));
expect(await screen.findByText(/saved successfully/i)).toBeInTheDocument();
});
測試錯誤邊界
function Broken() {
throw new Error("boom");
}
test("error boundary renders fallback", () => {
// 抑制 React 對於預期拋出錯誤的 console.error 雜訊,然後還原,
// 以免間諜洩漏到其他測試並隱藏真正的錯誤。
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Broken />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
} finally {
errorSpy.mockRestore();
}
});
測試 Suspense 邊界
test("shows loading then content", async () => {
renderWithProviders(
<Suspense fallback={<div>Loading...</div>}>
<UserDetail id="1" />
</Suspense>,
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(await screen.findByText("Alice")).toBeInTheDocument();
});