Testing React avec Vitest

Philosophie, configuration et premiers tests

React Testing Library + Vitest + userEvent

Objectifs de la leçon

1. Comprendre la philosophie

Testing Library : tester le comportement utilisateur

2. Configurer l'environnement

Vitest + jsdom + React Testing Library

3. Maîtriser les queries

getBy, queryBy, findBy — quand utiliser laquelle

4. Simuler les interactions

userEvent pour des tests réalistes

Plan du cours

1. Pourquoi tester ?

Le coût d'un bug en production vs le coût d'un test

2. Testing Library

Tester le COMPORTEMENT, pas l'implémentation

3. Setup complet

Vitest + jsdom + @testing-library/react

4. render, screen, queries

Les 3 types de queries et leurs différences

5. Live Coding

Tester un Button, puis un formulaire avec userEvent

Module 1

Pourquoi tester ?

Le coût d'un bug en production vs le coût d'un test

Le coût d'un bug

Plus un bug est détecté tard, plus il coûte cher

Phase dev

1x

Correction immédiate

QA

5x

Retour en arrière

Staging

15x

Processus de release

Production

100x

Hotfix + réputation

Les tests vous font gagner du temps

Sans tests

  • • Peur de casser quelque chose
  • • Tests manuels répétitifs
  • • Régressions fréquentes
  • • Refactoring risqué

Avec tests

  • • Confiance dans les changements
  • • Détection immédiate des bugs
  • • Documentation vivante
  • • Refactoring serein

Module 2

Testing Library Philosophy

Tester le COMPORTEMENT, pas l'implémentation

Le principe fondamental

"The more your tests resemble the way your software is used,

the more confidence they can give you."

— Kent C. Dodds, créateur de Testing Library

Tester comme un UTILISATEUR

L'utilisateur ne connaît pas vos className, useState ou testId

✗ Mauvais tests

getByTestId('submit-btn')

container.querySelector('.btn')

expect(state.called).toBe(true)

Teste l'implémentation

✓ Bons tests

getByRole('button', {name: /envoyer/i})

getByText('Connexion')

getByLabelText('Email')

Teste ce que l'utilisateur voit

Les priorités de queries

Testing Library recommande cet ordre

1 getByRole() Accessibilité maximale
2 getByLabelText() Formulaires
3 getByText() Contenu textuel
... getByTestId() Dernier recours seulement

Module 3

Setup Vitest + React Testing Library

Configuration complète dans un projet Vite

Installation des packages

npm install -D vitest @vitest/ui jsdom

npm install -D @testing-library/react

npm install -D @testing-library/user-event

npm install -D @testing-library/jest-dom

vitest

Test runner ultra-rapide

jsdom

Simulation du DOM

@testing-library/react

render, screen, queries

user-event

Interactions réalistes

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/setupTests.ts'

}

})

src/setupTests.ts

import '@testing-library/jest-dom'

Matchers supplémentaires disponibles :

  • toBeInTheDocument()
  • toHaveTextContent()
  • toBeVisible()
  • toHaveClass()

package.json — scripts

{

"scripts": {

"test": "vitest",

"test:ui": "vitest --ui",

"test:coverage": "vitest --coverage"

}

}

Module 4

render, screen, queries

Les outils fondamentaux de React Testing Library

render() — Monter le composant

Affiche votre composant dans le DOM virtuel

import { render } from '@testing-library/react'

import Button from './Button'

// Dans le test

render(<Button>Cliquez-moi</Button>)

screen — Accéder au DOM

Objet global contenant toutes les queries

import { screen } from '@testing-library/react'

// Après render()

const button = screen.getByRole('button', { name: /cliquez/i })

screen expose toutes les queries :

screen.getByText(), screen.getByRole(), screen.getByLabelText()...

Les 3 types de queries

Query Si trouvé Si non trouvé Usage
getBy* Retourne l'élément Throw Error Élément présent
queryBy* Retourne l'élément Retourne null Vérifier absence
findBy* Promise (élément) Promise reject Async (attente)

getBy — L'élément DOIT exister

// ✅ Bon usage : vérifier présence

expect(screen.getByText('Bienvenue')).toBeInTheDocument()

// ❌ Si l'élément n'existe pas → Error!

Utilisez getBy quand :

Vous vous attendez à ce que l'élément soit présent immédiatement

queryBy — Vérifier l'absence

// ✅ Bon usage : vérifier absence

expect(screen.queryByText('Erreur')).not.toBeInTheDocument()

// ✅ Retourne null si non trouvé (pas d'erreur)

Utilisez queryBy quand :

Vous voulez vérifier qu'un élément n'est PAS dans le document

findBy — Attendre l'async

// ✅ Bon usage : attendre apparition

const message = await screen.findByText('Chargement terminé')

expect(message).toBeInTheDocument()

Utilisez findBy quand :

L'élément apparaît après un délai (API, animation, setTimeout)

Module 5

Live Coding

Tester un Button, puis un formulaire

Test 1 : Composant Button

Button.tsx

interface ButtonProps {

children: React.ReactNode;

onClick?: () => void;

disabled?: boolean;

}

export function Button({ children, onClick, disabled = false }: ButtonProps) {

return (

<button

onClick={onClick}

disabled={disabled}

className="px-4 py-2 bg-blue-500 text-white rounded"

>

{children}

</button>

)

}

Button.test.tsx

import { render, screen } from '@testing-library/react'

import { test, expect } from 'vitest'

import userEvent from '@testing-library/user-event'

import { Button } from './Button'

test('affiche le texte passé en props', () => {

render(<Button>Cliquez-moi</Button>)

const button = screen.getByRole('button', { name: 'Cliquez-moi' })

expect(button).toBeInTheDocument()

})

userEvent — Interactions réalistes

Simuler les actions de l'utilisateur

import { render, screen } from '@testing-library/react'

import { test, expect, vi } from 'vitest'

import userEvent from '@testing-library/user-event'

import { Button } from './Button'

test('appelle onClick au clic', async () => {

const handleClick = vi.fn()

render(<Button onClick={handleClick}>Cliquez-moi</Button>)

const user = userEvent.setup()

await user.click(screen.getByRole('button'))

expect(handleClick).toHaveBeenCalledTimes(1)

expect(handleClick).toHaveBeenCalledWith()

})

✅ userEvent simule le comportement réel : focus → click → blur

Tests complets du Button

Couvrir tous les cas d'usage

test('est désactivé quand disabled=true', () => {

render(<Button disabled>Disabled</Button>)

const button = screen.getByRole('button')

expect(button).toBeDisabled()

})

test('n\'appelle pas onClick quand désactivé', async () => {

const handleClick = vi.fn()

render(<Button onClick={handleClick} disabled>Click</Button>)

const user = userEvent.setup()

await user.click(screen.getByRole('button'))

expect(handleClick).not.toHaveBeenCalled()

})

test('a les classes CSS appropriées', () => {

render(<Button>Styled</Button>)

const button = screen.getByRole('button')

expect(button).toHaveClass('px-4', 'py-2', 'bg-blue-500')

})

userEvent vs fireEvent

✗ fireEvent

fireEvent.click(element)

  • • Événement synthétique
  • • Ne simule pas tout le workflow
  • • Moins réaliste

✓ userEvent

await user.click(element)

  • • Simule l'interaction complète
  • • Focus, blur, events en cascade
  • • Plus proche du comportement réel

Test 2 : Formulaire de connexion

LoginForm.tsx

export function LoginForm({ onSubmit }) {

return (

<form onSubmit={onSubmit}>

<label>

Email:

<input name="email" type="email" />

</label>

<label>

Mot de passe:

<input name="password" type="password" />

</label>

<button type="submit">Connexion</button>

</form>

)

}

LoginForm.test.tsx

import { render, screen } from '@testing-library/react'

import { test, expect, vi } from 'vitest'

import userEvent from '@testing-library/user-event'

import { LoginForm } from './LoginForm'

test('soumet le formulaire avec les valeurs', async () => {

const handleSubmit = vi.fn()

render(<LoginForm onSubmit={handleSubmit} />)

const user = userEvent.setup()

// Remplir les champs

await user.type(screen.getByLabelText('Email:'), 'test@example.com')

await user.type(screen.getByLabelText('Mot de passe:'), 'secret123')

// Soumettre

await user.click(screen.getByRole('button', {name: 'Connexion'}))

expect(handleSubmit).toHaveBeenCalledWith(expect.any(Object), {

email: 'test@example.com',

password: 'secret123'

})

})

Module 6

Organisation des tests en architecture Feature

Où placer vos tests dans une architecture feature-based

Le problème : Tests éparpillés

Dans une structure classique, les tests sont loin du code

// ❌ Structure classique avec tests séparés

📁 src/

  📁 components/

    📄 Button.tsx

    📄 UserCard.tsx

  📁 hooks/

    📄 useUsers.ts

  📁 services/

    📄 userService.ts

📁 tests/

  📄 Button.test.tsx

  📄 UserCard.test.tsx

  📄 useUsers.test.ts

  📄 userService.test.ts

⚠️ Difficile de trouver les tests d'une feature spécifique

Solution : Tests co-localisés

Placer les tests à côté du code qu'ils testent

// ✅ Feature-based avec tests co-localisés

📁 features/

  📁 users/

    📁 components/

      📄 UserCard.tsx

      📄 UserCard.test.tsx // ← à côté!

      📄 UserForm.tsx

      📄 UserForm.test.tsx // ← à côté!

    📁 hooks/

      📄 useUsers.ts

      📄 useUsers.test.ts // ← à côté!

    📁 services/

      📄 userService.ts

      📄 userService.test.ts // ← à côté!

    📄 types.ts

✅ Tests et code dans le même dossier = facile à trouver et maintenir

Avantages de la co-localisation

🎯 Proximité

  • • Tests visibles quand on modifie le code
  • • Plus facile de mettre à jour les tests
  • • Contexte immédiat du code testé

🚀 Maintenance

  • • Supprimer une feature = supprimer ses tests
  • • Renommer = pas de recherche globale
  • • Refactoring localisé

📋 Organisation

  • • Un dossier = une feature complète
  • • Pas de fichiers orphelins
  • • Structure évidente pour les nouveaux

⚡ Performance

  • • Tests rapides sur une feature spécifique
  • • CI/CD par feature possible

  • • Parallelisation naturelle

Structure complète d'une feature

📁 features/users/

  📁 components/

  │  📄 UserCard.tsx

  │  📄 UserCard.test.tsx

  │  📄 UserList.tsx

  │  📄 UserList.test.tsx

  │  📄 UserForm.tsx

  │  📄 UserForm.test.tsx

  📁 hooks/

  │  📄 useUsers.ts

  │  📄 useUsers.test.ts

  📁 services/

  │  📄 userService.ts

  │  📄 userService.test.ts

  📁 utils/

  │  📄 userHelpers.ts

  │  📄 userHelpers.test.ts

  📄 types.ts

  📄 constants.ts

  📄 index.ts // exports publics

  📄 users.test.ts // tests d'intégration

Tests d'intégration de feature

Tester la feature complète

// features/users/users.test.ts

import { render, screen } from '@testing-library/react'

import { test, expect } from 'vitest'

import { UserList } from './components/UserList'

import { UserProvider } from './hooks/useUsers'

test('affiche la liste complète des utilisateurs', async () => {

render(

<UserProvider>

<UserList />

</UserProvider>

)

expect(screen.getByText('Utilisateurs')).toBeInTheDocument()

})

💡 Teste l'interaction entre tous les composants de la feature

Configuration Vitest pour feature-based

Adapter la configuration pour trouver tous les tests

// vitest.config.ts

export default defineConfig({

test: {

environment: 'jsdom',

globals: true,

setupFiles: './src/setupTests.ts',

include: [

'**/*.{test,spec}.{js,ts,jsx,tsx}'

],

exclude: [

'node_modules',

'dist'

],

}

})

✅ Vitest trouvera automatiquement tous les .test.ts dans features/

Scripts npm pour les tests par feature

Lancer les tests spécifiques à une feature

// package.json

{

"scripts": {

"test": "vitest",

"test:ui": "vitest --ui",

"test:users": "vitest features/users",

"test:products": "vitest features/products",

"test:watch": "vitest --watch",

"test:coverage": "vitest --coverage",

}

}

🚀 npm run test:users ne teste que la feature users!

Best Practices Feature-Based Testing

🏗️ Structure

  • • 1 test file par component/hook/service
  • • Tests d'intégration à la racine de la feature
  • • Index.ts pour les exports publics

🧪 Tests

  • • Unit tests : isolés, rapides
  • • Integration tests : feature complète
  • • Mock des dépendances externes

📦 Maintenance

  • • Supprimer feature = supprimer tous ses tests
  • • Renommer = pas d'impact sur autres features
  • • CI/CD par feature possible

⚡ Performance

  • • Tests parallèles par nature
  • • Run tests par feature uniquement
  • • Coverage ciblé par feature

Pièges courants

Les erreurs à éviter

Piège #1 : Tester l'implémentation

✗ MAUVAIS

expect(component.state.count)

.toBe(5)

Teste les détails internes

✓ CORRECT

expect(screen.getByText('5'))

.toBeInTheDocument()

Teste le RÉSULTAT visible

Piège #2 : Abuser de testId

✗ ÉVITER

screen.getByTestId('btn-submit')

screen.getByTestId('input-email')

screen.getByTestId('error-msg')

testId = dernier recours

✓ PRÉFÉRER

screen.getByRole('button',

{name: /envoyer/i})

screen.getByLabelText('Email')

Queries accessibles

Piège #3 : Oublier await

✗ ERREUR

user.click(button)

// Pas de await → test flaky

userEvent est ASYNC !

✓ CORRECT

await user.click(button)

Toujours await userEvent

Piège #4 : Mauvaise query pour absence

✗ ERREUR

expect(

screen.getByText('Erreur')

).not.toBeInTheDocument()

// Throw si pas trouvé!

✓ CORRECT

expect(

screen.queryByText('Erreur')

).not.toBeInTheDocument()

// Retourne null si absent

Points clés à retenir

Comportement

Testez ce que l'utilisateur VOIT, pas le code interne

Queries

getBy = présent, queryBy = absent, findBy = async

userEvent

Toujours await, plus réaliste que fireEvent

Accessibilité

getByRole > getByText > getByTestId

Feature Tests

Co-localisez tests avec le code dans features/

Organisation

1 test file par composant + tests d'intégration

À retenir !

  • Testez le résultat visible, pas les détails d'implémentation
  • Utilisez getByRole et getByLabelText en priorité
  • userEvent + await pour les interactions
  • queryBy pour vérifier l'absence, findBy pour l'async
  • Co-localisez les tests avec le code dans features/
  • Organisez : unit tests par fichier + integration tests par feature

C'est terminé !

Vous savez maintenant tester vos composants React

Questions ?