Tests Asynchrones & MSW

findBy, waitFor, mocker les appels réseau, React Query, Formulaires & Routing

Utilisez les flèches, cliquez ou glissez pour naviguer

Objectifs de la leçon

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

Plan du cours

1

Le défi des tests async

Pourquoi findBy et waitFor existent

2

MSW (Mock Service Worker)

Intercepter les requêtes réseau dans les tests

3

Tester un composant useQuery

Wrapper QueryClientProvider

4

Tester React Hook Form

Remplir, valider, soumettre

5

Live coding : CRUD complet

List + Create + Delete avec routing

Le défi des tests async

Les queries getBy* ne suffisent plus

Quand le contenu arrive après le render...

getBy throw immédiatement!

Le problème avec getBy*

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* vs findBy*

❌ 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* en action

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*

Variantes de 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*

waitFor

Pour les cas plus complexes

Attendre qu'une assertion passe

Plus flexible que findBy*

Quand utiliser waitFor ?

✅ findBy*

Un seul élément à attendre

await screen.findByText('Loaded')

✅ waitFor

Assertions complexes ou multiples

await waitFor(() => {

expect(screen.getByRole('alert'))

.toBeInTheDocument()

})

Exemples avec waitFor

// 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)

Configurer le timeout

// 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

MSW — Mock Service Worker

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

Pourquoi MSW ?

❌ 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

Installation

# 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

Structure de base

// src/mocks/handlers.js

import { http, HttpResponse } from 'msw'

export const handlers = [

http.get('/api/users', () => {

return HttpResponse.json([

{ id: 1, name: 'John Doe' }

])

}),

]

Setup du server pour les tests

// 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

Setup dans les tests

// 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)

Handlers CRUD complets

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 })

})

]

Override handlers par test

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)

Tester React Query

Le défi du QueryClientProvider

useQuery a besoin d'un QueryClientProvider

Mais pas dans les tests par défaut!

Le problème

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

Solution : Wrapper avec providers

// 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>

)

}

⚠️ QueryClient FRAIS à chaque test

❌ 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

Test complet avec React Query

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

Tester React Hook Form

Remplir, valider, soumettre

userEvent pour simuler les interactions utilisateur

userEvent vs fireEvent

❌ 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

Setup userEvent

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

Remplir un formulaire

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')

Tester la validation

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

Tester la soumission

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'

})

})

Tester React Router

MemoryRouter et navigation

Le routeur a besoin d'un contexte

Wrapper avec MemoryRouter

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

Tester la navigation

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

Live Coding

Tester une page CRUD complète

Liste + Création + Suppression + Navigation

Tous les concepts en un seul test

Setup complet

// 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>

)

}

Test 1 : Afficher la liste

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()

})

Test 2 : Créer un utilisateur

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()

})

Test 3 : Supprimer un utilisateur

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()

})

})

Test 4 : Navigation entre pages

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()

})

Pièges courants

Les erreurs à éviter absolument

4 erreurs qui font échouer vos tests

❌ Piège 1 : Utiliser getBy pour du contenu async

❌ Mauvais

render(<UserList />)

screen.getByText('John')

// Error: Unable to find element

✅ Correct

render(<UserList />)

await screen.findByText('John')

// ✅ Attend que l'élément apparaisse

❌ Piège 2 : Ne pas attendre la disparition du loading

❌ Mauvais

render(<UserList />)

expect(screen.getByText('John'))

// Le loading est encore affiché!

✅ Correct

await waitFor(() => {

expect(screen.queryByText('Loading'))

.not.toBeInTheDocument()

})

❌ Piège 3 : Oublier server.close() et resetHandlers()

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

❌ Piège 4 : QueryClient non frais entre les tests

❌ 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!

À retenir!

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

Récapitulatif des outils

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 })

Questions?

Prêt pour le live coding!

On va tester ensemble une page CRUD complète

avec tous les concepts vus aujourd'hui