findBy, waitFor, mocker les appels réseau, React Query, Formulaires & Routing
Utilisez les flèches, cliquez ou glissez pour naviguer
1. findBy et waitFor
Tester du contenu asynchrone sans erreur
2. MSW (Mock Service Worker)
Intercepter les appels réseau dans les tests
3. Tester React Query
Wrapper avec QueryClientProvider
4. Tester React Hook Form
Remplir, valider, soumettre
5. Tester la navigation React Router
MemoryRouter et navigation entre pages
Le défi des tests async
Pourquoi findBy et waitFor existent
MSW (Mock Service Worker)
Intercepter les requêtes réseau dans les tests
Tester un composant useQuery
Wrapper QueryClientProvider
Tester React Hook Form
Remplir, valider, soumettre
Live coding : CRUD complet
List + Create + Delete avec routing
Les queries getBy* ne suffisent plus
Quand le contenu arrive après le render...
getBy throw immédiatement!
getBy* cherche l'élément immédiatement
Si l'élément n'est pas là → erreur instantanée
// ❌ Ce test échoue!
render(<UserList />);
const user = screen.getByText('John Doe');
// Error: Unable to find an element with the text: John Doe
⚠️ Pourquoi ça échoue ?
L'API call n'est pas encore terminé quand getBy s'exécute
❌ getBy*
Recherche synchrone
• Cherche immédiatement
• Throw si non trouvé
• Pas d'attente
screen.getByText('Data')
✅ findBy*
Recherche asynchrone
• Attend que l'élément apparaisse
• Polling jusqu'à 1000ms
• Retourne une Promise
await screen.findByText('Data')
findBy* attend que l'élément apparaisse
Polling toutes les 50ms pendant 1000ms par défaut
// ✅ Ce test passe!
render(<UserList />);
const user = await screen.findByText('John Doe');
// ✅ Attend que l'API call se termine
🎯 Règle simple
Contenu qui vient d'un fetch ? → findBy*
Mêmes variantes que getBy*, mais async
findByText('Hello')
findByRole('button')
findByLabelText('Email')
findByPlaceholderText('Search')
findByAltText('Avatar')
findByTestId('loading')
💡 Toujours utiliser await avec findBy*
waitForPour les cas plus complexes
Attendre qu'une assertion passe
Plus flexible que findBy*
✅ findBy*
Un seul élément à attendre
await screen.findByText('Loaded')
✅ waitFor
Assertions complexes ou multiples
await waitFor(() => {
expect(screen.getByRole('alert'))
.toBeInTheDocument()
})
// Attendre la disparition du loading
await waitFor(() => {
expect(screen.queryByText('Loading...'))
.not.toBeInTheDocument()
})
// Attendre un changement de state
await waitFor(() => {
expect(screen.getByTestId('count'))
.toHaveTextContent('5')
})
💡 queryBy* dans waitFor → retourne null si non trouvé (ne throw pas)
// Timeout par défaut : 1000ms
const user = await screen.findByText('Data', {}, {
timeout: 3000 // 3 secondes
});
// waitFor timeout
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
}, { timeout: 3000 })
⚠️ Utile pour les API lentes en développement
Intercepter les requêtes réseau dans les tests
Le code ne sait pas qu'il est testé
Interception au niveau réseau, pas dans le code
❌ Sans MSW
• Mock manuel de fetch/axios
• Coupler le code aux tests
• Difficile à maintenir
• Pas réaliste
✅ Avec MSW
• Interception réseau réelle
• Code inchangé
• Facile à maintenir
• Tests réalistes
🎯 MSW fonctionne comme un vrai serveur
Même comportement qu'en production
# Installer MSW
npm install -D msw
# Générer le service worker (pour navigateur)
npx msw init public/ --save
💡 Pour les tests Vitest/Jest, on utilise le setup server, pas le service worker navigateur
// src/mocks/handlers.js
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'John Doe' }
])
}),
]
// src/mocks/server.js
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
💡 msw/node = version Node.js pour les tests
Différent du service worker pour le navigateur
// src/test/setup.js
import { server } from '../mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
beforeAll → Démarrer le serveur mock
afterEach → Réinitialiser les handlers (pas de pollution entre tests)
afterAll → Fermer le serveur (cleanup)
export const handlers = [
// GET - Liste des utilisateurs
http.get('/api/users', () => {
return HttpResponse.json(users)
}),
// POST - Créer un utilisateur
http.post('/api/users', async ({ request }) => {
const body = await request.json()
const newUser = { id: Date.now(), ...body }
users.push(newUser)
return HttpResponse.json(newUser, { status: 201 })
}),
// DELETE - Supprimer
http.delete('/api/users/:id', ({ params }) => {
users = users.filter(u => u.id !== Number(params.id))
return new HttpResponse(null, { status: 204 })
})
]
Tester les cas d'erreur sans modifier les handlers globaux
it('affiche une erreur si le serveur échoue', async () => {
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 })
})
)
render(<UserList />)
const error = await screen.findByText('Erreur serveur')
expect(error).toBeInTheDocument()
})
✅ server.use() ajoute un handler temporaire (réinitialisé par afterEach)
Le défi du QueryClientProvider
useQuery a besoin d'un QueryClientProvider
Mais pas dans les tests par défaut!
Erreur classique : No QueryClient set
// ❌ Ce test échoue!
render(<UserList />)
// Error: No QueryClient set, use QueryClientProvider to set one
⚠️ React Query nécessite un contexte QueryClientProvider
// src/test/utils.jsx
import { render } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
function renderWithQueryClient(ui) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false }
}
})
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
)
}
❌ Ne PAS créer le QueryClient en dehors de la fonction
Le cache polluerait les tests suivants!
// ❌ MAUVAIS - QueryClient partagé
const queryClient = new QueryClient() // Hors de la fonction!
function renderWithQueryClient(ui) {
return render(<Wrapper>{ui}</Wrapper>)
}
✅ Créer un NOUVEAU QueryClient dans chaque appel de render
it('affiche la liste des utilisateurs', async () => {
renderWithQueryClient(<UserList />)
// Loading state
expect(screen.getByText('Chargement...')).toBeInTheDocument()
// Success state - findBy car async
const user = await screen.findByText('John Doe')
expect(user).toBeInTheDocument()
})
💡 retry: false dans les tests → pas de retry automatique sur erreur
Remplir, valider, soumettre
userEvent pour simuler les interactions utilisateur
❌ fireEvent
Simule UN événement isolé
• Pas réaliste
• Ignore les behaviors du navigateur
✅ userEvent
Simule l'interaction complète
• Focus, blur, change
• Comportement réaliste
🎯 Toujours utiliser userEvent pour les formulaires
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
it('remplit le formulaire', async () => {
const user = userEvent.setup()
render(<LoginForm />)
await user.type(screen.getByLabelText('Email'), 'test@example.com')
})
💡 userEvent.setup() crée une instance réutilisable
const user = userEvent.setup()
// Remplir un champ texte
await user.type(screen.getByLabelText('Email'), 'test@example.com')
// Effacer un champ
await user.clear(screen.getByLabelText('Email'))
// Cocher une checkbox
await user.click(screen.getByLabelText('Accept terms'))
// Sélectionner dans un select
await user.selectOptions(screen.getByLabelText('Country'), 'France')
it('affiche une erreur si email invalide', async () => {
const user = userEvent.setup()
render(<LoginForm />)
// Remplir avec un email invalide
await user.type(screen.getByLabelText('Email'), 'invalid-email')
await user.click(screen.getByRole('button', { name: 'Envoyer' }))
// Vérifier l'erreur
const error = await screen.findByText('Email invalide')
expect(error).toBeInTheDocument()
})
💡 findByText pour l'erreur car elle apparaît de manière asynchrone
it('soumet le formulaire avec les bonnes données', async () => {
const mockSubmit = vi.fn()
render(<LoginForm onSubmit={mockSubmit} />)
const user = userEvent.setup()
await user.type(screen.getByLabelText('Email'), 'test@example.com')
await user.type(screen.getByLabelText('Password'), 'password123')
await user.click(screen.getByRole('button', { name: 'Envoyer' }))
expect(mockSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
})
})
MemoryRouter et navigation
Le routeur a besoin d'un contexte
import { MemoryRouter } from 'react-router-dom'
function renderWithRouter(ui, { route = '/' } = {}) {
return render(
<MemoryRouter initialEntries={[route]}>
{ui}
</MemoryRouter>
)
}
💡 initialEntries définit l'URL de départ
it('navigue vers la page de détail', async () => {
const user = userEvent.setup()
renderWithRouter(<App />, { route: '/users' })
// Cliquer sur un lien
await user.click(screen.getByRole('link', { name: 'John Doe' }))
// Vérifier le contenu de la nouvelle page
const title = await screen.findByRole('heading', { name: 'Détail utilisateur' })
expect(title).toBeInTheDocument()
})
💡 findByRole car le contenu arrive après la navigation
Tester une page CRUD complète
Liste + Création + Suppression + Navigation
Tous les concepts en un seul test
// src/test/utils.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
export function renderWithProviders(ui, { route = '/' } = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>
{ui}
</MemoryRouter>
</QueryClientProvider>
)
}
it('affiche la liste des utilisateurs', async () => {
renderWithProviders(<UsersPage />)
// Loading
expect(screen.getByText('Chargement...')).toBeInTheDocument()
// Données
const user = await screen.findByText('John Doe')
expect(user).toBeInTheDocument()
})
it('crée un nouvel utilisateur', async () => {
const user = userEvent.setup()
renderWithProviders(<CreateUserPage />)
await user.type(screen.getByLabelText('Nom'), 'Jane Doe')
await user.type(screen.getByLabelText('Email'), 'jane@example.com')
await user.click(screen.getByRole('button', { name: 'Créer' }))
// Vérifier le message de succès
const success = await screen.findByText('Utilisateur créé!')
expect(success).toBeInTheDocument()
})
it('supprime un utilisateur', async () => {
const user = userEvent.setup()
renderWithProviders(<UsersPage />)
// Attendre que la liste soit chargée
await screen.findByText('John Doe')
// Cliquer sur supprimer
await user.click(screen.getByRole('button', { name: 'Supprimer John' }))
// Confirmer
await user.click(screen.getByRole('button', { name: 'Confirmer' }))
// Vérifier la disparition
await waitFor(() => {
expect(screen.queryByText('John Doe')).not.toBeInTheDocument()
})
})
it('navigue vers le formulaire de création', async () => {
const user = userEvent.setup()
renderWithProviders(<App />, { route: '/users' })
// Attendre le chargement
await screen.findByText('Liste des utilisateurs')
// Cliquer sur "Nouvel utilisateur"
await user.click(screen.getByRole('link', { name: 'Nouvel utilisateur' }))
// Vérifier la nouvelle page
const title = await screen.findByRole('heading', { name: 'Créer un utilisateur' })
expect(title).toBeInTheDocument()
})
Les erreurs à éviter absolument
4 erreurs qui font échouer vos tests
❌ Mauvais
render(<UserList />)
screen.getByText('John')
// Error: Unable to find element
✅ Correct
render(<UserList />)
await screen.findByText('John')
// ✅ Attend que l'élément apparaisse
❌ Mauvais
render(<UserList />)
expect(screen.getByText('John'))
// Le loading est encore affiché!
✅ Correct
await waitFor(() => {
expect(screen.queryByText('Loading'))
.not.toBeInTheDocument()
})
Sans cleanup, les tests se polluent entre eux
// ✅ Setup correct
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers()) // ← Important!
afterAll(() => server.close()) // ← Important!
⚠️ Sans resetHandlers(), les overrides d'un test affectent les suivants
❌ Mauvais
// QueryClient global
const queryClient = new QueryClient()
function render() {
return render(<Wrapper />)
}
// Cache pollué!
✅ Correct
// QueryClient frais à chaque render
function renderWithQueryClient() {
const queryClient = new QueryClient()
return render(<Wrapper />)
}
// Cache propre!
findBy*
Attend que l'élément apparaisse (polling)
→ Pour le contenu qui vient d'un fetch
waitFor
Attend qu'une assertion passe
→ Pour les cas complexes
MSW
Intercepte au niveau réseau
→ Le code ne sait pas qu'il est testé
QueryClientProvider
Toujours wrapper React Query
→ Avec un client FRAIS à chaque test
| Besoin | Outil | Exemple |
|---|---|---|
| Élément async | findBy* | await screen.findByText('Data') |
| Assertion complexe | waitFor | await waitFor(() => expect(...)) |
| Mock API | MSW | http.get('/api/users', ...) |
| Tester React Query | QueryClientProvider | renderWithQueryClient() |
| Tester formulaire | userEvent | await user.type(input, 'text') |
| Tester routing | MemoryRouter | renderWithRouter(ui, { route }) |
Prêt pour le live coding!
On va tester ensemble une page CRUD complète
avec tous les concepts vus aujourd'hui