Passer au contenu

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 :

Fenêtre de terminal
npm install -D vitest @testing-library/react @testing-library/react-hooks jsdom

Configurez Vitest pour utiliser l’environnement JSDOM :

vitest.config.ts
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 :

src/hooks/useCounter.ts
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 :

src/hooks/useCounter.test.ts
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 hook
  • rerender : Fonction pour forcer le rendu du hook avec de nouveaux props
  • unmount : 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 :

src/hooks/useValue.ts
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.ts
import { 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 :

src/hooks/useFetch.ts
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 :

src/hooks/useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react'
import { vi, expect, describe, it, beforeEach } from 'vitest'
import { useFetch } from './useFetch'
// Mock global fetch
const 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 :

src/context/ThemeContext.tsx
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 :

src/context/ThemeContext.test.tsx
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é :

src/hooks/useUserPreferences.ts
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.tsx
import { 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 providers
const 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 :

src/hooks/useEventListener.ts
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.ts
import { 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 :

src/hooks/useDebounce.ts
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.ts
import { 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

  1. Isolez vos tests : Testez un seul aspect du hook par test
  2. Utilisez toujours act() pour les mises à jour d’état
  3. Nettoyez les mocks entre les tests
  4. Testez les edge cases et les conditions d’erreur
  5. Vérifiez le nettoyage des ressources en utilisant unmount()
  6. Simulez les timeouts avec vi.useFakeTimers()
  7. Créez des wrappers réutilisables pour tester les hooks qui dépendent de contextes