Tests React en 2026 : Vitest, React Testing Library et Bonnes Pratiques

Maîtriser les tests React avec Vitest et React Testing Library. Découvrir les patterns de test de composants, la gestion asynchrone, les stratégies de mocking et les bonnes pratiques pour les entretiens techniques en 2026.

Diagramme des tests React en 2026 avec Vitest et React Testing Library

Les tests React en 2026 reposent sur Vitest comme exécuteur de tests dominant et React Testing Library (RTL) comme standard pour les tests de composants. Cette combinaison offre des boucles de feedback rapides, un support natif des modules ES et des tests qui vérifient le comportement plutôt que les détails d'implémentation.

Philosophie des Tests

Écrire des tests qui ressemblent à la façon dont les utilisateurs interagissent avec les composants. Interroger par rôles accessibles, labels et texte—pas par classes CSS ou attributs data-testid. Cette approche détecte les vrais bugs et survit au refactoring.

Configuration de Vitest pour les Projets React

Vitest a remplacé Jest comme exécuteur de tests préféré pour les applications React modernes. Le support natif des modules ES élimine le surcoût de transformation, et l'intégration étroite avec Vite signifie que la configuration reflète l'environnement de développement.

Une configuration Vitest prête pour la production gère la transformation JSX, les alias de chemins et la configuration de l'environnement de test sans boilerplate :

vitest.config.tstypescript
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    // Use jsdom for DOM APIs and React rendering
    environment: 'jsdom',
    // Run setup file before each test file
    setupFiles: ['./src/test/setup.ts'],
    // Include only test files, exclude e2e
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    // Enable global test APIs (describe, it, expect)
    globals: true,
    // Generate coverage reports
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: ['node_modules', 'src/test/**'],
    },
  },
})

Le fichier de setup étend les matchers et configure le nettoyage entre les tests :

src/test/setup.tstypescript
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'

// Clean up rendered components after each test
afterEach(() => {
  cleanup()
})

// Mock window.matchMedia for responsive components
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
})

Cette configuration fournit une base propre pour chaque test et gère les mocks courants des API navigateur que jsdom ne possède pas.

Tests de Composants avec React Testing Library

React Testing Library impose de tester les composants du point de vue de l'utilisateur. Au lieu d'inspecter l'état interne ou les valeurs des props, les tests interagissent avec le rendu via des requêtes d'accessibilité.

Considérons un composant de recherche qui filtre les résultats et affiche les états de chargement :

SearchResults.tsxtsx
import { useState } from 'react'

interface SearchResultsProps {
  onSearch: (query: string) => Promise<string[]>
}

export function SearchResults({ onSearch }: SearchResultsProps) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<string[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async function handleSearch() {
    if (!query.trim()) return
    setIsLoading(true)
    setError(null)
    try {
      const data = await onSearch(query)
      setResults(data)
    } catch (e) {
      setError('Search failed. Please try again.')
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div>
      <label htmlFor="search-input">Search</label>
      <input
        id="search-input"
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button onClick={handleSearch} disabled={isLoading}>
        {isLoading ? 'Searching...' : 'Search'}
      </button>
      {error && <p role="alert">{error}</p>}
      <ul aria-label="Search results">
        {results.map((result, i) => (
          <li key={i}>{result}</li>
        ))}
      </ul>
    </div>
  )
}

Les tests vérifient le comportement du composant à travers les interactions utilisateur et les états asynchrones :

SearchResults.test.tsxtypescript
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { SearchResults } from './SearchResults'

describe('SearchResults', () => {
  it('displays results after successful search', async () => {
    // Arrange: mock the search function
    const mockSearch = vi.fn().mockResolvedValue(['React', 'Vue', 'Angular'])
    const user = userEvent.setup()
    render(<SearchResults onSearch={mockSearch} />)

    // Act: type a query and click search
    await user.type(screen.getByLabelText('Search'), 'framework')
    await user.click(screen.getByRole('button', { name: 'Search' }))

    // Assert: results appear in the list
    expect(await screen.findByText('React')).toBeInTheDocument()
    expect(screen.getByText('Vue')).toBeInTheDocument()
    expect(mockSearch).toHaveBeenCalledWith('framework')
  })

  it('shows loading state during search', async () => {
    // Create a promise that doesn't resolve immediately
    let resolveSearch: (value: string[]) => void
    const searchPromise = new Promise<string[]>((resolve) => {
      resolveSearch = resolve
    })
    const mockSearch = vi.fn().mockReturnValue(searchPromise)
    const user = userEvent.setup()
    render(<SearchResults onSearch={mockSearch} />)

    await user.type(screen.getByLabelText('Search'), 'test')
    await user.click(screen.getByRole('button', { name: 'Search' }))

    // Button shows loading state
    expect(screen.getByRole('button', { name: 'Searching...' })).toBeDisabled()

    // Resolve the search to clean up
    resolveSearch!([])
  })

  it('displays error message on search failure', async () => {
    const mockSearch = vi.fn().mockRejectedValue(new Error('Network error'))
    const user = userEvent.setup()
    render(<SearchResults onSearch={mockSearch} />)

    await user.type(screen.getByLabelText('Search'), 'query')
    await user.click(screen.getByRole('button', { name: 'Search' }))

    // Error message appears with alert role for screen readers
    expect(await screen.findByRole('alert')).toHaveTextContent(
      'Search failed. Please try again.'
    )
  })
})

La bibliothèque userEvent simule des interactions utilisateur réalistes incluant les événements clavier et la gestion du focus, détectant des bugs que les événements synthétiques manquent.

Patterns de Tests Asynchrones et waitFor

Les opérations asynchrones nécessitent des stratégies d'attente explicites. React Testing Library fournit les requêtes findBy qui combinent getBy avec une attente automatique, plus waitFor pour les assertions complexes.

Priorité des Requêtes

Préférer findBy à waitFor + getBy pour attendre l'apparition d'éléments. Réserver waitFor pour les assertions sur des éléments existants qui changent d'état.

Les patterns asynchrones courants apparaissent lors des tests de hooks et composants de récupération de données :

async-patterns.test.tsxtypescript
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'

// Wait for element to appear (preferred for new elements)
it('loads user profile on mount', async () => {
  render(<UserProfile userId="123" />)
  
  // findBy returns a promise, automatically waits up to 1000ms
  const heading = await screen.findByRole('heading', { name: /john doe/i })
  expect(heading).toBeInTheDocument()
})

// Wait for element to disappear
it('removes loading spinner after data loads', async () => {
  render(<DataTable />)
  
  // Wait for spinner to be removed from DOM
  await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'))
  expect(screen.getByRole('table')).toBeInTheDocument()
})

// Wait for multiple conditions
it('enables submit button when form is valid', async () => {
  const user = userEvent.setup()
  render(<RegistrationForm />)

  const submitButton = screen.getByRole('button', { name: 'Submit' })
  expect(submitButton).toBeDisabled()

  await user.type(screen.getByLabelText('Email'), 'user@example.com')
  await user.type(screen.getByLabelText('Password'), 'SecurePass123!')

  // waitFor retries the assertion until it passes or times out
  await waitFor(() => {
    expect(submitButton).toBeEnabled()
  })
})

// Avoid false positives with queryBy for absence checks
it('does not show premium badge for free users', async () => {
  render(<UserCard subscription="free" />)
  
  // queryBy returns null instead of throwing, use for absence checks
  expect(screen.queryByText('Premium')).not.toBeInTheDocument()
})

La personnalisation des timeouts aide avec les opérations lentes sans ralentir l'ensemble de la suite :

typescript
// Increase timeout for slow operations
await screen.findByText('Upload complete', {}, { timeout: 5000 })

await waitFor(
  () => expect(mockSubmit).toHaveBeenCalled(),
  { timeout: 3000, interval: 100 }
)

Prêt à réussir tes entretiens React / Next.js ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Stratégies de Mocking : Modules, APIs et Hooks

Les capacités de mocking de Vitest gèrent les dépendances externes sans polluer le graphe de modules. Le mocking stratégique isole les composants des appels réseau, bibliothèques tierces et dépendances complexes.

Le mocking de modules remplace les imports au niveau du bundler :

api.test.tstypescript
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { fetchUserData } from './api'

// Mock the entire fetch module
vi.mock('./http-client', () => ({
  httpClient: {
    get: vi.fn(),
  },
}))

import { httpClient } from './http-client'

describe('fetchUserData', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('transforms API response into user model', async () => {
    // Type-safe mock implementation
    vi.mocked(httpClient.get).mockResolvedValue({
      data: { id: 1, first_name: 'John', last_name: 'Doe' },
    })

    const user = await fetchUserData(1)

    expect(user).toEqual({
      id: 1,
      fullName: 'John Doe',
    })
  })
})

Pour les hooks React qui dépendent du contexte ou de l'état externe, l'utilitaire renderHook permet des tests isolés :

useAuth.test.tstypescript
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { useAuth } from './useAuth'
import { AuthProvider } from './AuthContext'

describe('useAuth', () => {
  it('returns authenticated user after login', async () => {
    const mockLogin = vi.fn().mockResolvedValue({ id: 1, name: 'Alice' })
    
    const { result } = renderHook(() => useAuth(), {
      wrapper: ({ children }) => (
        <AuthProvider loginFn={mockLogin}>{children}</AuthProvider>
      ),
    })

    expect(result.current.user).toBeNull()
    expect(result.current.isAuthenticated).toBe(false)

    await result.current.login('alice@example.com', 'password')

    await waitFor(() => {
      expect(result.current.user).toEqual({ id: 1, name: 'Alice' })
      expect(result.current.isAuthenticated).toBe(true)
    })
  })
})

MSW (Mock Service Worker) fournit le mocking d'API au niveau réseau, permettant des tests qui exercent les appels fetch réels :

handlers.tstypescript
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Test User',
    })
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json()
    if (body.email === 'valid@example.com') {
      return HttpResponse.json({ token: 'abc123' })
    }
    return HttpResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    )
  }),
]

Tester les Server Components de React 19

Les Server Components présentent des défis de test uniques puisqu'ils s'exécutent sur le serveur et streament le HTML vers le client. Les tests unitaires directs nécessitent une approche différente des tests de composants client.

Pour les Server Components qui effectuent de la récupération de données, tester le rendu avec des sources de données mockées :

ServerComponent.test.tsxtypescript
import { describe, it, expect, vi } from 'vitest'

// Mock the data fetching function
vi.mock('./db', () => ({
  getUser: vi.fn().mockResolvedValue({ id: 1, name: 'Server User' }),
}))

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

describe('UserProfile Server Component', () => {
  it('renders user data from database', async () => {
    // Server components are async, await the component
    const Component = await UserProfile({ userId: '1' })
    render(Component)

    expect(screen.getByRole('heading')).toHaveTextContent('Server User')
  })
})

Les tests d'intégration avec Playwright fournissent la couverture la plus fiable pour les Server Components en testant le pipeline de rendu complet :

e2e/server-component.spec.tstypescript
import { test, expect } from '@playwright/test'

test('server component renders with streamed data', async ({ page }) => {
  await page.goto('/users/1')
  
  // Wait for streaming to complete
  await expect(page.getByRole('heading', { name: /profile/i })).toBeVisible()
  await expect(page.getByText('Server User')).toBeVisible()
})

Tester les Custom Hooks avec État Complexe

Les hooks personnalisés qui gèrent un état complexe, des effets de bord ou des abonnements externes bénéficient de tests dédiés. L'utilitaire renderHook de React Testing Library gère le cycle de vie et les mises à jour des hooks.

Considérons un hook de recherche avec debounce qui se coordonne avec une API :

useDebounceSearch.tstypescript
import { useState, useEffect, useCallback } from 'react'

export function useDebounceSearch<T>(
  searchFn: (query: string) => Promise<T[]>,
  delay = 300
) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<T[]>([])
  const [isLoading, setIsLoading] = useState(false)

  useEffect(() => {
    if (!query) {
      setResults([])
      return
    }

    setIsLoading(true)
    const timeoutId = setTimeout(async () => {
      try {
        const data = await searchFn(query)
        setResults(data)
      } finally {
        setIsLoading(false)
      }
    }, delay)

    return () => clearTimeout(timeoutId)
  }, [query, searchFn, delay])

  return { query, setQuery, results, isLoading }
}

Les tests vérifient le comportement du debouncing et les transitions d'état :

useDebounceSearch.test.tstypescript
import { renderHook, act, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useDebounceSearch } from './useDebounceSearch'

describe('useDebounceSearch', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  it('debounces search calls', async () => {
    const mockSearch = vi.fn().mockResolvedValue(['result1', 'result2'])
    const { result } = renderHook(() => useDebounceSearch(mockSearch, 300))

    // Type multiple characters quickly
    act(() => result.current.setQuery('r'))
    act(() => result.current.setQuery('re'))
    act(() => result.current.setQuery('rea'))
    act(() => result.current.setQuery('reac'))
    act(() => result.current.setQuery('react'))

    // Search not called yet (debouncing)
    expect(mockSearch).not.toHaveBeenCalled()

    // Advance timers past debounce delay
    await act(async () => {
      vi.advanceTimersByTime(300)
    })

    // Only one search call with final query
    expect(mockSearch).toHaveBeenCalledTimes(1)
    expect(mockSearch).toHaveBeenCalledWith('react')
  })

  it('shows loading state during search', async () => {
    let resolveSearch: (value: string[]) => void
    const mockSearch = vi.fn().mockImplementation(
      () => new Promise((resolve) => { resolveSearch = resolve })
    )
    const { result } = renderHook(() => useDebounceSearch(mockSearch, 100))

    act(() => result.current.setQuery('test'))
    
    await act(async () => {
      vi.advanceTimersByTime(100)
    })

    expect(result.current.isLoading).toBe(true)

    await act(async () => {
      resolveSearch(['result'])
    })

    expect(result.current.isLoading).toBe(false)
    expect(result.current.results).toEqual(['result'])
  })
})

Les fake timers de Vitest permettent des tests déterministes du comportement dépendant du temps sans délais réels.

Questions d'Entretien sur les Tests React

Les entretiens techniques sondent fréquemment les connaissances en testing. Ces questions évaluent la compréhension pratique plutôt que les concepts théoriques.

Piège Courant

Éviter de tester les détails d'implémentation comme les valeurs d'état ou les méthodes de composants. Les tests qui cassent lors du refactoring de la structure interne fournissent une fausse confiance et un fardeau de maintenance.

Priorité des requêtes dans React Testing Library

La priorité de requête recommandée suit l'accessibilité : getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId. Les requêtes basées sur les rôles garantissent que les composants restent accessibles et que les tests survivent aux changements de markup.

Quand utiliser findBy vs waitFor

Les requêtes findBy attendent que les éléments apparaissent dans le DOM et retournent une promise. Les utiliser lorsqu'on attend de nouveaux éléments après des opérations asynchrones. waitFor enveloppe les assertions qui peuvent ne pas passer immédiatement, réessayant jusqu'au succès ou timeout. Utiliser waitFor pour asserter sur les changements d'état des éléments existants.

Mocking fetch vs MSW

Le mocking direct de fetch (vi.spyOn(global, 'fetch')) fonctionne pour les cas simples mais nécessite de réimplémenter les objets Response et la gestion d'erreurs. MSW intercepte au niveau réseau, permettant aux tests d'exercer les appels fetch réels, la sérialisation des requêtes et les chemins de code de gestion d'erreurs.

Pour une pratique approfondie de ces concepts, explorer les questions d'entretien React Testing sur SharpSkill.

Conclusion

  • Configurer Vitest avec l'environnement jsdom, les fichiers de setup pour les extensions de matchers et les rapports de couverture pour la visibilité sur les lacunes de couverture
  • Interroger les composants par rôles et labels accessibles pour écrire des tests qui vérifient le comportement face à l'utilisateur et détectent les vrais bugs
  • Utiliser les requêtes findBy pour les éléments qui apparaissent après des opérations asynchrones, waitFor pour les assertions sur l'état changeant
  • Mocker au niveau approprié : mocks de modules pour l'isolation unitaire, MSW pour des tests réalistes de la couche réseau
  • Tester les hooks personnalisés avec renderHook et fake timers pour vérifier la gestion d'état complexe et les effets de bord
  • Les Server Components nécessitent soit un rendu asynchrone dans les tests soit des tests d'intégration complets avec Playwright
  • Lectures connexes : Patterns avancés de React Hooks et TypeScript avec React pour les patterns de test type-safe

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#react
#testing
#vitest
#react-testing-library
#javascript

Partager

Articles similaires