Tests Asynchrones avec React et Vitest
Les applications React modernes comportent souvent des opérations asynchrones : appels API, chargement de données, animations et transitions. Tester ce comportement asynchrone nécessite des techniques spécifiques pour s’assurer que vos tests sont fiables et précis.
Comprendre les Défis des Tests Asynchrones
Les tests asynchrones présentent plusieurs défis particuliers :
- Timing : Les tests peuvent s’exécuter trop rapidement, avant que les opérations asynchrones ne soient terminées
- Ordre d’exécution : Les tests peuvent ne pas s’exécuter dans l’ordre prévu
- Mises à jour d’état : Les mises à jour d’état React peuvent être différées par rapport aux appels asynchrones
- Conditions de course : Les tests peuvent souffrir de conditions de course entre différentes parties de l’application
Outils pour les Tests Asynchrones
React Testing Library fournit plusieurs outils pour gérer ces défis :
- waitFor : Attend qu’une condition soit satisfaite
- findBy* : Variantes asynchrones des requêtes qui attendent l’apparition des éléments
- act : Enveloppe les mises à jour React pour s’assurer qu’elles sont appliquées avant de continuer
Tester un Composant de Chargement de Données
Voici un exemple de composant qui charge des données d’une API :
import { useState, useEffect } from 'react'import { fetchUsers } from '../../api/users'
interface User { id: number name: string}
export function UserList() { const [users, setUsers] = useState<User[]>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState<string | null>(null)
useEffect(() => { let isMounted = true
const loadUsers = async () => { try { setLoading(true) const data = await fetchUsers()
if (isMounted) { setUsers(data) setError(null) } } catch (err) { if (isMounted) { setError('Erreur lors du chargement des utilisateurs') setUsers([]) } } finally { if (isMounted) { setLoading(false) } } }
loadUsers()
return () => { isMounted = false } }, [])
if (loading) { return <div data-testid="loading">Chargement en cours...</div> }
if (error) { return <div data-testid="error">{error}</div> }
return ( <ul data-testid="user-list"> {users.map(user => ( <li key={user.id} data-testid={`user-item-${user.id}`}> {user.name} </li> ))} </ul> )}
Test avec findBy
import { render, screen } from '@testing-library/react'import { vi, expect, describe, it, beforeEach } from 'vitest'import { UserList } from './UserList'import * as api from '../../api/users'
// Mock du module APIvi.mock('../../api/users', () => ({ fetchUsers: vi.fn(),}))
describe('UserList', () => { beforeEach(() => { vi.resetAllMocks() })
it('affiche les utilisateurs après le chargement', async () => { // Configurer le mock pour retourner des données de test vi.mocked(api.fetchUsers).mockResolvedValueOnce([ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ])
render(<UserList />)
// Vérifier l'état de chargement initial expect(screen.getByTestId('loading')).toBeInTheDocument()
// Attendre que les utilisateurs apparaissent (findBy est asynchrone) const userList = await screen.findByTestId('user-list')
// Maintenant que le chargement est terminé, vérifier le résultat expect(userList).toBeInTheDocument() expect(screen.getByText('Alice')).toBeInTheDocument() expect(screen.getByText('Bob')).toBeInTheDocument()
// Vérifier que l'indicateur de chargement a disparu expect(screen.queryByTestId('loading')).not.toBeInTheDocument() })
it('affiche une erreur si le chargement échoue', async () => { // Simuler une erreur vi.mocked(api.fetchUsers).mockRejectedValueOnce(new Error('API error'))
render(<UserList />)
// Attendre que le message d'erreur apparaisse const errorMessage = await screen.findByTestId('error')
expect(errorMessage).toHaveTextContent('Erreur lors du chargement des utilisateurs') expect(screen.queryByTestId('loading')).not.toBeInTheDocument() expect(screen.queryByTestId('user-list')).not.toBeInTheDocument() })})
Les méthodes findBy*
sont parfaites pour les tests simples car elles combinent :
- Une requête (comme
getBy*
) - Un
waitFor
implicite qui attend que l’élément apparaisse - Un timeout (par défaut 1000ms) après lequel le test échoue
Tester avec waitFor
Pour des scénarios plus complexes où vous devez attendre qu’une condition particulière soit remplie :
import { useState } from 'react'import { searchUsers } from '../../api/users'
export function UserSearch() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [loading, setLoading] = useState(false)
const handleSearch = async () => { if (!query.trim()) return
setLoading(true) try { // Simuler un délai pour le chargement const data = await searchUsers(query) setResults(data) } catch (error) { console.error(error) setResults([]) } finally { setLoading(false) } }
return ( <div> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Rechercher des utilisateurs" data-testid="search-input" /> <button onClick={handleSearch} disabled={loading || !query.trim()} data-testid="search-button" > {loading ? 'Recherche...' : 'Rechercher'} </button>
{loading && <div data-testid="loading-indicator">Chargement...</div>}
{!loading && results.length > 0 && ( <ul data-testid="results-list"> {results.map(user => ( <li key={user.id} data-testid={`result-${user.id}`}>{user.name}</li> ))} </ul> )}
{!loading && query.trim() && results.length === 0 && ( <div data-testid="no-results">Aucun résultat trouvé</div> )} </div> )}
Test avec waitFor
:
import { render, screen, fireEvent, waitFor } from '@testing-library/react'import userEvent from '@testing-library/user-event'import { vi, expect, describe, it, beforeEach } from 'vitest'import { UserSearch } from './UserSearch'import * as api from '../../api/users'
vi.mock('../../api/users', () => ({ searchUsers: vi.fn(),}))
describe('UserSearch', () => { beforeEach(() => { vi.resetAllMocks() })
it('affiche les résultats après une recherche réussie', async () => { const mockResults = [ { id: 1, name: 'Alice Smith' }, { id: 2, name: 'Alice Johnson' }, ]
vi.mocked(api.searchUsers).mockResolvedValueOnce(mockResults)
render(<UserSearch />)
// Configuration du user-event const user = userEvent.setup()
// Saisir le terme de recherche await user.type(screen.getByTestId('search-input'), 'Alice')
// Cliquer sur le bouton de recherche await user.click(screen.getByTestId('search-button'))
// Vérifier que l'indicateur de chargement s'affiche expect(screen.getByTestId('loading-indicator')).toBeInTheDocument()
// Attendre que les résultats s'affichent await waitFor(() => { expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument() })
// Vérifier les résultats expect(screen.getByTestId('results-list')).toBeInTheDocument() expect(screen.getByText('Alice Smith')).toBeInTheDocument() expect(screen.getByText('Alice Johnson')).toBeInTheDocument()
// Vérifier que la fonction de recherche a été appelée avec le bon argument expect(api.searchUsers).toHaveBeenCalledWith('Alice') })
it('affiche un message lorsqu\'aucun résultat n\'est trouvé', async () => { // Mock renvoyant un tableau vide vi.mocked(api.searchUsers).mockResolvedValueOnce([])
render(<UserSearch />)
// Utiliser fireEvent comme alternative à userEvent fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'NonExistentUser' } })
fireEvent.click(screen.getByTestId('search-button'))
// Attendre que le message de "aucun résultat" apparaisse await waitFor(() => { expect(screen.getByTestId('no-results')).toBeInTheDocument() })
expect(screen.getByTestId('no-results')).toHaveTextContent('Aucun résultat trouvé') expect(screen.queryByTestId('results-list')).not.toBeInTheDocument() })
it('gère un délai plus long pour la recherche', async () => { // Créer une promesse qu'on résoudra manuellement let resolvePromise const promise = new Promise(resolve => { resolvePromise = resolve })
vi.mocked(api.searchUsers).mockReturnValueOnce(promise)
render(<UserSearch />)
fireEvent.change(screen.getByTestId('search-input'), { target: { value: 'SlowQuery' } })
fireEvent.click(screen.getByTestId('search-button'))
// Vérifier l'état de chargement expect(screen.getByTestId('loading-indicator')).toBeInTheDocument()
// Résoudre la promesse après un délai setTimeout(() => { resolvePromise([{ id: 1, name: 'Slow Result' }]) }, 100)
// Attendre que le résultat s'affiche, avec un timeout plus long si nécessaire await waitFor(() => { expect(screen.getByText('Slow Result')).toBeInTheDocument() }, { timeout: 1000 })
// L'indicateur de chargement devrait avoir disparu expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument() })})
Contrôle du Temps avec Vitest
Vitest permet de contrôler le temps pour les tests impliquant des timeouts ou des intervalles :
import { useState, useEffect } from 'react'import { fetchLatestData } from '../../api/data'
export function AutoRefresh() { const [data, setData] = useState(null) const [lastUpdated, setLastUpdated] = useState(null) const [loading, setLoading] = useState(false)
const refreshData = async () => { setLoading(true) try { const newData = await fetchLatestData() setData(newData) setLastUpdated(new Date()) } catch (error) { console.error(error) } finally { setLoading(false) } }
useEffect(() => { // Charger initialement refreshData()
// Rafraîchir toutes les 60 secondes const interval = setInterval(refreshData, 60000)
return () => clearInterval(interval) }, [])
return ( <div> {loading && <div data-testid="loading">Mise à jour...</div>}
<div data-testid="data-display"> {data ? JSON.stringify(data) : 'Aucune donnée'} </div>
{lastUpdated && ( <div data-testid="last-updated"> Dernière mise à jour: {lastUpdated.toLocaleTimeString()} </div> )}
<button onClick={refreshData} disabled={loading} data-testid="refresh-button" > Rafraîchir maintenant </button> </div> )}
Test avec vi.useFakeTimers()
:
import { render, screen, waitFor, act } from '@testing-library/react'import userEvent from '@testing-library/user-event'import { vi, expect, describe, it, beforeEach, afterEach } from 'vitest'import { AutoRefresh } from './AutoRefresh'import * as api from '../../api/data'
vi.mock('../../api/data', () => ({ fetchLatestData: vi.fn(),}))
describe('AutoRefresh', () => { beforeEach(() => { vi.resetAllMocks() // Utiliser des timers simulés vi.useFakeTimers() })
afterEach(() => { // Restaurer les timers réels vi.useRealTimers() })
it('charge les données au montage et affiche la dernière mise à jour', async () => { const mockData = { value: 'initial data' } vi.mocked(api.fetchLatestData).mockResolvedValueOnce(mockData)
// Fixer la date pour des tests déterministes const fixedDate = new Date('2023-01-01T12:00:00') vi.setSystemTime(fixedDate)
render(<AutoRefresh />)
// Attendre que le chargement initial soit terminé await waitFor(() => { expect(screen.queryByTestId('loading')).not.toBeInTheDocument() })
// Vérifier que les données sont affichées expect(screen.getByTestId('data-display')).toHaveTextContent('initial data') expect(screen.getByTestId('last-updated')).toBeInTheDocument()
// La fonction fetchLatestData devrait être appelée une fois au montage expect(api.fetchLatestData).toHaveBeenCalledTimes(1) })
it('rafraîchit automatiquement les données après 60 secondes', async () => { // Premier chargement vi.mocked(api.fetchLatestData).mockResolvedValueOnce({ value: 'data 1' })
render(<AutoRefresh />)
// Attendre le chargement initial await waitFor(() => { expect(screen.getByTestId('data-display')).toHaveTextContent('data 1') })
// Configurer la réponse pour le second appel vi.mocked(api.fetchLatestData).mockResolvedValueOnce({ value: 'data 2' })
// Avancer dans le temps de 60 secondes act(() => { vi.advanceTimersByTime(60000) })
// Attendre la mise à jour await waitFor(() => { expect(screen.getByTestId('data-display')).toHaveTextContent('data 2') })
// Vérifier que la fonction a été appelée deux fois expect(api.fetchLatestData).toHaveBeenCalledTimes(2) })
it('permet le rafraîchissement manuel', async () => { // Configuration du user-event const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
// Premier chargement vi.mocked(api.fetchLatestData).mockResolvedValueOnce({ value: 'initial' })
render(<AutoRefresh />)
// Attendre le chargement initial await waitFor(() => { expect(screen.getByTestId('data-display')).toHaveTextContent('initial') })
// Préparer la deuxième réponse vi.mocked(api.fetchLatestData).mockResolvedValueOnce({ value: 'refreshed' })
// Cliquer sur le bouton de rafraîchissement await user.click(screen.getByTestId('refresh-button'))
// Vérifier l'état de chargement expect(screen.getByTestId('loading')).toBeInTheDocument()
// Attendre la mise à jour await waitFor(() => { expect(screen.queryByTestId('loading')).not.toBeInTheDocument() })
// Vérifier que les données ont été mises à jour expect(screen.getByTestId('data-display')).toHaveTextContent('refreshed')
// La fonction devrait avoir été appelée deux fois expect(api.fetchLatestData).toHaveBeenCalledTimes(2) })})
Tester les Rejets de Promesses
Pour tester la gestion des erreurs dans le code asynchrone :
// Exemple de test pour un rejet de promesseit('gère les erreurs lors du chargement des données', async () => { // Simuler une erreur vi.mocked(api.fetchLatestData).mockRejectedValueOnce(new Error('API failure'))
// Espionner console.error pour éviter de polluer les logs de test const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
render(<AutoRefresh />)
// Attendre que le chargement se termine, même s'il y a une erreur await waitFor(() => { expect(screen.queryByTestId('loading')).not.toBeInTheDocument() })
// Vérifier que l'erreur a été capturée par console.error expect(consoleErrorSpy).toHaveBeenCalled()
// Vérifier que le composant affiche un état par défaut expect(screen.getByTestId('data-display')).toHaveTextContent('Aucune donnée')
// Nettoyer le spy consoleErrorSpy.mockRestore()})
Bonnes Pratiques pour les Tests Asynchrones
-
Utilisez findBy pour les cas simples : C’est l’approche la plus concise et lisible.
-
Préférez waitFor pour les scénarios complexes : Quand vous devez attendre plusieurs conditions ou vérifier l’absence d’éléments.
-
Soyez explicite avec les assertions : Vérifiez bien que les états intermédiaires (chargement, erreur) se comportent comme prévu.
-
Nettoyez les mocks et timers : Utilisez beforeEach/afterEach pour réinitialiser l’état.
-
Définissez des timeouts appropriés : Adaptez les timeouts au comportement attendu de votre application.
-
Isolez les dépendances externes : Mockez toujours les API, fetch, et autres appels réseau.
-
Utilisez act() correctement : Enveloppez les opérations qui déclenchent des mises à jour React.
-
Prévoyez les conditions de course : Testez des scénarios où plusieurs opérations asynchrones se produisent en parallèle.
Déboguer les Tests Asynchrones
Si vous rencontrez des problèmes avec vos tests asynchrones :
- Utilisez des timeouts plus longs : Parfois, les opérations prennent plus de temps que prévu.
await waitFor(() => { expect(screen.getByText('Résultat')).toBeInTheDocument()}, { timeout: 5000 }) // Attendre jusqu'à 5 secondes
- Journalisez les états intermédiaires : Ajoutez des console.log dans vos tests.
await waitFor(() => { console.log('État actuel:', screen.debug()) expect(screen.getByText('Résultat')).toBeInTheDocument()})
- Utilisez prettyDOM pour inspecter le HTML :
import { prettyDOM } from '@testing-library/react'
console.log(prettyDOM(screen.getByTestId('container')))
Conclusion
Les tests asynchrones sont essentiels pour vérifier le comportement de vos applications React dans des scénarios réels. En utilisant les outils fournis par Vitest et React Testing Library, vous pouvez créer des tests fiables et expressifs qui détectent les problèmes avant qu’ils n’atteignent la production.
L’approche “attendre puis vérifier” est au cœur des tests asynchrones efficaces. Les fonctions waitFor
et findBy*
vous permettent d’attendre que votre application soit dans l’état souhaité avant de vérifier son comportement, ce qui rend vos tests plus robustes face aux variations de timing.