【Vitest React 測試教學】#03 Mock 與非同步測試:vi.fn、vi.mock 與 waitFor

測驗:Mock 與非同步測試

共 5 題,點選答案後會立即顯示結果

1. vi.fn() 建立的 mock 函數可以做什麼?

  • A. 只能用來模擬 API 呼叫
  • B. 只能追蹤函數被呼叫幾次
  • C. 追蹤呼叫次數、參數,並可設定回傳值
  • D. 直接執行原本函數的邏輯

2. 當你想模擬一個 Promise 成功回傳資料時,應該使用哪個方法?

  • A. mockReturnValue()
  • B. mockResolvedValue()
  • C. mockRejectedValue()
  • D. mockAsyncValue()

3. vi.mock('./api') 這行程式碼做了什麼?

  • A. 執行 api 模組中的所有函數
  • B. 刪除 api 模組的所有匯出
  • C. 只模擬 api 模組的預設匯出
  • D. 將 api 模組的所有匯出函數替換為 mock 函數

4. getByRolefindByRole 的主要差異是什麼?

  • A. getByRole 會等待元素出現,findByRole 不會
  • B. 兩者完全相同,只是名稱不同
  • C. getByRole 是同步查詢,findByRole 會等待元素出現
  • D. findByRole 只能查詢按鈕元素

5. waitFor 函數的作用是什麼?

  • A. 讓測試暫停固定秒數
  • B. 重複執行傳入的函數,直到成功或超時
  • C. 等待所有 API 呼叫完成
  • D. 取消正在進行的非同步操作

前言

在前一篇文章中,我們學會了如何測試 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() 時,它在做這件事:

  1. 建立一個「假函數」:這個函數可以被呼叫,但不會執行任何邏輯
  2. 記錄所有呼叫:它會記住被呼叫幾次、傳入什麼參數
  3. 可以設定回傳值:讓它回傳你指定的值

常見的 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:

  1. 攔截 ./api 模組的匯入
  2. 自動將所有匯出的函數替換為 mock 函數
  3. 這些 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 會:

  1. 執行你傳入的函數
  2. 如果函數拋出錯誤(assertion 失敗),等待一小段時間後重試
  3. 重複直到成功或超時(預設 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)

重點整理

  1. vi.fn():建立可追蹤的 mock 函數
    • 記錄呼叫次數和參數
    • 可設定回傳值(mockReturnValuemockResolvedValue
  2. vi.mock():模擬整個模組
    • 自動將模組中的函數替換為 mock
    • 使用 vi.mocked() 取得正確的 TypeScript 型別
  3. 非同步測試
    • findBy*:等待元素出現
    • waitFor:等待任意條件成立
    • 記得使用 async/await
  4. 測試前重置
    • 使用 beforeEachvi.resetAllMocks() 確保測試獨立

下一步

在最後一篇文章中,我們將學習測試 Custom Hook 和 Context,這是測試 React 應用中共享邏輯的關鍵技能。

進階測驗:Mock 與非同步測試

測驗目標:驗證你是否能在實際情境中應用所學。
共 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’) })
  • A. vi.mocked(fetchUser).mockReturnValue(new Error('Network error'))
  • B. vi.mocked(fetchUser).mockResolvedValue({ error: 'Network error' })
  • C. vi.mocked(fetchUser).mockRejectedValue(new Error('Network error'))
  • D. vi.mocked(fetchUser).mockThrow(new Error('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() })
  • A. mock 的回傳值格式錯誤
  • B. 應該使用 findByText 並加上 await,因為資料載入是非同步的
  • C. 需要先呼叫 vi.resetAllMocks()
  • D. render 函數需要加上 await

3. 你想測試按鈕點擊後會呼叫正確的 callback 函數。以下哪個寫法最完整? 情境題

function SubmitButton({ onSubmit }) { return <button onClick={() => onSubmit(‘data’)}>Submit</button> }
  • A. const handleSubmit = () => {}; render(<SubmitButton onSubmit={handleSubmit} />)
  • B. const handleSubmit = vi.fn(); expect(handleSubmit).toBeCalled()
  • C. render(<SubmitButton />); await user.click(screen.getByRole('button'))
  • D. const handleSubmit = vi.fn(); await user.click(button); expect(handleSubmit).toHaveBeenCalledWith('data')

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’) })
  • A. 需要把兩個測試放在不同的檔案
  • B. 應該在每個測試後呼叫 cleanup()
  • C. 需要在 beforeEach 中呼叫 vi.resetAllMocks()
  • D. 應該使用 vi.fn() 而不是 vi.mock()

5. 你想等待「Loading…」文字消失後再進行斷言,應該如何寫? 情境題

// 元件載入時顯示 Loading…,完成後顯示資料 render(<AsyncComponent />) // 如何等待 Loading… 消失?
  • A. await screen.findByText('Loading...', { hidden: true })
  • B. await waitFor(() => { expect(screen.queryByText('Loading...')).not.toBeInTheDocument() })
  • C. await screen.getByText('Loading...').waitForRemoval()
  • D. expect(screen.findByText('Loading...')).toBeNull()

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *