Tester les Hooks React avec Vitest
Les hooks React ont révolutionné la façon dont nous gérons l’état et les effets de cycle de vie dans les applications React. Cependant, tester ces hooks directement peut être un défi, car ils ne peuvent pas être appelés en dehors des composants React. C’est là que renderHook
de React Testing Library intervient.
Introduction à renderHook
La fonction renderHook
vous permet de tester un hook React en l’enveloppant automatiquement dans un composant de test. Cela vous permet d’accéder au résultat du hook et de tester son comportement sans avoir à créer manuellement un composant.
Installation et Configuration
Pour tester les hooks, installez les dépendances suivantes :
npm install -D vitest @testing-library/react @testing-library/react-hooks jsdom
Configurez Vitest pour utiliser l’environnement JSDOM :
import { defineConfig } from 'vitest/config'import react from '@vitejs/plugin-react'
export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: './src/test/setup.ts', },})
Tester un Hook d’État Simple
Commençons par un hook simple qui gère un compteur :
import { useState } from 'react'
export function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue)
const increment = () => setCount(prev => prev + 1) const decrement = () => setCount(prev => prev - 1) const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }}
Voici comment le tester avec renderHook
:
import { renderHook, act } from '@testing-library/react'import { expect, describe, it } from 'vitest'import { useCounter } from './useCounter'
describe('useCounter hook', () => { it('should initialize with default value', () => { const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0) })
it('should initialize with custom value', () => { const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10) })
it('should increment the counter', () => { const { result } = renderHook(() => useCounter())
act(() => { result.current.increment() })
expect(result.current.count).toBe(1) })
it('should decrement the counter', () => { const { result } = renderHook(() => useCounter())
act(() => { result.current.decrement() })
expect(result.current.count).toBe(-1) })
it('should reset the counter', () => { const { result } = renderHook(() => useCounter(5))
act(() => { result.current.increment() result.current.increment() })
expect(result.current.count).toBe(7)
act(() => { result.current.reset() })
expect(result.current.count).toBe(5) })})
Comprendre l’Objet Result
La fonction renderHook
renvoie un objet avec plusieurs propriétés utiles :
const { result, rerender, unmount } = renderHook(() => useCounter())
result.current
: Accès à la valeur de retour actuelle du hookrerender
: Fonction pour forcer le rendu du hook avec de nouveaux propsunmount
: Fonction pour simuler le démontage du composant
Tester les Mises à Jour de Props
Pour tester comment un hook réagit aux changements de props :
import { useState, useEffect } from 'react'
export function useValue(initialValue: string) { const [value, setValue] = useState(initialValue)
useEffect(() => { setValue(initialValue) }, [initialValue])
return value}
// src/hooks/useValue.test.tsimport { renderHook } from '@testing-library/react'import { expect, describe, it } from 'vitest'import { useValue } from './useValue'
describe('useValue hook', () => { it('should update when props change', () => { const { result, rerender } = renderHook((props) => useValue(props), { initialProps: 'initial', })
expect(result.current).toBe('initial')
// Rerendre avec de nouvelles props rerender('updated')
expect(result.current).toBe('updated') })})
Tester les Hooks avec useEffect
Pour tester des hooks qui utilisent useEffect
:
import { useState, useEffect } from 'react'
export function useFetch(url: string) { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null)
useEffect(() => { let isMounted = true
const fetchData = async () => { try { setLoading(true) const response = await fetch(url) const json = await response.json()
if (isMounted) { setData(json) setError(null) } } catch (err) { if (isMounted) { setError(err) setData(null) } } finally { if (isMounted) { setLoading(false) } } }
fetchData()
return () => { isMounted = false } }, [url])
return { data, loading, error }}
Test de ce hook avec des mocks :
import { renderHook, waitFor } from '@testing-library/react'import { vi, expect, describe, it, beforeEach } from 'vitest'import { useFetch } from './useFetch'
// Mock global fetchconst mockFetch = vi.fn()global.fetch = mockFetch
describe('useFetch hook', () => { beforeEach(() => { vi.resetAllMocks() })
it('should fetch data successfully', async () => { const mockData = { id: 1, name: 'Test' } mockFetch.mockResolvedValueOnce({ json: async () => mockData, })
const { result } = renderHook(() => useFetch('https://api.example.com/data'))
// Initialement en chargement expect(result.current.loading).toBe(true) expect(result.current.data).toBe(null) expect(result.current.error).toBe(null)
// Attendre la résolution de la promesse await waitFor(() => { expect(result.current.loading).toBe(false) })
// Vérifier les valeurs après chargement expect(result.current.data).toEqual(mockData) expect(result.current.error).toBe(null) expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data') })
it('should handle fetch error', async () => { const mockError = new Error('Network error') mockFetch.mockRejectedValueOnce(mockError)
const { result } = renderHook(() => useFetch('https://api.example.com/data'))
// Attendre la résolution de la promesse await waitFor(() => { expect(result.current.loading).toBe(false) })
// Vérifier la gestion d'erreur expect(result.current.data).toBe(null) expect(result.current.error).toBe(mockError) })
it('should refetch when URL changes', async () => { // Premier appel mockFetch.mockResolvedValueOnce({ json: async () => ({ id: 1 }), })
const { result, rerender } = renderHook((url) => useFetch(url), { initialProps: 'https://api.example.com/data/1', })
// Attendre le premier chargement await waitFor(() => { expect(result.current.loading).toBe(false) })
expect(result.current.data).toEqual({ id: 1 })
// Configurer la réponse pour le deuxième appel mockFetch.mockResolvedValueOnce({ json: async () => ({ id: 2 }), })
// Changer l'URL rerender('https://api.example.com/data/2')
// Devrait retourner en état de chargement expect(result.current.loading).toBe(true)
// Attendre le second chargement await waitFor(() => { expect(result.current.loading).toBe(false) })
expect(result.current.data).toEqual({ id: 2 }) expect(mockFetch).toHaveBeenCalledTimes(2) expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://api.example.com/data/2') })})
Tester les Hooks avec Contexte
Pour tester un hook qui utilise le contexte React :
import { createContext, useContext, useState, ReactNode } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextProps { theme: Theme toggleTheme: () => void}
const ThemeContext = createContext<ThemeContextProps | undefined>(undefined)
export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setTheme] = useState<Theme>('light')
const toggleTheme = () => { setTheme(prev => prev === 'light' ? 'dark' : 'light') }
return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> )}
export function useTheme() { const context = useContext(ThemeContext)
if (context === undefined) { throw new Error('useTheme must be used within a ThemeProvider') }
return context}
Test du hook useTheme
:
import { renderHook, act } from '@testing-library/react'import { vi, expect, describe, it } from 'vitest'import { ThemeProvider, useTheme } from './ThemeContext'
describe('useTheme hook', () => { it('should throw error when used outside ThemeProvider', () => { // Capturer les erreurs lors du rendu du hook const spy = vi.spyOn(console, 'error') spy.mockImplementation(() => {})
expect(() => { renderHook(() => useTheme()) }).toThrow('useTheme must be used within a ThemeProvider')
spy.mockRestore() })
it('should provide theme context', () => { const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider, })
expect(result.current.theme).toBe('light') })
it('should toggle theme', () => { const { result } = renderHook(() => useTheme(), { wrapper: ThemeProvider, })
expect(result.current.theme).toBe('light')
act(() => { result.current.toggleTheme() })
expect(result.current.theme).toBe('dark')
act(() => { result.current.toggleTheme() })
expect(result.current.theme).toBe('light') })})
Wrapper personnalisé pour plusieurs contextes
Pour tester des hooks qui utilisent plusieurs contextes, vous pouvez créer un wrapper personnalisé :
import { useTheme } from '../context/ThemeContext'import { useUser } from '../context/UserContext'
export function useUserPreferences() { const { theme, toggleTheme } = useTheme() const { user } = useUser()
return { theme, toggleTheme, userName: user?.name, prefersDarkMode: user?.preferences?.darkMode || false, }}
// src/hooks/useUserPreferences.test.tsximport { renderHook, act } from '@testing-library/react'import { FC, ReactNode } from 'react'import { expect, describe, it } from 'vitest'import { ThemeProvider } from '../context/ThemeContext'import { UserProvider } from '../context/UserContext'import { useUserPreferences } from './useUserPreferences'
// Wrapper combinant plusieurs providersconst AllProviders: FC<{ children: ReactNode }> = ({ children }) => { return ( <UserProvider initialUser={{ name: 'John', preferences: { darkMode: true } }}> <ThemeProvider> {children} </ThemeProvider> </UserProvider> )}
describe('useUserPreferences hook', () => { it('should combine data from multiple contexts', () => { const { result } = renderHook(() => useUserPreferences(), { wrapper: AllProviders, })
expect(result.current.theme).toBe('light') expect(result.current.userName).toBe('John') expect(result.current.prefersDarkMode).toBe(true) })
it('should toggle theme while preserving user data', () => { const { result } = renderHook(() => useUserPreferences(), { wrapper: AllProviders, })
act(() => { result.current.toggleTheme() })
expect(result.current.theme).toBe('dark') expect(result.current.userName).toBe('John') })})
Tests Avancés pour Custom Hooks
Tester le cleanup des hooks
Pour vérifier que votre hook nettoie correctement les ressources :
import { useEffect, RefObject } from 'react'
export function useEventListener<T extends HTMLElement = HTMLElement>( eventName: string, handler: (event: Event) => void, element?: RefObject<T>) { useEffect(() => { const targetElement = element?.current || window
targetElement.addEventListener(eventName, handler)
return () => { targetElement.removeEventListener(eventName, handler) } }, [eventName, handler, element])}
// src/hooks/useEventListener.test.tsimport { renderHook } from '@testing-library/react'import { vi, expect, describe, it } from 'vitest'import { useEventListener } from './useEventListener'import { useRef } from 'react'
describe('useEventListener hook', () => { it('should add event listener to window', () => { const handler = vi.fn() const addEventListener = vi.spyOn(window, 'addEventListener') const removeEventListener = vi.spyOn(window, 'removeEventListener')
const { unmount } = renderHook(() => useEventListener('click', handler))
expect(addEventListener).toHaveBeenCalledWith('click', handler)
unmount()
expect(removeEventListener).toHaveBeenCalledWith('click', handler) })
it('should add event listener to specific element', () => { const handler = vi.fn() const element = document.createElement('div') const addEventListener = vi.spyOn(element, 'addEventListener') const removeEventListener = vi.spyOn(element, 'removeEventListener')
const { unmount } = renderHook(() => { const ref = { current: element } useEventListener('click', handler, ref) })
expect(addEventListener).toHaveBeenCalledWith('click', handler)
unmount()
expect(removeEventListener).toHaveBeenCalledWith('click', handler) })})
Tester les dépendances du useEffect
Pour vérifier que le hook réagit correctement aux changements de dépendances :
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value) }, delay)
return () => { clearTimeout(timer) } }, [value, delay])
return debouncedValue}
// src/hooks/useDebounce.test.tsimport { renderHook, act } from '@testing-library/react'import { vi, expect, describe, it, beforeEach } from 'vitest'import { useDebounce } from './useDebounce'
describe('useDebounce hook', () => { beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('should update value after delay', () => { const { result, rerender } = renderHook( ({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 500 } } )
expect(result.current).toBe('initial')
// Changer la valeur rerender({ value: 'changed', delay: 500 })
// La valeur ne doit pas changer immédiatement expect(result.current).toBe('initial')
// Avancer dans le temps act(() => { vi.advanceTimersByTime(499) })
// Toujours pas changé juste avant le délai expect(result.current).toBe('initial')
act(() => { vi.advanceTimersByTime(1) })
// Maintenant ça doit être mis à jour expect(result.current).toBe('changed') })
it('should reset timeout on value change', () => { const { result, rerender } = renderHook( ({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 500 } } )
// Changer la valeur après 400ms act(() => { vi.advanceTimersByTime(400) }) rerender({ value: 'changed', delay: 500 })
// Avancer encore de 400ms (total: 800ms depuis le début, mais seulement 400ms depuis le changement) act(() => { vi.advanceTimersByTime(400) })
// La valeur ne doit pas encore être changée expect(result.current).toBe('initial')
// Compléter le délai pour la seconde valeur act(() => { vi.advanceTimersByTime(100) })
// Maintenant ça doit être mis à jour expect(result.current).toBe('changed') })
it('should respect new delay parameter', () => { const { result, rerender } = renderHook( ({ value, delay }) => useDebounce(value, delay), { initialProps: { value: 'initial', delay: 500 } } )
// Changer la valeur et le délai rerender({ value: 'changed', delay: 1000 })
// Avancer de 500ms (ancien délai) act(() => { vi.advanceTimersByTime(500) })
// Pas encore changé avec le nouveau délai expect(result.current).toBe('initial')
// Avancer jusqu'au nouveau délai act(() => { vi.advanceTimersByTime(500) })
// Maintenant ça doit être mis à jour expect(result.current).toBe('changed') })})
Bonnes Pratiques
- Isolez vos tests : Testez un seul aspect du hook par test
- Utilisez toujours
act()
pour les mises à jour d’état - Nettoyez les mocks entre les tests
- Testez les edge cases et les conditions d’erreur
- Vérifiez le nettoyage des ressources en utilisant
unmount()
- Simulez les timeouts avec
vi.useFakeTimers()
- Créez des wrappers réutilisables pour tester les hooks qui dépendent de contextes