Vitest 單元測試 React 組件完整教學:從設定到實戰的測試指南
老實說,我一開始對寫測試這件事是抗拒的。覺得寫完功能還要再寫測試,根本是在浪費時間。直到有一天,我改了一個看起來無關緊要的 utility function,結果整個購物車模組直接炸掉,花了三個小時 debug 才找到原因。從那天起,我就下定決心:該寫的測試一定要寫。
問題是,用 Jest 跑測試的體驗真的不太好。設定一堆有的沒的、跑一次要等好幾秒,尤其是大專案光啟動就要花十幾秒。後來接觸到 Vitest,完全被它的速度驚豔到。如果你也用 Vite 做開發,Vitest 根本就是天作之合。這篇教學會帶你從零開始,一步步學會用 Vitest 為 React 組件寫出扎實的單元測試。
為什麼選 Vitest 而非 Jest
先講結論:如果你的專案已經在用 Vite,那選 Vitest 幾乎沒有懸念。但即使你不是用 Vite,Vitest 也值得認真考慮。以下是我實際使用後的比較心得:
速度差異是最有感的。Jest 使用自己的 transform pipeline,每次啟動都要經歷一段漫長的初始化。Vitest 直接複用 Vite 的 dev server 和 HMR 機制,首次跑測試就快很多,而 watch 模式下的增量測試更是幾乎瞬間完成。在我手上一個中型專案(約 200 個測試檔案),Jest 跑完全部測試要 45 秒,Vitest 只要 12 秒左右。
設定簡潔度也差很多。Jest 需要另外設定 babel-jest 或 ts-jest 來處理 TypeScript 和 ESM,還要搞一堆 moduleNameMapper。Vitest 直接讀 vite.config.ts,你在 Vite 裡設定好的 alias、plugin 全部自動生效,不用重複設定。
API 相容性方面,Vitest 刻意設計成跟 Jest 幾乎一模一樣的 API。describe、it、expect、vi.fn()、vi.mock() 這些你都不用重新學。如果你本來就會 Jest,遷移過來的成本非常低。
不過 Jest 也不是沒有優勢。它的生態系更成熟,社群資源更多,遇到問題比較容易 Google 到答案。如果你的專案不是用 Vite(比如用 Create React App 或 webpack),那 Jest 的整合確實比較無痛。但如果你是新專案,我真心推薦直接從 Vitest 開始。
環境設定:Vitest + React Testing Library + jsdom
來動手吧。假設你已經有一個 Vite + React + TypeScript 的專案,只需要幾個步驟就能把測試環境搞定。
首先安裝需要的套件:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom接著在 vite.config.ts 裡加上測試相關設定:
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
},
})這裡有幾個重點。globals: true 讓你不用每個測試檔案都 import describe、it、expect,直接用就好。environment: 'jsdom' 模擬瀏覽器 DOM 環境,這對 React 組件測試來說是必要的。setupFiles 指定一個初始化檔案,我們待會要在裡面設定 @testing-library/jest-dom。
建立 src/test/setup.ts:
import '@testing-library/jest-dom'就這樣一行。這會擴充 Vitest 的 expect,讓你可以用 toBeInTheDocument()、toHaveTextContent() 等好用的 matcher。
最後在 package.json 加上 script:
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}vitest 預設是 watch 模式,修改檔案後自動重跑相關測試。vitest run 則是跑一次就結束,適合 CI 使用。設定完成,我們可以開始寫第一個測試了。
第一個測試:render、screen、fireEvent
來寫一個簡單的計數器組件測試。先假設我們有這個組件:
// src/components/Counter.tsx
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p data-testid="count">目前計數:{count}</p>
<button onClick={() => setCount(c => c + 1)}>增加</button>
<button onClick={() => setCount(0)}>重置</button>
</div>
)
}對應的測試檔案:
// src/components/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from './Counter'
describe('Counter', () => {
it('初始計數應為 0', () => {
render(<Counter />)
expect(screen.getByTestId('count')).toHaveTextContent('目前計數:0')
})
it('點擊增加按鈕後計數加 1', () => {
render(<Counter />)
fireEvent.click(screen.getByText('增加'))
expect(screen.getByTestId('count')).toHaveTextContent('目前計數:1')
})
it('點擊重置按鈕後計數歸零', () => {
render(<Counter />)
fireEvent.click(screen.getByText('增加'))
fireEvent.click(screen.getByText('增加'))
fireEvent.click(screen.getByText('重置'))
expect(screen.getByTestId('count')).toHaveTextContent('目前計數:0')
})
})這裡面幾個核心概念:render() 把組件渲染到虛擬 DOM 中;screen 是一個全域查詢器,提供各種找元素的方法;fireEvent 用來模擬使用者操作。跑 npm test 就能看到三個測試全部通過,而且速度飛快。
這邊有個小建議:盡量用 getByRole 或 getByText 來查找元素,而不是依賴 testid。因為 role 和 text 更接近使用者實際看到的畫面,測試也更有意義。testid 是最後手段,當你真的找不到好的 query 方式時才用。
測試 Props 與 State 變化
真實的 React 組件通常會接收 props,我們也需要測試不同 props 下的行為。來看一個稍微複雜一點的例子:
// src/components/UserGreeting.tsx
interface UserGreetingProps {
name: string
role?: 'admin' | 'user'
onLogout: () => void
}
export function UserGreeting({ name, role = 'user', onLogout }: UserGreetingProps) {
return (
<div>
<h2>歡迎回來,{name}!</h2>
{role === 'admin' && <span className="badge">管理員</span>}
<button onClick={onLogout}>登出</button>
</div>
)
}測試檔案:
// src/components/UserGreeting.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { UserGreeting } from './UserGreeting'
describe('UserGreeting', () => {
const defaultProps = {
name: '小明',
onLogout: vi.fn(),
}
it('顯示使用者名稱', () => {
render(<UserGreeting {...defaultProps} />)
expect(screen.getByText('歡迎回來,小明!')).toBeInTheDocument()
})
it('一般使用者不顯示管理員標籤', () => {
render(<UserGreeting {...defaultProps} />)
expect(screen.queryByText('管理員')).not.toBeInTheDocument()
})
it('管理員顯示管理員標籤', () => {
render(<UserGreeting {...defaultProps} role="admin" />)
expect(screen.getByText('管理員')).toBeInTheDocument()
})
it('點擊登出按鈕觸發 onLogout', () => {
const onLogout = vi.fn()
render(<UserGreeting {...defaultProps} onLogout={onLogout} />)
fireEvent.click(screen.getByText('登出'))
expect(onLogout).toHaveBeenCalledTimes(1)
})
})注意這裡用了 vi.fn() 來建立 mock 函式,這是 Vitest 版本的 jest.fn()。另外,用 queryByText 而非 getByText 來測試「元素不存在」的情況——getByText 找不到會拋錯,queryByText 則會回傳 null。
Mock 技巧:API 請求、模組與 Timer
寫測試最麻煩的部分大概就是 mock 了。不過一旦掌握幾種基本模式,其實也沒那麼可怕。
Mock API 請求
假設有個組件會 fetch 使用者資料:
// 測試中 mock fetch
it('載入並顯示使用者資料', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: true,
json: async () => ({ name: '小華', email: '[email protected]' }),
} as Response)
render(<UserProfile userId="123" />)
expect(screen.getByText('載入中...')).toBeInTheDocument()
await screen.findByText('小華')
expect(screen.getByText('[email protected]')).toBeInTheDocument()
})用 vi.spyOn 攔截 fetch,讓它回傳我們指定的假資料。findByText 會等待元素出現(預設最多 1 秒),很適合測試非同步的畫面更新。
Mock 整個模組
vi.mock('./api/userService', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: '測試使用者' }),
updateUser: vi.fn().mockResolvedValue({ success: true }),
}))整個模組都被替換掉了。如果你只想 mock 部分 export,可以搭配 vi.importActual。
Mock Timer
測試 debounce、setTimeout 這類跟時間有關的邏輯時,fake timer 非常好用:
it('debounce 搜尋在 300ms 後觸發', async () => {
vi.useFakeTimers()
const onSearch = vi.fn()
render(<SearchInput onSearch={onSearch} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'react' } })
expect(onSearch).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(onSearch).toHaveBeenCalledWith('react')
vi.useRealTimers()
})記得在測試結束後呼叫 vi.useRealTimers(),不然可能會影響其他測試。或者更好的做法是放在 afterEach 裡統一清理。
快照測試的使用時機與陷阱
快照測試是個有趣的功能。它會把組件的渲染結果存成一個 .snap 檔案,下次跑測試時比對是否有變動。用法很簡單:
it('渲染結果符合快照', () => {
const { container } = render(<Button variant="primary">送出</Button>)
expect(container.firstChild).toMatchSnapshot()
})第一次跑會自動建立快照檔,之後每次跑都會比對。如果 UI 有意圖的變動,執行 vitest -u 就能更新快照。
但說實話,我對快照測試的態度是比較保守的。它有幾個問題:第一,快照很容易變得很大,一個小改動就會產生巨大的 diff,code review 時幾乎沒人會認真看。第二,團隊成員遇到快照失敗時,最常做的事就是直接更新快照而不去理解為什麼變了。第三,它測的是「輸出有沒有變」,而不是「輸出對不對」。
我建議只在以下場景使用快照測試:小型、穩定、不常變動的 UI 組件(像 Icon、Badge 這種)、序列化的資料結構比對。其他情況下,明確的斷言(assertion)會是更好的選擇。如果你對前端測試有興趣,也可以看看 Playwright E2E 測試教學,了解端到端測試如何補足單元測試的不足。
測試 Custom Hooks
Custom Hook 不能直接呼叫,它必須在 React 組件的上下文中才能運作。@testing-library/react 提供了 renderHook 來解決這個問題:
// src/hooks/useCounter.ts
import { useState, useCallback } from 'react'
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount(c => c + 1), [])
const decrement = useCallback(() => setCount(c => c - 1), [])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, decrement, reset }
}// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('初始值預設為 0', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('支援自訂初始值', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increment 增加計數', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1)
})
it('reset 回到初始值', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.increment())
act(() => result.current.increment())
act(() => result.current.reset())
expect(result.current.count).toBe(5)
})
})關鍵是用 act() 包住所有會觸發 state 更新的操作。如果不包,React 會警告你。result.current 永遠指向 hook 最新的回傳值。
如果你的 hook 依賴 context(比如 Redux store 或自訂的 Provider),可以透過 renderHook 的 wrapper 選項提供:
const wrapper = ({ children }) => (
<ThemeProvider theme="dark">{children}</ThemeProvider>
)
const { result } = renderHook(() => useTheme(), { wrapper })搭配 React 19 新功能教學 中提到的新 Hooks,你也能用同樣的方式測試 React 19 的 use hook 和 Actions。
CI 整合與覆蓋率報告
寫好測試當然要放進 CI pipeline 自動跑。以 GitHub Actions 為例:
name: Test
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
- run: npx vitest run --coverage要產生覆蓋率報告,需要安裝 coverage provider:
npm install -D @vitest/coverage-v8然後在 vite.config.ts 的 test 區塊加上:
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
}thresholds 設定覆蓋率門檻,低於這個數字 CI 就會失敗。80% 是我覺得比較合理的起始值——太低沒意義,太高會逼你寫一堆沒價值的測試來湊數。
跑 npm run test:coverage 之後,會在 coverage/ 目錄產生 HTML 報告,可以直接打開來看哪些程式碼還沒被測試覆蓋到。
測試最佳實踐:該測什麼、不該測什麼
寫了這麼多測試之後,我慢慢歸納出一些原則,分享給你參考。
該測的
- 使用者可見的行為:按鈕點了會怎樣、表單送出後畫面怎麼變、錯誤訊息有沒有正確顯示。這些是測試的核心價值。
- 邊界條件:空陣列、null、超長字串、特殊字元。這些往往是 bug 的溫床。
- 條件渲染:根據不同 props 或 state,組件該顯示或隱藏什麼。
- 錯誤處理:API 失敗、網路斷線、資料格式錯誤時的 fallback 行為。
- 無障礙性:確保重要元素有正確的 role、aria-label,可被螢幕閱讀器識別。
不該測的
- 實作細節:不要測 state 的內部值、不要測 component 內部的方法。今天用 useState 明天可能換成 useReducer,你不會想因為重構就改一堆測試。
- 第三方套件:不用測 React Router 的路由有沒有正確運作、不用測 Axios 有沒有正確發請求。那是他們的事。
- CSS 樣式:顏色對不對、間距夠不夠,這種用視覺回歸測試比較合適。
- 常數和靜態內容:一段固定的文字或一個靜態設定值,不太有測試的必要。
幾個實用原則
第一,每個測試只驗證一件事。如果你的 it 描述裡有「而且」,通常代表這個測試應該拆成兩個。
第二,測試要能獨立運行。測試之間不能有依賴關係,A 測試的結果不能影響 B 測試。善用 beforeEach 做初始化、afterEach 做清理。
第三,偏好 userEvent 而非 fireEvent。@testing-library/user-event 更真實地模擬使用者行為,比如打字時會觸發 keydown、keyup 等完整事件序列,而 fireEvent 只觸發單一事件。
第四,測試命名要有意義。好的測試描述就是最好的文件。「應該正確渲染」這種命名毫無資訊量,「未登入時顯示登入按鈕而非使用者名稱」才是好的命名。
第五,不要追求 100% 覆蓋率。覆蓋率是參考指標,不是目標。有些程式碼(如 error boundary 的 fallback、生產環境的 logging)強行測試反而增加維護成本。把時間花在測試真正重要的業務邏輯上。
如果你對 React 的效能優化也有興趣,可以參考 React Compiler 自動優化教學,了解 React Compiler 如何自動處理 useMemo 和 useCallback,這也會影響你在測試中如何 mock 和驗證 memoized 行為。
結語
Vitest 讓寫測試這件事變得不再痛苦。快速的啟動、與 Vite 無縫整合、幾乎零成本的遷移,這些都大幅降低了「開始寫測試」的門檻。
我的建議是從小地方開始:先挑一個重要的組件,為它寫幾個基本的測試。等你嘗到測試帶來的安心感——那種「放心重構因為測試會幫你抓錯」的感覺——你就會自然而然地寫更多測試。不需要一開始就追求完美的測試覆蓋率,重要的是養成習慣。
希望這篇教學對你有幫助。如果你已經在用 Vitest,歡迎分享你的實戰經驗。如果你還在觀望,不妨今天就在你的專案裡跑一次 npm install -D vitest 試試看吧。
繼續閱讀
Playwright E2E 端對端測試完整教學:Next.js 專案從設定到 CI/CD 自動化
Playwright 是目前最強大的 E2E 測試框架,本文手把手教你在 Next.js 專案中完整設定,從 Page Object Model 到 GitHub Actions CI/CD 自動化,打造穩定可靠的端對端測試流程。
相關文章
你可能也喜歡
探索其他領域的精選好文