Skip to Content
React Native14. Qualité14.3 RN Testing Library

Chapitre 14.3 — React Native Testing Library

Où on en est : la logique est testée nue. L’étage au-dessus : les ÉCRANS — rendus en environnement de test, interrogés comme un utilisateur (« je vois le score ? je tape la réponse ? »), avec les modules natifs mockés là où il faut.

⏱️ TL;DRRNTL rend les composants dans un DOM-like de test : render(<QuizScreen/>), requêtes par TEXTE (getByText, getByRole — ce que voit l’utilisateur, pas l’implémentation), interactions par fireEvent.press (+ user-event pour le réalisme), asynchrone par findBy*/waitFor. Les mocks : le strict nécessaire (SQLite, notifications), via jest.mock — et un TestWrapper qui fournit les providers. Cible : 5-6 tests d’écrans CRITIQUES, pas un test par composant.

🎯 Objectifs

  • Rendre un écran avec ses providers (le TestWrapper réutilisable).
  • Interroger par texte/rôle (la philosophie « comme l’utilisateur »).
  • Tester le flux jouer-répondre-explication (le cœur de Drill).
  • Mocker les modules natifs au strict nécessaire.

Setup et philosophie

npx expo install -- --dev @testing-library/react-native

La philosophie RNTL (identique à sa sœur web) : les tests interrogent ce que l’utilisateur perçoit — du texte, des rôles d’accessibilité, des interactions. Pas les states, pas les props, pas les classes : un refactor interne ne doit PAS casser un test d’écran ; un changement de comportement, SI.

// test-utils/render.tsx — le wrapper qui fournit l'environnement import { render } from '@testing-library/react-native' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' export function renderWithProviders(ui: React.ReactElement) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) // pas de retry en test ! return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>) }

(retry: false : un fetch mocké qui échoue doit échouer TOUT DE SUITE — pas après 3 tentatives de 10 s.)

Le test du cœur : répondre à une question

// app/__tests__/quiz.test.tsx import { fireEvent, screen } from '@testing-library/react-native' import { renderWithProviders } from '../../test-utils/render' import { useSession } from '../../stores/session' import QuizScreen from '../quiz' const QUESTIONS: Question[] = [{ id: 'sf-1', prompt: 'Quel attribut déclare une route ?', choices: ['#[Route]', '#[Path]', '#[Url]', '#[Endpoint]'], correctIndex: 0, explanation: "L'attribut #[Route] se pose sur la méthode du contrôleur.", difficulty: 'facile', tags: [], }] beforeEach(() => { useSession.getState().reset() useSession.getState().start('symfony', QUESTIONS) useSession.getState().startQuestion() }) it('affiche la question et les 4 choix', () => { renderWithProviders(<QuizScreen />) expect(screen.getByText(/Quel attribut déclare/)).toBeOnTheScreen() expect(screen.getAllByRole('button')).toHaveLength(4) }) it('bonne réponse : révèle l\'explication et fige les boutons', () => { renderWithProviders(<QuizScreen />) fireEvent.press(screen.getByText('#[Route]')) expect(screen.getByText(/se pose sur la méthode/)).toBeOnTheScreen() // l'explication fireEvent.press(screen.getByText('#[Path]')) // tentative post-verdict expect(useSession.getState().answers).toHaveLength(1) // ignorée (garde) }) it('affiche « Question suivante » après une réponse', () => { renderWithProviders(<QuizScreen />) fireEvent.press(screen.getByText('#[Route]')) expect(screen.getByText('Question suivante')).toBeOnTheScreen() })

Lecture : on prépare l’état par le STORE (pas par navigation simulée — l’unitaire d’écran teste l’écran), on interagit par le texte VISIBLE, et on vérifie côté perception (l’explication apparaît) ET côté état (la garde tient — le test du ch. 14.2, revérifié à travers l’UI).

Pour l’asynchrone (un écran qui fetch) : await screen.findByText(…) (attend l’apparition) et waitFor(() => …) — les mêmes réflexes que la RTL web.

Les mocks : le strict nécessaire

jest-expo mocke déjà beaucoup (Reanimated, gesture-handler ont leurs mocks officiels préconfigurés). Reste à votre charge, selon l’écran :

// jest.setup.ts (référencé par "setupFilesAfterEach" dans la config jest) jest.mock('expo-haptics') // les appels deviennent des no-op espionnables jest.mock('expo-notifications', () => ({ getPermissionsAsync: jest.fn().mockResolvedValue({ granted: true, canAskAgain: true }), scheduleNotificationAsync: jest.fn(), cancelScheduledNotificationAsync: jest.fn(), setNotificationHandler: jest.fn(), }))

Et pour SQLite, la voie royale n’est PAS le mock : expo-sqlite ne tourne pas sous Node, mais nos écrans ne l’appellent qu’à travers les hooks de query (ch. 8.3) — on mocke donc LE HOOK (jest.mock('../../hooks/stats') → données contrôlées), pas la base. Mocker à la frontière qu’on possède : plus stable, plus lisible.

Le timer : jest.useFakeTimers() + jest.advanceTimersByTime(21_000) fait « expirer » le useCountdown sans attendre — le test du timeout devient instantané. (Attention au couple fake timers + Date.now : notre countdown lit les DEUX — jest.setSystemTime les synchronise.)

⚠️ Piège — Le sur-mock : un test d’écran où TOUT est mocké (store compris) ne teste plus que le JSX — il passera pendant que l’app brûle. La hiérarchie saine : store RÉEL (c’est du JS pur), logique RÉELLE, hooks de données mockés (la frontière), modules natifs mockés (pas le choix). Si le setup de mocks dépasse le test en taille : le test teste trop bas — remonter d’un niveau ou redescendre en unitaire.

💡 Pour un dev Next.js — C’est la RTL que vous connaissez, à trois différences : getByRole('button') s’appuie sur les props accessibility (nos accessibilityRole du ch. 3.3 paient ici — l’accessibilité et la testabilité sont le même investissement), toBeOnTheScreen() remplace toBeInTheDocument, et les mocks natifs remplacent les mocks de next/router.

✏️ Exercices

1. Écrivez le test du timeout : fake timers, avancer de 21 s, vérifier l’explication affichée, la streak cassée dans le store, et le choix null enregistré.

✅ Solution

it('timeout : révèle la réponse, casse la streak', () => { jest.useFakeTimers() jest.setSystemTime(Date.now()) // horloge et timers synchrones renderWithProviders(<QuizScreen />) act(() => { jest.advanceTimersByTime(21_000) }) expect(screen.getByText(/se pose sur la méthode/)).toBeOnTheScreen() const s = useSession.getState() expect(s.streak).toBe(0) expect(s.answers[0]).toMatchObject({ correct: false }) jest.useRealTimers() })

Le act() autour de l’avancée : les updates React déclenchées par le tick doivent être flushées. Et setSystemTime : notre timer compare endsAt à Date.now() (ch. 6.3) — avancer les timers SANS l’horloge ne suffirait pas. Le test rejoue exactement la mécanique du chapitre machine d’état.

2. Testez l’écran Historique : hook de stats mocké (3 sessions), vérifier le rendu de la liste ET l’état vide (« Aucune session ») quand le mock renvoie [].

✅ Solution

jest.mock('../../hooks/stats') + mocked(useHistory).mockReturnValue({ data: fake3, isPending: false … }) → getByText sur un score attendu ; second test avec data: [] → getByText(/Aucune session/). Le ListEmptyComponent (ch. 3.4) obtient ENFIN son test — les états vides sont les moins regardés à l’œil, donc les plus rentables à tester.

3. Un refactor change AnswerButton : le label passe d’un Text enfant à une prop accessibilityLabel + rendu custom. Lequel de vos tests casse, et qu’est-ce que ça enseigne ?

✅ Solution

getByText('#[Route]') casse SI le texte n’est plus rendu comme texte accessible ; getByRole('button', { name: … }) survivrait (il lit le label d’accessibilité). Leçon double : préférer getByRole+name (le plus proche de la perception assistée), et un test qui casse sur un refactor d’implémentation pure signale une requête trop fragile — on ajuste la REQUÊTE, pas le comportement. La frontière test-utile/test-fragile passe par « qu’est-ce que l’utilisateur percevrait ? ».

🧠 Quiz

1. Quelle est la philosophie de requête de RNTL ?

Réponse

Interroger ce que l’utilisateur perçoit : texte visible, rôles d’accessibilité — jamais l’implémentation (state, props, testID en dernier recours). Refactor interne ≠ test cassé.

2. Comment prépare-t-on l’état d’un écran de jeu en test ?

Réponse

Par le store réel (getState().start/startQuestion en beforeEach) — pas de navigation simulée : l’unitaire d’écran teste l’écran, l’enchaînement complet est le rôle du E2E.

3. Où mocke-t-on les données SQLite, et pourquoi pas la base ?

Réponse

Au niveau des hooks de query (la frontière que nous possédons — ch. 8.3) : expo-sqlite ne tourne pas sous Node, et mocker le hook donne des tests lisibles et stables.

4. Quel duo d’APIs gère le timeout en test ?

Réponse

jest.useFakeTimers() + advanceTimersByTime — ET jest.setSystemTime car notre countdown lit aussi Date.now() (l’horloge fait foi, ch. 6.3).

5. Quel est le signal d’un test qui teste « trop bas » ?

Réponse

Un setup de mocks plus long que le test, ou un test qui ne survit à aucun refactor : soit remonter au niveau perception, soit redescendre en unitaire de lib/.


👉 Chapitre suivant : 14.4 — E2E avec Maestro — le robot qui joue à Drill sur un vrai émulateur.