【Vitest React 測試教學】#04 測試自訂 Hooks:renderHook 與 act

測驗:測試自訂 Hooks:renderHook 與 act

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

1. 為什麼需要使用 renderHook 來測試自訂 Hook?

  • A. 因為 Hook 執行速度太快,需要特殊工具來捕捉結果
  • B. 因為 Hook 不能直接呼叫,必須在 React 元件裡面使用
  • C. 因為 Hook 會自動產生 DOM 元素,需要特殊處理
  • D. 因為 Hook 只能在 TypeScript 環境中執行

2. renderHook 回傳的物件中,result.current 代表什麼?

  • A. Hook 目前回傳的值
  • B. Hook 的初始參數
  • C. Hook 的執行次數
  • D. Hook 的錯誤狀態

3. 為什麼需要使用 act 來包裹狀態更新操作?

  • A. 為了讓測試程式碼更容易閱讀
  • B. 為了避免 React 拋出錯誤訊息
  • C. 因為 React 狀態更新是非同步的,需要確保更新完成後再斷言
  • D. 為了自動記錄狀態變化的歷史

4. 測試含有 useEffect cleanup 函式的 Hook 時,應該使用哪個方法來模擬元件卸載?

  • A. result.cleanup()
  • B. unmount()
  • C. rerender(null)
  • D. act(() => null)

5. 測試使用 setTimeoutsetInterval 的 Hook(如 useDebounce)時,應該搭配什麼技術?

  • A. 使用 await new Promise 等待真實時間經過
  • B. 直接用 rerender 強制更新
  • C. 使用 waitFor 無限等待
  • D. 使用 fake timers 搭配 vi.advanceTimersByTime()

一句話說明

renderHook 單獨測試 hook 邏輯,用 act 確保狀態更新完成再斷言。


為什麼需要 renderHook?

Hook 不能直接呼叫,必須在 React 元件裡面使用。但為了測試 hook 邏輯,每次都寫一個測試元件太麻煩。

// 不好:每次都要寫測試元件
function TestComponent() {
  const result = useCounter();
  return <div>{result.count}</div>;
}

// 好:用 renderHook 直接測試
const { result } = renderHook(() => useCounter());
Code language: JavaScript (javascript)

一句話renderHook 幫你建立一個隱形的測試元件,讓你專注在 hook 邏輯。


最小範例

待測試的 Hook

// useCounter.ts
import { useState } from 'react';

export function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initial);

  return { count, increment, decrement, reset };
}
Code language: JavaScript (javascript)

測試程式碼

// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('初始值為 0', () => {
  const { result } = renderHook(() => useCounter());
  expect(result.current.count).toBe(0);
});

test('increment 增加計數', () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});
Code language: JavaScript (javascript)

逐行翻譯:renderHook

import { renderHook, act } from '@testing-library/react';
// ↑ 從 testing-library 引入 hook 測試工具

const { result } = renderHook(() => useCounter());
// ↑ 執行 hook,結果放在 result 裡

expect(result.current.count).toBe(0);
// ↑ result.current 是 hook 回傳的值
Code language: JavaScript (javascript)

renderHook 回傳什麼?

const { result, rerender, unmount } = renderHook(() => useMyHook());

// result.current - hook 目前回傳的值
// rerender()     - 重新渲染,用於測試依賴項變化
// unmount()      - 卸載,用於測試 cleanup
Code language: JavaScript (javascript)
回傳值 用途
result.current 取得 hook 目前的回傳值
rerender() 重新執行 hook(可傳新參數)
unmount() 模擬元件卸載

逐行翻譯:act

act(() => {
  result.current.increment();  // 狀態更新操作
});
// ↑ act 確保 React 狀態更新完成後再往下執行

expect(result.current.count).toBe(1);
// ↑ 這時候狀態已經更新了
Code language: JavaScript (javascript)

為什麼需要 act?

React 的狀態更新是非同步的。沒有 act,斷言可能在狀態更新前就執行了:

// 可能失敗:狀態還沒更新
result.current.increment();
expect(result.current.count).toBe(1);  // 可能還是 0

// 正確:用 act 包起來
act(() => {
  result.current.increment();
});
expect(result.current.count).toBe(1);  // 確定是 1
Code language: JavaScript (javascript)

一句話:任何會觸發狀態更新的操作,都要用 act 包起來。


常見變化

變化 1:測試初始參數

test('可以設定初始值', () => {
  const { result } = renderHook(() => useCounter(10));
  expect(result.current.count).toBe(10);
});
Code language: JavaScript (javascript)

翻譯:傳參數給 hook,測試初始狀態。

變化 2:用 rerender 測試參數變化

test('rerender 可以傳入新參數', () => {
  const { result, rerender } = renderHook(
    ({ initial }) => useCounter(initial),
    { initialProps: { initial: 0 } }
  );

  expect(result.current.count).toBe(0);

  rerender({ initial: 100 });
  // 注意:這只會觸發重新渲染,不會重設 state
});
Code language: JavaScript (javascript)

翻譯rerender 模擬 props 變化,測試 hook 如何響應。

變化 3:多次狀態更新

test('多次操作', () => {
  const { result } = renderHook(() => useCounter(0));

  act(() => {
    result.current.increment();
    result.current.increment();
    result.current.increment();
  });

  expect(result.current.count).toBe(3);
});
Code language: JavaScript (javascript)

翻譯:多個操作可以放在同一個 act 裡面。


測試含有 useEffect 的 Hook

待測試的 Hook

// useDocumentTitle.ts
import { useEffect } from 'react';

export function useDocumentTitle(title: string) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}
Code language: JavaScript (javascript)

測試程式碼

test('設定 document title', () => {
  renderHook(() => useDocumentTitle('Hello'));

  expect(document.title).toBe('Hello');
});

test('title 變化時更新', () => {
  const { rerender } = renderHook(
    ({ title }) => useDocumentTitle(title),
    { initialProps: { title: 'Hello' } }
  );

  expect(document.title).toBe('Hello');

  rerender({ title: 'World' });

  expect(document.title).toBe('World');
});
Code language: JavaScript (javascript)

重點useEffect 在 renderHook 後會自動執行,不需要額外的 act


測試含有 cleanup 的 Hook

待測試的 Hook

// useEventListener.ts
import { useEffect } from 'react';

export function useEventListener(
  event: string,
  handler: (e: Event) => void
) {
  useEffect(() => {
    window.addEventListener(event, handler);

    return () => {
      window.removeEventListener(event, handler);  // cleanup
    };
  }, [event, handler]);
}
Code language: JavaScript (javascript)

測試程式碼

test('卸載時移除事件監聽', () => {
  const handler = vi.fn();
  const { unmount } = renderHook(() =>
    useEventListener('click', handler)
  );

  // 觸發事件,handler 會被呼叫
  window.dispatchEvent(new Event('click'));
  expect(handler).toHaveBeenCalledTimes(1);

  // 卸載 hook
  unmount();

  // 再次觸發,handler 不會被呼叫
  window.dispatchEvent(new Event('click'));
  expect(handler).toHaveBeenCalledTimes(1);  // 還是 1
});
Code language: JavaScript (javascript)

重點:用 unmount() 測試 cleanup 函式有沒有正確執行。


實戰:測試 useLocalStorage

待測試的 Hook

// useLocalStorage.ts
import { useState, useEffect } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}
Code language: JavaScript (javascript)

測試程式碼

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  test('初始值正確', () => {
    const { result } = renderHook(() =>
      useLocalStorage('name', 'default')
    );

    expect(result.current[0]).toBe('default');
  });

  test('讀取已存在的值', () => {
    localStorage.setItem('name', '"stored"');

    const { result } = renderHook(() =>
      useLocalStorage('name', 'default')
    );

    expect(result.current[0]).toBe('stored');
  });

  test('更新值會同步到 localStorage', () => {
    const { result } = renderHook(() =>
      useLocalStorage('count', 0)
    );

    act(() => {
      result.current[1](42);  // setValue(42)
    });

    expect(result.current[0]).toBe(42);
    expect(localStorage.getItem('count')).toBe('42');
  });
});
Code language: JavaScript (javascript)

實戰:測試 useDebounce

待測試的 Hook

// useDebounce.ts
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}
Code language: JavaScript (javascript)

測試程式碼

describe('useDebounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  test('延遲更新值', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 500),
      { initialProps: { value: 'initial' } }
    );

    // 初始值
    expect(result.current).toBe('initial');

    // 更新輸入值
    rerender({ value: 'updated' });

    // 還沒到 500ms,值不變
    expect(result.current).toBe('initial');

    // 快轉 500ms
    act(() => {
      vi.advanceTimersByTime(500);
    });

    // 現在值更新了
    expect(result.current).toBe('updated');
  });

  test('連續輸入只保留最後一個', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 500),
      { initialProps: { value: 'a' } }
    );

    rerender({ value: 'ab' });
    vi.advanceTimersByTime(100);

    rerender({ value: 'abc' });
    vi.advanceTimersByTime(100);

    rerender({ value: 'abcd' });

    // 還是初始值
    expect(result.current).toBe('a');

    // 從最後一次輸入開始算 500ms
    act(() => {
      vi.advanceTimersByTime(500);
    });

    expect(result.current).toBe('abcd');
  });
});
Code language: JavaScript (javascript)

重點:測試 debounce 需要搭配 fake timers。


測試非同步 Hook

待測試的 Hook

// useFetch.ts
import { useState, useEffect } from 'react';

export function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading, error };
}
Code language: JavaScript (javascript)

測試程式碼

describe('useFetch', () => {
  beforeEach(() => {
    vi.resetAllMocks();
  });

  test('成功取得資料', async () => {
    const mockData = { name: 'Alice' };
    global.fetch = vi.fn().mockResolvedValue({
      json: () => Promise.resolve(mockData),
    });

    const { result } = renderHook(() =>
      useFetch('/api/user')
    );

    // 初始狀態:loading
    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBe(null);

    // 等待非同步完成
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBe(null);
  });

  test('處理錯誤', async () => {
    global.fetch = vi.fn().mockRejectedValue(
      new Error('Network error')
    );

    const { result } = renderHook(() =>
      useFetch('/api/user')
    );

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBe(null);
    expect(result.current.error?.message).toBe('Network error');
  });
});
Code language: JavaScript (javascript)

重點:非同步 hook 用 waitFor 等待狀態變化。


常見錯誤與解決

錯誤 1:忘記用 act

// 錯誤:會有 warning
result.current.increment();
expect(result.current.count).toBe(1);

// 正確
act(() => {
  result.current.increment();
});
expect(result.current.count).toBe(1);
Code language: JavaScript (javascript)

錯誤 2:在 act 外面斷言更新後的值

// 錯誤:斷言放在 act 裡面
act(() => {
  result.current.increment();
  expect(result.current.count).toBe(1);  // 可能還沒更新
});

// 正確:斷言放在 act 外面
act(() => {
  result.current.increment();
});
expect(result.current.count).toBe(1);
Code language: JavaScript (javascript)

錯誤 3:忘記 await 非同步的 act

// 錯誤:非同步操作沒有 await
act(() => {
  result.current.fetchData();  // 非同步
});

// 正確:用 await
await act(async () => {
  await result.current.fetchData();
});
Code language: JavaScript (javascript)

整理:act 使用時機

情況 需要 act?
呼叫會更新狀態的函式
觸發事件
快轉 fake timers
單純讀取 result.current
renderHook 本身 否(內部已處理)

Vibe Coder 檢查點

看到 hook 測試時確認:

  • [ ] 狀態更新有用 act 包起來嗎?
  • [ ] 斷言是放在 act 外面嗎?
  • [ ] 非同步操作有用 waitForawait act 嗎?
  • [ ] 有測試 cleanup(unmount)嗎?
  • [ ] 用到 timer 的 hook 有用 fake timers 嗎?

延伸:知道就好

這些進階用法遇到再查:

  • wrapper:測試需要 Provider 的 hook(如 Context)
  • initialProps:設定 hook 的初始參數
  • hydrate:測試 SSR hydration
// wrapper 範例:提供 Context
const wrapper = ({ children }) => (
  <ThemeContext.Provider value="dark">
    {children}
  </ThemeContext.Provider>
);

const { result } = renderHook(() => useTheme(), { wrapper });
Code language: JavaScript (javascript)

重點回顧

  1. renderHook:獨立測試 hook,不需要寫測試元件
  2. result.current:取得 hook 目前的回傳值
  3. act:確保狀態更新完成後再斷言
  4. rerender:測試 props 變化
  5. unmount:測試 cleanup 邏輯
  6. waitFor:等待非同步操作完成

進階測驗:測試自訂 Hooks:renderHook 與 act

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你正在測試一個 useCounter Hook,需要驗證連續呼叫三次 increment 後計數是否為 3。以下哪種寫法最正確? 情境題

  • A. 分別用三個 act 包裹每次 increment(),中間各做一次斷言
  • B. 直接連續呼叫三次 result.current.increment(),最後再斷言
  • C. 用一個 act 包裹三次 increment() 呼叫,然後在 act 外面斷言
  • D. 用 waitFor 等待 result.current.count 變成 3

2. 小明寫了以下測試程式碼,但測試失敗了:錯誤診斷

test(‘increment 增加計數’, () => { const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); expect(result.current.count).toBe(1); }); });

最可能的問題是什麼?

  • A. renderHook 沒有正確引入
  • B. 斷言不應該放在 act 裡面,因為狀態可能還沒更新完成
  • C. 應該使用 await act 而不是 act
  • D. useCounter 需要傳入初始值參數

3. 你需要測試一個 useDebounce Hook,它會在 500ms 後才更新值。你已經設定了 vi.useFakeTimers(),接下來應該怎麼做? 情境題

const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 500), { initialProps: { value: ‘initial’ } } ); rerender({ value: ‘updated’ }); // 接下來應該怎麼寫?
  • A. await waitFor(() => expect(result.current).toBe('updated'));
  • B. vi.advanceTimersByTime(500); 然後直接斷言
  • C. await vi.advanceTimersByTimeAsync(500);
  • D. act(() => { vi.advanceTimersByTime(500); }); 然後斷言

4. 以下測試 useFetch Hook 的程式碼有什麼問題?錯誤診斷

test(‘成功取得資料’, () => { const mockData = { name: ‘Alice’ }; global.fetch = vi.fn().mockResolvedValue({ json: () => Promise.resolve(mockData), }); const { result } = renderHook(() => useFetch(‘/api/user’)); expect(result.current.loading).toBe(false); expect(result.current.data).toEqual(mockData); });
  • A. global.fetch 應該改用 window.fetch
  • B. mock 的寫法錯誤,應該用 mockImplementation
  • C. fetch 是非同步的,需要用 waitFor 等待 loading 變成 false 後再斷言
  • D. 需要在 act 裡面呼叫 renderHook

5. 你正在測試一個 useEventListener Hook,需要驗證卸載後事件監聽器確實被移除。以下哪種測試策略最完整? 情境題

  • A. 只要確認 unmount() 不拋出錯誤即可
  • B. 在 unmount() 前後各觸發一次事件,確認 handler 只被呼叫一次
  • C. 使用 vi.spyOn(window, 'removeEventListener') 確認被呼叫
  • D. 先觸發事件確認 handler 被呼叫,呼叫 unmount(),再觸發事件確認 handler 不再被呼叫

發佈留言

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