Bạn viết một custom hook như useCounter hay useDebounce, giờ muốn test nó. Gọi thẳng hook trong file test thì React ném lỗi “Invalid hook call” — hook chỉ chạy được bên trong một component đang render. Vậy test nó thế nào mà không phải dựng cả một component giả?
renderHook của Testing Library dựng một component vô hình để chạy hook và đưa giá trị trả về ra ngoài qua result.current. Bài này đi qua act, rerender, wrapper, waitFor và fake timers — kèm những sai lầm khiến test đỏ oan hoặc pass giả.
1. Vì sao không gọi hook trực tiếp trong test được
React lưu state của hook theo từng fiber — cái node ứng với một component đang render. Gọi useState ngoài một lần render thì không có fiber nào để gắn state vào, nên React chặn ngay bằng lỗi “Invalid hook call”. Đó là lý do bạn không thể const c = useCounter() thẳng trong test.
renderHook giải quyết đúng chỗ đó: nó dựng một component tạm, gọi hook bên trong lần render của component đó, rồi trả về result — một object mà result.current luôn giữ giá trị trả về mới nhất của hook sau mỗi lần commit.
1
2
3
4
5
6
7
8
// vitest.config.ts — hook cần DOM giả, nên chạy trên jsdom
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom' },
})Các ví dụ dưới đây chạy trên Vitest 4, @testing-library/react 16 và React 19. Một lưu ý cho ai từ dự án cũ: renderHook từng nằm ở package riêng @testing-library/react-hooks, nhưng từ React 18 nó đã được gộp vào @testing-library/react. Nếu bạn còn import { renderHook } from '@testing-library/react-hooks', đổi sang @testing-library/react là đủ.
1
2
3
4
5
6
7
8
9
10
// useCounter.ts
import { useState, useCallback } from 'react'
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial)
const increment = useCallback(() => setCount((c) => c + 1), [])
const decrement = useCallback(() => setCount((c) => c - 1), [])
const reset = useCallback(() => setCount(initial), [initial])
return { count, increment, decrement, reset }
}1
2
3
4
5
6
7
8
9
10
import { describe, it, expect } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('khởi tạo với giá trị mặc định', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
})Giải thích:
renderHook(() => useCounter())chạy hook một lần và trả vềresult.result.currentlà object hook trả về; đọcresult.current.countđể assert.- Đừng lưu
result.currentra biến rồi assert biến đó về sau — sau mỗi update,result.currenttrỏ tới object mới, còn biến cũ thì đứng yên.
2. Bọc mọi update bằng act
Test trên chỉ đọc giá trị ban đầu. Khi bạn gọi một hàm hook trả về để update state — như increment — update đó phải nằm trong act, nếu không nó chưa được flush khi bạn assert.
1
2
3
4
5
6
7
8
9
10
11
import { renderHook, act } from '@testing-library/react'
it('tăng count khi gọi increment', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(6)
})Giải thích:
act(...)gom một nhóm update lại rồi flush cả state lẫn effect trước khi trả quyền điều khiển về cho test — đúng thứ tự React chạy trong app thật.renderHookđã tự bọcactquanh lần render đầu, quanhrerendervàunmount. Bạn chỉ cần tự bọc những trigger mình chủ động gọi: hàm hook trả về (increment), hoặcvi.advanceTimersByTime(mục 7).- Bỏ
actở đây thì update chưa flush,result.current.countvẫn là giá trị cũ và test fail — xem tận mắt ở mục 9.1.
3. Đổi props và deps với rerender
Nhiều hook nhận tham số và phải phản ứng khi tham số đổi — giống component cha truyền props mới. renderHook cho bạn rerender(newProps) để mô phỏng đúng chuyện đó. Ví dụ kinh điển là usePrevious, hook nhớ giá trị của lần render trước:
1
2
3
4
5
6
7
8
9
10
// usePrevious.ts
import { useEffect, useRef } from 'react'
export function usePrevious<T>(value: T) {
const ref = useRef<T | undefined>(undefined)
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { describe, it, expect } from 'vitest'
import { renderHook } from '@testing-library/react'
import { usePrevious } from './usePrevious'
describe('usePrevious', () => {
it('trả về giá trị của lần render trước', () => {
const { result, rerender } = renderHook(
({ value }) => usePrevious(value),
{ initialProps: { value: 'a' } },
)
expect(result.current).toBeUndefined()
rerender({ value: 'b' })
expect(result.current).toBe('a')
rerender({ value: 'c' })
expect(result.current).toBe('b')
})
})Giải thích:
initialPropslà props cho lần render đầu; callback({ value }) => ...nhận đúng object đó.rerender({ value: 'b' })render lại hook với props mới, mô phỏng component cha đổi props.- Lần đầu chưa có gì trước đó nên
result.currentlàundefined; sau khi đổi sang'b', hook trả về giá trị cũ'a'.rerendercũng đã được bọcactsẵn nên effect kịp chạy.
4. Test hook đọc Context bằng wrapper
Hook gọi useContext cần một Provider bao quanh, nếu không nó chỉ nhận giá trị mặc định của context. renderHook có option wrapper — một component bọc quanh hook — để bạn dựng đúng Provider đó.
1
2
3
4
5
6
7
8
// ThemeContext.tsx
import { createContext, useContext } from 'react'
export const ThemeContext = createContext<'light' | 'dark'>('light')
export function useTheme() {
return useContext(ThemeContext)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { describe, it, expect } from 'vitest'
import { renderHook } from '@testing-library/react'
import { ThemeContext, useTheme } from './ThemeContext'
describe('useTheme', () => {
it('đọc value từ Context provider bọc ngoài', () => {
const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => (
<ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
),
})
expect(result.current).toBe('dark')
})
})Giải thích:
wrappernhậnchildrenvà bọc chúng —renderHookrender hook bên trong wrapper đó.- Cùng cách này áp dụng cho mọi Provider: Redux
Provider, React QueryQueryClientProvider, router… Cần nhiều Provider thì lồng chúng trong một wrapper. - Nhờ vậy bạn test được hook phụ thuộc context mà không cần render một component thật dùng nó.
5. Test hook async với waitFor
Hook gọi API không có kết quả ngay: lần render đầu loading là true, data là null; chỉ sau khi request resolve state mới đổi. Đọc result.current ngay sau renderHook sẽ thấy dữ liệu chưa về. waitFor chạy lại callback đến khi nó ngừng throw — tức đến khi điều kiện đúng.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 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(() => {
let active = true
setLoading(true)
fetch(url)
.then((res) => res.json())
.then((json) => {
if (active) {
setData(json)
setLoading(false)
}
})
.catch((err) => {
if (active) {
setError(err)
setLoading(false)
}
})
return () => {
active = false
}
}, [url])
return { data, loading, error }
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useFetch } from './useFetch'
describe('useFetch', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(() =>
Promise.resolve({ json: () => Promise.resolve({ name: 'Alice' }) }),
),
)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('trả về data sau khi request resolve', async () => {
const { result } = renderHook(() => useFetch<{ name: string }>('/api/user'))
expect(result.current.loading).toBe(true)
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.data).toEqual({ name: 'Alice' })
})
})Giải thích:
waitFor(() => expect(...))retry callback theo chu kỳ đến khi hết throw hoặc timeout (mặc định 1000ms). Nó tự bọcactvà chờ state async flush — đừng thay bằngsetTimeoutthủ công.vi.stubGlobal('fetch', vi.fn(...))thayfetchthật bằng một mock trả về data cố định, test không đụng network;vi.unstubAllGlobals()trongafterEachgỡ mock để không rò rỉ sang test sau.- Chi tiết
vi.fn/vi.mockvà cách dọn mock giữa các test nằm ở bài vi.spyOn vs vi.mock. Với request phức tạp hơn — nhiều endpoint, status code, header — thường người ta dựng mock ở tầng network bằng MSW; nhưng tới lúc cần MSW thì hầu như đã là lúc nên test qua component (mục 8) chứ không phải quarenderHook.
6. Kiểm tra cleanup của useEffect bằng unmount
Nhiều hook đăng ký listener, subscription hay timer rồi dọn chúng trong cleanup của useEffect. Quên dọn là rò rỉ. unmount() từ renderHook mô phỏng component bị gỡ, cho bạn kiểm tra cleanup có thật sự chạy.
1
2
3
4
5
6
7
8
9
// useEventListener.ts
import { useEffect } from 'react'
export function useEventListener(event: string, handler: () => void) {
useEffect(() => {
window.addEventListener(event, handler)
return () => window.removeEventListener(event, handler)
}, [event, handler])
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { afterEach, describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useEventListener } from './useEventListener'
describe('useEventListener', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('gỡ listener khi unmount', () => {
const add = vi.spyOn(window, 'addEventListener')
const remove = vi.spyOn(window, 'removeEventListener')
const handler = vi.fn()
const { unmount } = renderHook(() => useEventListener('resize', handler))
expect(add).toHaveBeenCalledWith('resize', handler)
unmount()
expect(remove).toHaveBeenCalledWith('resize', handler)
})
})Giải thích:
vi.spyOn(window, 'addEventListener')theo dõi lời gọi mà vẫn để hàm gốc chạy, nên bạn khẳng định được đúngeventvàhandlerđược truyền vào.unmount()chạy cleanup củauseEffect, và bạn kiểm traremoveEventListenerđược gọi với đúng cặpevent+handlerđó — bằng chứng listener đã được gỡ.vi.restoreAllMocks()trongafterEachgỡ hai spy sau mỗi test để chúng không rò sang test sau.- Cùng cách này dùng để test hook dọn
setInterval, hủy subscription, hoặcAbortController.
7. Hook có timer: dùng fake timers
useDebounce chờ một khoảng lặng rồi mới cập nhật value. Cơ chế fake timers — vi.useFakeTimers() / vi.advanceTimersByTime() và lý do phải trả timer thật trong afterEach — đã nói ở bài flaky test. Điểm mới khi timer nằm trong hook: lần tua giờ làm hook cập nhật state, nên phải bọc trong act.
1
2
3
4
5
6
7
8
9
10
11
12
13
// useDebounce.ts
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay: number) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDebounce } from './useDebounce'
describe('useDebounce', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('chỉ cập nhật value sau khoảng delay', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'a' } },
)
expect(result.current).toBe('a')
rerender({ value: 'ab' })
rerender({ value: 'abc' })
expect(result.current).toBe('a')
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current).toBe('abc')
})
})Giải thích:
- Đổi value liên tục (
'ab'rồi'abc') mà chưa tua giờ thìdebouncedvẫn là'a'— đúng bản chất debounce. vi.advanceTimersByTime(500)kích hoạtsetTimeoutcallback →setDebouncedchạy, nên phải bọc trongactđể flush; sau đódebouncedmới thành'abc'.
8. Khi nào test qua component thay vì renderHook
renderHook mạnh cho hook logic thuần — state, tính toán, subscription — thứ không dính nhiều tới DOM hay tương tác người dùng. Nhưng nếu hook gắn chặt với UI (đọc DOM, phản ứng theo click/gõ, phối hợp nhiều hook trong một component), test QUA component dùng nó sẽ sát thực tế hơn: render component, tương tác như user, rồi assert trên những gì user thấy.
Dùng renderHook khi | Test qua component khi |
|---|---|
| Hook logic thuần: state, tính toán, subscription | Hook điều khiển UI, phản ứng theo tương tác |
| Hook tái sử dụng ở nhiều component | Hook chỉ dùng trong một component cụ thể |
| Muốn cô lập hành vi hook khỏi render | Muốn test luồng người dùng thật (click, gõ) |
Test component là địa hạt của render + userEvent + các query của Testing Library — một chủ đề riêng, đủ dài cho một bài khác. Quy tắc gọn: renderHook để dành cho hook độc lập UI (thư viện hook, hook chia sẻ nhiều nơi); hook chỉ phục vụ một màn hình thì test luôn qua màn hình đó.
9. Ba sai lầm hay gặp
9.1. Quên act
Đây là bug âm thầm nhất: bỏ act quanh một update, test không báo lỗi cú pháp mà chỉ đọc nhầm giá trị.
1
2
3
4
5
it('tăng count mà không bọc act', () => {
const { result } = renderHook(() => useCounter())
result.current.increment() // update state ngoài act
expect(result.current.count).toBe(1)
})1
2
3
4
5
6
7
8
FAIL src/useCounter.test.ts > useCounter > tăng count mà không bọc act
AssertionError: expected +0 to be 1 // Object.is equality
- Expected
+ Received
- 1
+ 0React yêu cầu mọi update chạy trong act để được flush trước khi bạn assert. Bỏ act, tùy phiên bản bạn sẽ thấy cảnh báo “not wrapped in act(…)”, hoặc — như React 19 ở đây — update đơn giản chưa flush nên result.current.count vẫn là 0 và test fail. Bọc increment trong act là hết.
9.2. Test implementation thay vì behavior
Assert vào chi tiết bên trong — hook re-render bao nhiêu lần, có gọi useMemo không, tên biến state nội bộ — khiến test đỏ mỗi lần refactor dù hành vi không đổi. Test cái hook trả về và cái nó làm ra ngoài (result.current, side effect như listener được gỡ), đừng test cách nó làm bên trong. Còn cơ chế nội tại của hook — stale closure, dependency array, vì sao useEffect chạy hai lần ở StrictMode — là chuyện hiểu hook chứ không phải chuyện test hook, và nên để cho một bài riêng.
9.3. Snapshot cả object result
expect(result.current).toMatchSnapshot() chụp luôn cả các function hook trả về (increment, reset) và cấu trúc nội bộ. Đổi tên một hàm là snapshot đỏ, mà chẳng nói lên hành vi nào sai. Assert từng giá trị cụ thể — result.current.count, result.current.data — vừa rõ ý định vừa không giòn.
Chạy cả bộ test, bảy case đều xanh:
1
2
3
4
5
6
7
8
9
10
✓ src/useEventListener.test.ts > useEventListener > gỡ listener khi unmount
✓ src/useCounter.test.ts > useCounter > khởi tạo với giá trị mặc định
✓ src/useCounter.test.ts > useCounter > tăng count khi gọi increment
✓ src/usePrevious.test.ts > usePrevious > trả về giá trị của lần render trước
✓ src/useDebounce.test.ts > useDebounce > chỉ cập nhật value sau khoảng delay
✓ src/useFetch.test.ts > useFetch > trả về data sau khi request resolve
✓ src/useTheme.test.tsx > useTheme > đọc value từ Context provider bọc ngoài
Test Files 6 passed (6)
Tests 7 passed (7)renderHook cho bạn test hook mà không phải dựng UI: nhớ bọc act quanh mọi update bạn tự trigger, dùng rerender cho props, wrapper cho context, waitFor cho async, unmount cho cleanup. Và khi hook đã dính chặt vào UI, đừng cố nhồi vào renderHook — test qua component sẽ đúng tinh thần hơn. Mở file hook gần nhất trong dự án và viết case test đầu tiên cho nó thử xem.