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;DR — RNTL 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 parfireEvent.press(+user-eventpour le réalisme), asynchrone parfindBy*/waitFor. Les mocks : le strict nécessaire (SQLite, notifications), viajest.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-nativeLa 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.