前端測試
測試使用者能看見和操作的內容,而非元件的建構方式。搭配 naki-tdd 進行迭代循環,使用 naki-e2e-playwright 處理完整瀏覽器流程,並透過 naki-accessibility 確保語意正確。如果專案有專屬的技能規則檔(例如 naki-myapp),請遵循該檔案的指示。
何時使用
- 需要為新的元件、鉤子或頁面撰寫測試。
- 重構未改變行為,但測試卻失敗(表示與實作耦合)。
- 測試不穩定、大量使用
waitFor,或模擬了半個應用程式。
核心規則
- 像使用者一樣查詢。 優先級:
getByRole(搭配name)、getByLabelText、getByPlaceholderText、getByText、getByDisplayValue。僅在沒有可存取的查詢方式時,才使用getByTestId。 - 使用
userEvent進行互動,而非fireEvent。使用const user = userEvent.setup()建立,並對每個呼叫使用await。 - 針對輸出做斷言:可見文字、角色、表單值、對網路端點的呼叫。永遠不要對狀態變數、鉤子內部實作、CSS 類別名稱或元件實例方法做斷言。
- 模擬網路,而非你的模組。 在 HTTP 層使用 MSW 處理器。僅對真正的外部邊界(時間、隨機、jsdom 中沒有的瀏覽器 API)才模擬模組。
- 對稍後出現的元素使用
findBy*。 保留waitFor給非查詢的斷言,且永遠不要在waitFor內部放置副作用。 - 每個測試測試一種行為,命名為一個句子:
shows an error when the server returns 500。 - 每個測試設定自己的狀態。 不依賴執行順序;在
afterEach中重設處理器、計時器和儲存空間。 - 透過
renderWithProviders輔助函式提供真實的 Provider(路由器、關閉重試功能的查詢客戶端、主題),而非模擬它們。 - 可存取性是可測試的。 失敗的
getByRole("button", { name: "Save" })通常代表一個真實的可存取性錯誤。可選擇性地為關鍵畫面加入vitest-axe檢查。 - 涵蓋率是訊號,不是目標。 涵蓋重要的分支:錯誤狀態、空狀態、權限。
查詢速查表
| 元素 | 查詢方式 |
|---|---|
| 按鈕 | getByRole("button", { name: /save/i }) |
| 連結 | getByRole("link", { name: "Docs" }) |
| 帶標籤的文字輸入框 | getByRole("textbox", { name: "Email" }) 或 getByLabelText("Email") |
| 核取方塊 | getByRole("checkbox", { name: "Remember me" }) |
| 標題 | getByRole("heading", { level: 2, name: "Alerts" }) |
| 表格列 | getAllByRole("row") 然後 within(row) |
| 對話框 | getByRole("dialog", { name: "Delete alert" }) |
| 元素不存在 | expect(queryByText("Error")).not.toBeInTheDocument() |
工作流程
- 列出行為:渲染狀態(載入、空、資料、錯誤)和互動。
- 為元件呼叫的端點加入 MSW 處理器;預設為成功。
- 每個行為撰寫一個測試,對於錯誤情況使用
server.use(...)覆蓋處理器。 - 執行
npx vitest run path/to/file.test.tsx,然後執行整個測試套件。 - 刻意讓實作失敗一次(註解掉一行),以確認測試能夠失敗。
設定
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { cleanup } from "@testing-library/react";
import { server } from "./src/test/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { server.resetHandlers(); cleanup(); });
afterAll(() => server.close());
使用 onUnhandledRequest: "error" 會讓未處理的請求明確失敗,而不是掛起。
範例
import { http, HttpResponse } from "msw";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { server } from "../test/server";
import { AlertList } from "./AlertList";
test("lists alerts and filters by severity", async () => {
server.use(http.get("/api/alerts", () => HttpResponse.json([
{ id: "a1", title: "Brute force on vpn", severity: "high" },
{ id: "a2", title: "New admin login", severity: "low" },
])));
const user = userEvent.setup();
render(<AlertList />);
expect(await screen.findByText("Brute force on vpn")).toBeInTheDocument();
await user.selectOptions(screen.getByRole("combobox", { name: "Severity" }), "high");
expect(screen.queryByText("New admin login")).not.toBeInTheDocument();
});
test("shows a retry button when loading fails", async () => {
server.use(http.get("/api/alerts", () => new HttpResponse(null, { status: 500 })));
render(<AlertList />);
expect(await screen.findByRole("button", { name: /retry/i })).toBeInTheDocument();
});
鉤子與時間
- 透過一個使用該鉤子的小元件來測試鉤子,或在沒有 UI 時使用
renderHook。 - 模擬計時器:使用
vi.useFakeTimers()和userEvent.setup({ advanceTimers: vi.advanceTimersByTime });在afterEach中使用vi.useRealTimers()恢復。 - 使用
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))凍結日期,使快照和標籤保持穩定。
檢查清單
- 查詢方式為角色或標籤為基礎。
- 所有
userEvent呼叫都已使用await。 - 使用 MSW 模擬網路;未處理的請求會失敗。
- 已涵蓋載入、錯誤和空狀態。
- 沒有對內部實作或類別名稱做斷言。
- 測試可獨立執行且不受順序影響。
- 輸出中沒有
act(...)警告。
常見錯誤
| 錯誤 | 修正方法 |
|---|---|
到處使用 getByTestId | 使用角色/標籤查詢;若元件缺少標籤則補上 |
await waitFor(() => getByText("x")) | await findByText("x") |
使用 fireEvent.change 模擬輸入 | await user.type(input, "text") |
直接模擬 useAlerts 鉤子 | 模擬 HTTP 端點 |
| 整個頁面的大型快照 | 對重要的部分進行明確的斷言 |
| 查詢客戶端啟用重試功能,導致錯誤測試緩慢 | 在測試 Provider 中設定 retry: false |
忽略 act 警告 | 使用 findBy* 等待非同步 UI 更新 |