測驗:Mock 與非同步測試
共 5 題,點選答案後會立即顯示結果
1. vi.fn() 建立的 mock 函數可以做什麼?
2. 當你想模擬一個 Promise 成功回傳資料時,應該使用哪個方法?
3. vi.mock('./api') 這行程式碼做了什麼?
4. getByRole 和 findByRole 的主要差異是什麼?
5. waitFor 函數的作用是什麼?
前言
在前一篇文章中,我們學會了如何測試 React 元件的渲染和使用者互動。但真實世界的元件往往需要呼叫 API、處理非同步操作,或依賴外部模組。這時候,我們需要學會兩個關鍵技能:Mock(模擬) 和非同步測試。
本篇將帶你理解這些測試程式碼在做什麼,讓你在閱讀專案中的測試時不再困惑。
vi.fn():建立 Mock 函數
最小範例
import { vi, expect, test } from 'vitest'
test('mock function tracks calls', () => {
const mockFn = vi.fn()
mockFn('hello')
mockFn('world')
expect(mockFn).toHaveBeenCalledTimes(2)
expect(mockFn).toHaveBeenCalledWith('hello')
})
Code language: JavaScript (javascript)解讀重點
當你看到 vi.fn() 時,它在做這件事:
- 建立一個「假函數」:這個函數可以被呼叫,但不會執行任何邏輯
- 記錄所有呼叫:它會記住被呼叫幾次、傳入什麼參數
- 可以設定回傳值:讓它回傳你指定的值
常見的 Mock 函數用法
// 設定回傳值
const mockFn = vi.fn().mockReturnValue(42)
expect(mockFn()).toBe(42)
// 模擬 Promise 成功
const mockAsync = vi.fn().mockResolvedValue({ data: 'success' })
await expect(mockAsync()).resolves.toEqual({ data: 'success' })
// 模擬 Promise 失敗
const mockError = vi.fn().mockRejectedValue(new Error('Failed'))
await expect(mockError()).rejects.toThrow('Failed')
Code language: JavaScript (javascript)實際應用:測試按鈕點擊
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi, expect, test } from 'vitest'
function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>
}
test('calls onClick when clicked', async () => {
const handleClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick}>Click me</Button>)
await user.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
Code language: JavaScript (javascript)解讀:這個測試驗證「當按鈕被點擊時,onClick 函數會被呼叫」。我們用 vi.fn() 建立一個 mock 函數來追蹤它是否被呼叫。
vi.mock():模擬整個模組
為什麼需要模擬模組?
當元件依賴外部模組(例如 API 客戶端),我們不希望測試真的發送網路請求。透過 vi.mock(),我們可以「替換」整個模組。
最小範例
假設有一個 API 模組:
// api.ts
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
Code language: JavaScript (javascript)測試時我們可以模擬它:
import { vi, expect, test } from 'vitest'
import { fetchUser } from './api'
// 告訴 Vitest:用假的模組取代真的
vi.mock('./api')
test('mock module example', async () => {
// 設定 mock 函數的回傳值
vi.mocked(fetchUser).mockResolvedValue({ id: '1', name: 'Alice' })
const user = await fetchUser('1')
expect(user).toEqual({ id: '1', name: 'Alice' })
expect(fetchUser).toHaveBeenCalledWith('1')
})
Code language: JavaScript (javascript)解讀 vi.mock() 的運作方式
vi.mock('./api')
Code language: JavaScript (javascript)這行程式碼告訴 Vitest:
- 攔截
./api模組的匯入 - 自動將所有匯出的函數替換為 mock 函數
- 這些 mock 函數預設回傳
undefined
vi.mocked() 的作用
vi.mocked(fetchUser).mockResolvedValue(...)
Code language: CSS (css)vi.mocked() 是 TypeScript 的輔助函數,它告訴編譯器「這個函數已經被 mock 了」,這樣你就可以使用 .mockResolvedValue() 等方法而不會有型別錯誤。
非同步測試:waitFor 與 findBy*
為什麼需要等待?
React 元件的狀態更新是非同步的。當你呼叫 API 後更新狀態,畫面不會立即改變。測試需要「等待」這些變化發生。
findBy* vs getBy*
// getByRole:同步查詢,元素不存在會立即失敗
const button = screen.getByRole('button')
// findByRole:非同步查詢,會等待元素出現(預設 1 秒)
const button = await screen.findByRole('button')
Code language: JavaScript (javascript)關鍵差異:
getBy*:「現在」就要找到,找不到就報錯findBy*:「等一下」再找,最多等 1 秒
waitFor:等待條件成立
import { render, screen, waitFor } from '@testing-library/react'
test('waits for loading to complete', async () => {
render(<AsyncComponent />)
// 等待 "Loading..." 消失
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
// 現在可以安全地查詢載入完成後的內容
expect(screen.getByText('Data loaded')).toBeInTheDocument()
})
Code language: JavaScript (javascript)waitFor 的運作原理
await waitFor(() => {
expect(something).toBe(true)
})
Code language: JavaScript (javascript)waitFor 會:
- 執行你傳入的函數
- 如果函數拋出錯誤(assertion 失敗),等待一小段時間後重試
- 重複直到成功或超時(預設 1 秒)
實戰:測試 API 呼叫元件
被測試的元件
// UserProfile.tsx
import { useState, useEffect } from 'react'
import { fetchUser } from './api'
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetchUser(userId)
.then(data => {
setUser(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [userId])
if (loading) return <div>Loading...</div>
if (error) return <div role="alert">{error}</div>
return <div>Hello, {user.name}!</div>
}
Code language: JavaScript (javascript)完整測試範例
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react'
import { vi, expect, test, beforeEach } from 'vitest'
import { UserProfile } from './UserProfile'
import { fetchUser } from './api'
// 模擬 API 模組
vi.mock('./api')
beforeEach(() => {
// 每個測試前重置 mock
vi.resetAllMocks()
})
test('shows loading state initially', () => {
vi.mocked(fetchUser).mockResolvedValue({ name: 'Alice' })
render(<UserProfile userId="1" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
test('shows user name after loading', async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: 'Alice' })
render(<UserProfile userId="1" />)
// 使用 findByText 等待非同步載入完成
expect(await screen.findByText('Hello, Alice!')).toBeInTheDocument()
})
test('shows error message on failure', async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error('Network error'))
render(<UserProfile userId="1" />)
// 等待錯誤訊息出現
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Network error')
})
test('calls fetchUser with correct userId', async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: 'Bob' })
render(<UserProfile userId="42" />)
await screen.findByText('Hello, Bob!')
// 驗證 API 被正確呼叫
expect(fetchUser).toHaveBeenCalledWith('42')
expect(fetchUser).toHaveBeenCalledTimes(1)
})
Code language: JavaScript (javascript)逐一解讀每個測試
測試 1:顯示載入狀態
test('shows loading state initially', () => {
vi.mocked(fetchUser).mockResolvedValue({ name: 'Alice' })
render(<UserProfile userId="1" />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
Code language: JavaScript (javascript)- 這是同步測試,因為我們只檢查「初始」狀態
- 即使 API 會成功回傳,我們在它完成前就已經檢查完了
測試 2:載入完成顯示使用者名稱
test('shows user name after loading', async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: 'Alice' })
render(<UserProfile userId="1" />)
expect(await screen.findByText('Hello, Alice!')).toBeInTheDocument()
})
Code language: JavaScript (javascript)- 使用
async/await因為要等待非同步操作 findByText會等待文字出現
測試 3:顯示錯誤訊息
test('shows error message on failure', async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error('Network error'))
render(<UserProfile userId="1" />)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Network error')
})
Code language: JavaScript (javascript)- 使用
mockRejectedValue模擬 API 失敗 - 透過
role="alert"來查詢錯誤訊息元素
測試 4:驗證 API 呼叫
test('calls fetchUser with correct userId', async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: 'Bob' })
render(<UserProfile userId="42" />)
await screen.findByText('Hello, Bob!')
expect(fetchUser).toHaveBeenCalledWith('42')
expect(fetchUser).toHaveBeenCalledTimes(1)
})
Code language: JavaScript (javascript)- 等待載入完成後,驗證 mock 函數被正確呼叫
- 確認傳入的參數和呼叫次數
常見的 Mock 驗證方法
| 方法 | 說明 |
|---|---|
toHaveBeenCalled() |
函數有被呼叫過 |
toHaveBeenCalledTimes(n) |
函數被呼叫了 n 次 |
toHaveBeenCalledWith(arg) |
函數被呼叫時傳入了指定參數 |
toHaveBeenLastCalledWith(arg) |
最後一次呼叫傳入了指定參數 |
toHaveBeenNthCalledWith(n, arg) |
第 n 次呼叫傳入了指定參數 |
常見的非同步測試模式
模式 1:等待元素出現
// 使用 findBy*(推薦)
const element = await screen.findByText('Success')
// 或使用 waitFor
await waitFor(() => {
expect(screen.getByText('Success')).toBeInTheDocument()
})
Code language: JavaScript (javascript)模式 2:等待元素消失
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
Code language: JavaScript (javascript)模式 3:等待多個條件
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
expect(screen.getByText('Data loaded')).toBeInTheDocument()
})
Code language: JavaScript (javascript)重點整理
- vi.fn():建立可追蹤的 mock 函數
- 記錄呼叫次數和參數
- 可設定回傳值(
mockReturnValue、mockResolvedValue)
- vi.mock():模擬整個模組
- 自動將模組中的函數替換為 mock
- 使用
vi.mocked()取得正確的 TypeScript 型別
- 非同步測試:
findBy*:等待元素出現waitFor:等待任意條件成立- 記得使用
async/await
- 測試前重置:
- 使用
beforeEach和vi.resetAllMocks()確保測試獨立
- 使用
下一步
在最後一篇文章中,我們將學習測試 Custom Hook 和 Context,這是測試 React 應用中共享邏輯的關鍵技能。
進階測驗:Mock 與非同步測試
測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。
共 5 題,包含情境題與錯誤診斷題。
1. 測試 UserProfile 元件時,你想驗證 API 呼叫失敗時會顯示錯誤訊息。應該如何設定 mock? 情境題
// UserProfile 元件在 API 失敗時會顯示 <div role=”alert”>{error.message}</div>
vi.mock(‘./api’)
test(‘shows error on API failure’, async () => {
// 如何設定 fetchUser 的 mock?
render(<UserProfile userId=”1″ />)
const alert = await screen.findByRole(‘alert’)
expect(alert).toHaveTextContent(‘Network error’)
})
2. 小明的測試失敗了,錯誤訊息是「Unable to find an element with the text: Hello, Alice!」。問題出在哪裡? 錯誤診斷
test(‘shows user name after loading’, () => {
vi.mocked(fetchUser).mockResolvedValue({ name: ‘Alice’ })
render(<UserProfile userId=”1″ />)
expect(screen.getByText(‘Hello, Alice!’)).toBeInTheDocument()
})
3. 你想測試按鈕點擊後會呼叫正確的 callback 函數。以下哪個寫法最完整? 情境題
function SubmitButton({ onSubmit }) {
return <button onClick={() => onSubmit(‘data’)}>Submit</button>
}
4. 測試程式碼在每個測試間互相影響,第二個測試總是失敗。如何修正? 錯誤診斷
vi.mock(‘./api’)
test(‘test 1: success case’, async () => {
vi.mocked(fetchUser).mockResolvedValue({ name: ‘Alice’ })
render(<UserProfile userId=”1″ />)
await screen.findByText(‘Hello, Alice!’)
})
test(‘test 2: error case’, async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error(‘Error’))
render(<UserProfile userId=”1″ />)
// 這裡總是找不到 alert,因為還是顯示 Alice
await screen.findByRole(‘alert’)
})
5. 你想等待「Loading…」文字消失後再進行斷言,應該如何寫? 情境題
// 元件載入時顯示 Loading…,完成後顯示資料
render(<AsyncComponent />)
// 如何等待 Loading… 消失?