FE Day 15 測試:Vitest 與元件測試
執行需求:CPU 可跑。今天是系列的第十五篇。前十四天我們從環境、TypeScript、React 元件、Hooks、Context、表單到 TanStack Query 一路往上疊,寫了不少「能跑」的程式碼。但程式碼能跑跟「改完不會壞掉」是兩件事——後者需要測試。今天要介紹前端測試的事實標準——Vitest 3.x 與 React Testing Library,把前幾天寫的 Hook、表單、API 呼叫全部加上單元測試。我們會從最基本的「斷言一個數字」開始,逐步演練到「測試自訂 Hook」、「測試表單送出」、「測試 TanStack Query 元件」、「mock fetch」。整篇範例都在本機 CPU 跑得起來,不依賴雲端服務,跑完你會拿到一份可以直接 pnpm test 跑的測試套件。
引言
寫後端的我們對測試並不陌生:pytest、unittest、fixture、mock、coverage——這些是後端的基本功。前端的測試觀念類似,但工具有差異:Vitest 是「Vite 生態的測試執行器」,速度比 Jest 快很多、與 Vite 設定共用;React Testing Library(RTL)是「以使用者觀點測試元件」的函式庫,鼓勵你測「使用者會做什麼」而不是「元件內部狀態」。兩者結合起來就是 React 專案最常見的測試組合。
前端測試的價值不只是「驗證邏輯正確」,更重要的是「讓改 code 變得安全」。當元件長大、邏輯變複雜,一個小修改可能影響到整個應用。有測試的話,改壞了 CI 會跳出來擋下;沒測試的話,使用者會跳出來擋你。前端測試還有一個獨特價值:能在「不啟動瀏覽器」的情況下驗證元件,這對 CI 環境特別有用。
今天要回答五個問題:第一,Vitest 3.x 怎麼設定?第二,怎麼用 React Testing Library 測元件?第三,怎麼用 renderHook 測自訂 Hook?第四,怎麼 mock fetch 與 localStorage?第五,怎麼測 TanStack Query 元件?我們會給 useToggle、useDebounce、BookingForm、BookingList 寫完整測試。整篇閱讀時間約 35 分鐘,動手做大約 45 分鐘。
安裝 Vitest 與 Testing Library
在既有 Vite 專案裝測試套件:
# 裝 Vitest 3.x 與相關套件
pnpm add -D vitest@^3 @vitest/coverage-v8 jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
幾個套件的角色分工:vitest 是測試執行器;jsdom 提供瀏覽器環境模擬(讓 document、window 可用);@testing-library/react 提供 render、screen 等工具;@testing-library/jest-dom 提供 toBeInTheDocument 等自訂斷言;@testing-library/user-event 模擬真實使用者操作(比 fireEvent 更貼近真實)。
Vitest 設定檔
Vitest 可以直接讀 Vite 的設定,但測試需要 jsdom 環境,所以額外寫一份設定:
// vitest.config.ts
// Vitest 設定:沿用 Vite 設定,但測試環境改用 jsdom
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom", // 瀏覽器環境(讓 window、document 可用)
globals: true, // 啟用 describe / it / expect 等全域 API
setupFiles: ["./src/test/setup.ts"], // 共用設定:引入 jest-dom 等
css: false, // 測試不處理 CSS(避免 Tailwind className 報錯)
},
});
這份設定把 Vite 的 React plugin 帶進來(讓測試能處理 .tsx)、測試環境換成 jsdom、並引入一份共用的 setup 檔。
// src/test/setup.ts
// 測試共用設定:引入 jest-dom 的自訂斷言
import "@testing-library/jest-dom/vitest";
setup 檔的內容很簡單——只是引入 jest-dom 提供的自訂斷言。這樣在測試裡就能用 expect(button).toBeInTheDocument() 而不是 expect(button).toBeTruthy(),語意更清楚。
第一個測試:斷言一個純函式
先從最簡單的純函式測試開始,建立對 Vitest 的基本印象:
// src/utils/format.test.ts
// 測試一個簡單的格式化函式
import { describe, it, expect } from "vitest";
import { formatPrice } from "./format";
describe("formatPrice", () => {
it("把數字格式化為千分位 + NT$ 前綴", () => {
expect(formatPrice(1234)).toBe("NT$1,234");
});
it("處理零", () => {
expect(formatPrice(0)).toBe("NT$0");
});
it("處理負數", () => {
expect(formatPrice(-500)).toBe("NT$-500");
});
});
Vitest 的核心 API 與 Jest 幾乎相同:describe 把測試分群、it 定義單一測試、expect 做斷言。常見的 matcher 有 toBe(嚴格相等)、toEqual(深層相等)、toContain(陣列/字串包含)、toBeNull、toBeUndefined、toBeTruthy、toThrow(拋出例外)等。
// src/utils/format.ts
// 對應的實作
export function formatPrice(n: number): string {
return `NT$${n.toLocaleString("en-US")}`;
}
執行測試:pnpm vitest run。Vitest 會列出所有測試檔、跑過每一個 it 區塊、最後印出通過/失敗統計。預設 Vitest 進入 watch 模式(檔案變動自動重跑),加 run 參數才跑一次後退出,適合 CI 使用。
測試元件:用 React Testing Library
React Testing Library 的核心是 render:把元件渲染到 jsdom 裡、回傳 DOM 與工具函式。最基本的測試是「確認元件有渲染某段文字」。
// src/components/Greeting.test.tsx
// 測試一個最簡單的元件
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Greeting } from "./Greeting";
describe("Greeting", () => {
it("顯示傳入的姓名", () => {
render(<Greeting name="小明" />);
// getByText:找出內容完全相符的元素,找不到會拋錯
expect(screen.getByText("Hello, 小明")).toBeInTheDocument();
});
});
render 把元件掛到 jsdom 的 document.body 裡;screen 提供查詢 DOM 的函式(getByText、getByRole、getByLabelText 等)。getByText 找不到會拋錯(測試失敗),queryByText 找不到會回傳 null(測試可以繼續)。React Testing Library 的設計哲學是「測試使用者會看到的東西」——使用者看到的不是 class、不是 data-testid,而是文字、按鈕、標籤。所以優先用 getByRole、getByLabelText,data-testid 留到最後才用。
下面是測試「使用者互動」的範例:
// src/components/Counter.test.tsx
// 測試一個計數器元件:點按鈕會加 1
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
describe("Counter", () => {
it("點按鈕會讓數字加 1", async () => {
const user = userEvent.setup();
render(<Counter />);
// 初始數字是 0
expect(screen.getByText("目前:0")).toBeInTheDocument();
// 模擬使用者點按鈕
await user.click(screen.getByRole("button", { name: "+1" }));
// 數字變成 1
expect(screen.getByText("目前:1")).toBeInTheDocument();
});
});
userEvent.setup() 建立一個模擬使用者物件;user.click(element) 模擬點擊。userEvent 比 fireEvent 更貼近真實——它會依序觸發 pointerdown、pointerup、click 等事件,而不是只觸發 click。async/await 是必要的,因為 userEvent 的 API 是 async(要等待瀏覽器內部事件流程模擬)。
測試自訂 Hook:renderHook
Day 10 教的自訂 Hook 怎麼測?React Testing Library 提供 renderHook,把 Hook 渲染到測試元件裡、回傳目前的值與 rerender 函式。
// src/hooks/useToggle.test.ts
// 測試 useToggle:呼叫 toggle 後 value 會反過來
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useToggle } from "./useToggle";
describe("useToggle", () => {
it("預設值是 false", () => {
const { result } = renderHook(() => useToggle());
expect(result.current[0]).toBe(false);
});
it("呼叫 toggle 後值反過來", () => {
const { result } = renderHook(() => useToggle());
act(() => {
result.current[1](); // 呼叫 toggle
});
expect(result.current[0]).toBe(true);
});
it("呼叫 setValue 可以強制設值", () => {
const { result } = renderHook(() => useToggle(false));
act(() => {
result.current[2](true); // setValue(true)
});
expect(result.current[0]).toBe(true);
});
});
renderHook 回傳的 result.current 是 Hook 回傳的最新值(在這個範例是 [value, toggle, setValue] 元組)。act 用來包裝「會觸發 React 狀態更新」的動作,確保所有更新都被 React 處理完畢後再繼續斷言。沒有用 act 包裝可能會出現「狀態沒更新就斷言」的競態問題。
測試 useDebounce 需要 fake timers:
// src/hooks/useDebounce.test.ts
// 測試 useDebounce:300ms 後才更新值
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useDebounce } from "./useDebounce";
describe("useDebounce", () => {
beforeEach(() => vi.useFakeTimers()); // 啟用 fake timers
afterEach(() => vi.useRealTimers()); // 測試完恢復真實 timers
it("300ms 後才更新值", () => {
const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
initialProps: { value: "hello" },
});
expect(result.current).toBe("hello");
// 改 value,hook 還沒更新
rerender({ value: "world" });
expect(result.current).toBe("hello");
// 快轉 300ms
act(() => {
vi.advanceTimersByTime(300);
});
expect(result.current).toBe("world");
});
});
Fake timers 讓我們可以「快轉時間」而不真的等 300 毫秒,這對測試任何有 setTimeout、setInterval 的程式碼非常有用。vi.advanceTimersByTime(300) 把時間快轉 300 毫秒,所有在這段時間內應該被觸發的 callback 都會立刻執行。
Mock fetch:測試 API 呼叫
測試 API 呼叫時不能真的打 HTTP——一是會依賴網路、二是會打到真實伺服器。Vitest 提供 vi.spyOn(global, "fetch") 或直接替換 globalThis.fetch 來 mock。
// src/hooks/useBookings.test.tsx
// 測試 useBookings:mock fetch 並用 QueryClientProvider 包起來
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useBookings } from "./useBookings";
// 建立一個全新的 QueryClient(避免測試之間共用快取)
function createWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } }, // 測試不要重試,失敗就失敗
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
}
describe("useBookings", () => {
beforeEach(() => {
vi.restoreAllMocks(); // 每個測試前清掉 mock
});
it("成功抓取預約清單", async () => {
const mockData = [
{ id: "1", name: "王小明", date: "2026-04-01", time: "morning" as const },
];
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => mockData,
}));
const { result } = renderHook(() => useBookings(), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockData);
});
it("API 失敗時回傳錯誤", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({}),
}));
const { result } = renderHook(() => useBookings(), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error?.message).toContain("HTTP 500");
});
});
這個測試展示了測試 TanStack Query 的標準模式:用 QueryClientProvider 包起來(透過 wrapper 選項)、vi.stubGlobal("fetch", ...) 替換全域 fetch、waitFor 等非同步操作完成。retry: false 是關鍵——測試裡不要讓 TanStack Query 自動重試,否則一個失敗的測試會拖很久才結束。
測試表單:模擬使用者輸入
Day 12 的 BookingFormRHF 怎麼測?重點是「模擬使用者輸入、觸發驗證、呼叫 API」。表單元件的測試有三個關鍵場景:欄位未填時顯示驗證錯誤、欄位填好後正確呼叫 onSubmit、API 失敗時保留輸入並顯示錯誤訊息。下面是前兩個的測試範例,第三個會留給讀者當練習。
// src/components/BookingFormRHF.test.tsx
// 測試 React Hook Form 表單:輸入、驗證、送出
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BookingFormRHF } from "./BookingFormRHF";
describe("BookingFormRHF", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("欄位未填時送出會顯示驗證錯誤", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<BookingFormRHF onSubmit={onSubmit} />);
// 直接按送出,所有欄位都是空的
await user.click(screen.getByRole("button", { name: "建立預約" }));
// 驗證錯誤應該出現
await waitFor(() => {
expect(screen.getByText("姓名至少 2 個字")).toBeInTheDocument();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it("欄位填好後送出會呼叫 onSubmit", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<BookingFormRHF onSubmit={onSubmit} />);
// 填欄位
await user.type(screen.getByLabelText("姓名"), "王小明");
await user.type(screen.getByLabelText("Email"), "wang@example.com");
await user.type(screen.getByLabelText("日期"), "2026-04-01");
await user.selectOptions(screen.getByLabelText("時段"), "morning");
// 送出
await user.click(screen.getByRole("button", { name: "建立預約" }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: "王小明",
email: "wang@example.com",
date: "2026-04-01",
time: "morning",
note: "",
});
});
});
});
這個測試展示三個重要技巧:第一,getByLabelText("姓名") 用 label 文字找到 input,這也是使用者實際操作的方式;第二,user.type 模擬「一字一字輸入」,會依序觸發 keydown、keypress、input 等事件;第三,waitFor 包裝斷言,會不斷重試直到斷言通過或超時,適合處理「送出後需要時間處理」的非同步情境。
常見錯誤與踩雷
第一個踩雷是「忘記用 act 包裝狀態更新」。act 確保 React 把所有更新處理完才繼續。如果你看到「not wrapped in act」警告,就是在提醒你「這個狀態更新沒被 act 包起來」。修法是用 act(() => { ... }) 或呼叫 async 版本的 act(async () => { ... })。在 React Testing Library 裡,render、userEvent、fireEvent 等函式內部已經呼叫了 act,所以「從測試直接呼叫 setState」才需要手動包。
第二個踩雷之前提過,再展開講:「測試之間共用 QueryClient」。如果你在 describe 外建立 QueryClient,所有測試會共用同一份快取,導致測試順序影響結果。例如 test A 抓了 bookings,test B 預期 bookings 是空陣列但實際拿到 test A 的資料。修法是用工廠函式(createWrapper())在每個測試建立新的 client,並把 retry 設為 false。這是測試隔離的基本紀律。
第三個踩雷之前也提過:「忘記清掉 mock」。vi.fn() 與 vi.stubGlobal 會污染全域狀態,沒清掉的話下一個測試會用到舊的 mock。修法是在 beforeEach 呼叫 vi.restoreAllMocks() 與 vi.unstubAllGlobals()。如果用 MSW(Mock Service Worker)模擬 HTTP 層,要在 afterEach 呼叫 server.resetHandlers() 重置 handler 狀態。
第四個踩雷之前也提過:「waitFor 沒給 expect」。waitFor 內部必須有 expect,否則它不知道什麼時候該停止。waitFor 預設 timeout 是 1000 毫秒,超過就拋錯。實務上如果你看到「waitFor timed out」訊息,通常是斷言條件永遠不會成立——可能是 selector 寫錯、可能是元件根本沒渲染那段文字、可能是 mock 沒生效。
第五個踩雷是「用 data-testid 太多」。React Testing Library 鼓勵「用使用者看得到的屬性找元素」,過度依賴 data-testid 會讓測試跟實作綁太緊。例如你把按鈕的 className 從 btn 改成 button-primary,用 getByRole 的測試不會壞,但用 getByTestId("submit-btn") 的測試會壞。優先順序:getByRole → getByLabelText → getByPlaceholderText → getByText → getByDisplayValue → getByAltText → getByTitle → getByTestId。data-testid 留到「真的沒有其他方法」才用。
第二個踩雷是「測試之間共用 QueryClient」。如果你在 describe 外建立 QueryClient,所有測試會共用同一份快取,導致測試順序影響結果。修法是用工廠函式(createWrapper())在每個測試建立新的 client。
第三個踩雷是「忘記清掉 mock」。vi.fn() 與 vi.stubGlobal 會污染全域狀態,沒清掉的話下一個測試會用到舊的 mock。修法是在 beforeEach 呼叫 vi.restoreAllMocks() 與 vi.unstubAllGlobals()。
第四個踩雷是「waitFor 沒給斷言而給副作用」。waitFor 內部必須有 expect,否則它不知道什麼時候該停止。例如下面寫法會無限迴圈:
// 反例:waitFor 內沒 expect,會無限等待
await waitFor(() => {
console.log("loading…");
});
// 正例:waitFor 內有 expect,達到條件就停止
await waitFor(() => {
expect(screen.getByText("完成")).toBeInTheDocument();
});
第五個踩雷是「用 data-testid 太多」。React Testing Library 鼓勵「用使用者看得到的屬性找元素」,過度依賴 data-testid 會讓測試跟實作綁太緊。優先順序:getByRole → getByLabelText → getByPlaceholderText → getByText → getByDisplayValue → getByAltText → getByTitle → getByTestId。
效能與實務提醒
Vitest 的執行速度是它最大的優勢。對中型專案(200+ 測試),Vitest 通常 5–10 秒跑完,Jest 需要 30–60 秒。原因是 Vitest 用 esbuild 做轉譯、平行跑測試、共用 Vite 的 module graph。如果你的測試跑得慢,先檢查是不是有「在 describe 頂層做昂貴的初始化」(應該放進 beforeEach 或工廠函式)。
另一個實務建議是「寫測試金字塔」。底層是大量單元測試(Hook、純函式),中層是少數元件測試(render + 互動),上層是少量 E2E 測試(Playwright,Day 27 會教)。單元測試跑得快、除錯容易;E2E 測試跑得慢、容易 flaky。比例建議:70% 單元、20% 元件、10% E2E。為什麼是這個比例?因為單元測試的「投資報酬率」最高:寫 1 小時能保護 100 個迴歸場景;E2E 測試則要花 3 小時保護 1 個場景。把資源集中在金字塔底層,長期的維護成本最低。
另一個實務提醒是「不要為了 100% 覆蓋率而硬寫測試」。Coverage 是一個指標,不是目標。如果某個元件只是 這種純展示,硬寫測試只會讓測試程式碼比元件還長。判斷原則:「這段程式碼有邏輯嗎?有,就該測」。純展示、無 props、無條件分支的元件可以跳過測試。
Coverage 是另一個常見指標。Vitest 內建 @vitest/coverage-v8,跑 pnpm vitest run --coverage 會產出覆蓋率報告。實務上不用追求 100%,重點是「核心邏輯(Hook、reducer、API 呼叫)覆蓋率 90% 以上、UI 元件 60% 以上」。對小工具函式與純元件可以容忍低覆蓋率。Vitest 也支援在 vitest.config.ts 設定 coverage.thresholds,如果某個檔案的覆蓋率低於門檻就讓測試失敗。建議把門檻設在「合理可達」的水位(例如 60%),不要設 100%,否則每加一個新元件就要補一堆測試。
最後是「測試程式碼也是程式碼」。測試檔應該跟正式程式碼一樣維護:避免重複(用 helper 函式)、保持可讀(斷言意圖清楚)、定期重構(測試壞掉時優先修測試而不是刪測試)。一個「寫了就忘」的測試比沒有測試更糟——它會讓你以為程式碼有保護,實際上保護已經失效。常見的測試重構模式:把多個測試共用的 setup 抽成 helper、把常見的斷言組合(例如「找到這個元素並檢查內容」)抽成自訂 matcher、把工廠函式(createBooking 等)集中在 src/test/factories.ts。
最後提一下測試與 CI 的整合。Day 2 我們把 verify 寫進 package.json,CI 上跑 pnpm verify 就會自動跑 lint + typecheck + test。Vitest 預設會在 CI 環境下用 run 模式(跑一次就退出),不會進入 watch。記得在 CI 設定裡加 CI=true 環境變數,Vitest 會自動調整行為(例如關掉顏色輸出)。
小結
今天我們把前端測試從「裝套件」到「寫 Hook 測試、表單測試、TanStack Query 元件測試」一次走完。重點回顧:Vitest 3.x 是 Vite 生態的測試執行器,速度比 Jest 快很多;React Testing Library 用「以使用者觀點測試」的哲學,鼓勵用 getByRole、getByLabelText 而不是 data-testid;renderHook 可以單獨測試自訂 Hook;mock fetch 用 vi.stubGlobal;測試 TanStack Query 要用 QueryClientProvider 包起來並關閉 retry。明天 Day 15 會進入專案結構主題——怎麼組織 src/、怎麼分層、怎麼避免「所有東西都塞在一個資料夾」的窘境,這是「多人協作中型 React 專案」的必修課。
結語
明天,我們會把焦點從「單一檔案」轉到「整個專案的組織」。Day 15 會介紹幾種常見的 React 專案結構(atomic design、feature-based、layer-based),並用「預約管理系統」當範例,示範怎麼從零開始規劃 src/ 下面的目錄結構。我們也會討論「什麼時候該拆資料夾」、「什麼時候該合併」、「共用元件 vs 業務元件怎麼分」。記得今天寫的所有測試先留著,明天會把它們組織到 src/components/__tests__/ 與 src/hooks/__tests__/ 對應的位置。
今天我們走過的測試 API 是你未來兩個月寫測試時的日常工具。即使你還沒記住所有細節,只要記住三個核心觀念就夠了:第一,「以使用者觀點測試」用 getByRole 與 getByLabelText 找元素;第二,「用 renderHook 測 Hook」、「用 render 測元件」、「用 waitFor 等非同步」;第三,「mock 外部依賴(fetch、storage、計時器)讓測試獨立」。這三個觀念涵蓋了 90% 的 React 元件測試需求,剩下的 10%(例如視覺測試、a11y 測試、E2E)會在後續章節陸續介紹。
延伸資源
- Vitest 官方文件(3.x,2026 年 3 月):
https://vitest.dev/ - React Testing Library 官方文件:
https://testing-library.com/docs/react-testing-library/intro/ - Testing Library 〈Guiding Principles〉(為什麼推薦 getByRole):
https://testing-library.com/docs/guiding-principles/ - TanStack Query 測試指南:
https://tanstack.com/query/latest/docs/framework/react/guides/testing - Kent C. Dodds〈Testing Implementation Details〉:
https://kentcdodds.com/blog/testing-implementation-details
留言
張貼留言