Passer au contenu

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 :

  1. Timing : Les tests peuvent s’exécuter trop rapidement, avant que les opérations asynchrones ne soient terminées
  2. Ordre d’exécution : Les tests peuvent ne pas s’exécuter dans l’ordre prévu
  3. Mises à jour d’état : Les mises à jour d’état React peuvent être différées par rapport aux appels asynchrones
  4. 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 :

src/components/UserList/UserList.tsx
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

src/components/UserList/UserList.test.tsx
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 API
vi.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 :

src/components/UserSearch/UserSearch.tsx
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 :

src/components/UserSearch/UserSearch.test.tsx
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 :

src/components/AutoRefresh/AutoRefresh.tsx
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() :

src/components/AutoRefresh/AutoRefresh.test.tsx
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 promesse
it('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

  1. Utilisez findBy pour les cas simples : C’est l’approche la plus concise et lisible.

  2. Préférez waitFor pour les scénarios complexes : Quand vous devez attendre plusieurs conditions ou vérifier l’absence d’éléments.

  3. Soyez explicite avec les assertions : Vérifiez bien que les états intermédiaires (chargement, erreur) se comportent comme prévu.

  4. Nettoyez les mocks et timers : Utilisez beforeEach/afterEach pour réinitialiser l’état.

  5. Définissez des timeouts appropriés : Adaptez les timeouts au comportement attendu de votre application.

  6. Isolez les dépendances externes : Mockez toujours les API, fetch, et autres appels réseau.

  7. Utilisez act() correctement : Enveloppez les opérations qui déclenchent des mises à jour React.

  8. 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 :

  1. 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
  1. 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()
})
  1. 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.