Skip to Content
React Native14. Qualité14.2 Jest & logique pure

Chapitre 14.2 — Jest & la logique pure

Où on en est : les types verrouillent les formes ; les tests verrouillent les COMPORTEMENTS. On commence par le plus rentable : la logique pure de lib/ — celle qu’on a soigneusement isolée depuis la Partie 2, précisément pour ce jour.

⏱️ TL;DRjest-expo configure tout ; les fonctions de lib/ (scoring, tirage, answerState) se testent SANS mock ni RN — des entrées, des sorties. Les techniques qui comptent : cas nominaux + bords (0, vide, max), l’injection du hasard (le rng en paramètre — testé déterministe), et les tests du STORE Zustand (la machine d’état, gardes comprises). Objectif honnête : ~30 tests qui couvrent le cœur du jeu, pas un ratio de couverture.

🎯 Objectifs

  • Installer jest-expo et écrire les premiers tests de lib/.
  • Couvrir scoring, draw (hasard injecté) et answerState — bords compris.
  • Tester le store de session (transitions, gardes, timeout).
  • Savoir QUOI ne pas tester (et pourquoi).

Setup

npx expo install -- --dev jest-expo jest @types/jest
// package.json "scripts": { "test": "jest" }, "jest": { "preset": "jest-expo" }

Le preset gère les transforms RN/Expo (les node_modules à transpiler — LE piège classique du Jest-en-RN, résolu d’office). pnpm test — c’est prêt.

Tester lib/ : le dividende

computePoints (ch. 6.1) — le test s’écrit plus vite que ce paragraphe :

// lib/__tests__/scoring.test.ts import { computePoints } from '../scoring' describe('computePoints', () => { it('donne 1000 pour une réponse instantanée', () => { expect(computePoints(20, 20)).toBe(1000) }) it('donne 100 à la dernière seconde', () => { expect(computePoints(0, 20)).toBe(100) }) it('est linéaire au milieu', () => { expect(computePoints(10, 20)).toBe(550) }) // Les BORDS — là où vivent les bugs : it('borne les entrées aberrantes', () => { expect(computePoints(25, 20)).toBe(1000) // secondsLeft > total (resync bizarre) expect(computePoints(-3, 20)).toBe(100) // négatif (course au timeout) expect(computePoints(5, 0)).toBe(100) // total zéro (division !) }) })

Remarquez le ratio : 3 tests nominaux, 3 tests de bords — et les bords testent des scénarios RÉELS du cours (la resync d’arrière-plan du ch. 10.3, la course du timeout du ch. 6.3). Un test de bord = un bug de prod par anticipation.

Le hasard : injecter pour déterminiser

drawQuestions (ch. 7.2, exercice 1) utilisait Math.random — l’exercice avait prévu l’injection. La version testable :

// lib/draw.ts — le rng en paramètre, Math.random par défaut export function drawQuestions( all: Question[], count: number, rng: () => number = Math.random ): Question[] { /* Fisher-Yates avec rng() */ }
// lib/__tests__/draw.test.ts const fakeQuestions = (n: number): Question[] => Array.from({ length: n }, (_, i) => ({ id: `q-${i}`, /* … champs minimaux */ })) it('retourne count questions sans doublon', () => { const drawn = drawQuestions(fakeQuestions(50), 10) expect(drawn).toHaveLength(10) expect(new Set(drawn.map(q => q.id)).size).toBe(10) }) it('est borné par la taille de la banque', () => { expect(drawQuestions(fakeQuestions(3), 10)).toHaveLength(3) }) it('mélange de façon déterministe avec un rng fixé', () => { const rng = seeded(42) // mini-LCG maison, 5 lignes const a = drawQuestions(fakeQuestions(20), 5, seeded(42)) const b = drawQuestions(fakeQuestions(20), 5, seeded(42)) expect(a.map(q => q.id)).toEqual(b.map(q => q.id)) // même graine = même tirage })

Le principe généralise : tout non-déterminisme (hasard, horloge) devient un paramètreDate.now s’injecte pareil le jour où une fonction pure en dépend. (Pour les hooks à base d’horloge comme useCountdown : jest.useFakeTimers() — au chapitre suivant.)

Tester le store : la machine d’état

Zustand se teste SANS React (le superpouvoir getState — encore lui) :

// stores/__tests__/session.test.ts import { useSession } from '../session' const q = (id: string, correct: 0 | 1 | 2 | 3 = 0): Question => ({ /* … */ }) beforeEach(() => useSession.getState().reset()) describe('la machine de session', () => { it('answer correcte incrémente score et streak, passe en answered', () => { const s = useSession.getState() s.start('php', [q('a'), q('b')]) s.startQuestion() const ok = useSession.getState().answer(0, 15) expect(ok).toBe(true) const after = useSession.getState() expect(after.score).toBeGreaterThan(0) expect(after.streak).toBe(1) expect(after.phase.name).toBe('answered') }) it('GARDE : pas de double réponse', () => { const s = useSession.getState() s.start('php', [q('a')]) s.startQuestion() useSession.getState().answer(0, 15) const scoreAfterFirst = useSession.getState().score useSession.getState().answer(1, 14) // tentative illégale expect(useSession.getState().score).toBe(scoreAfterFirst) // refusée }) it('timeout casse la streak et enregistre une réponse fausse', () => { /* … */ }) it('next sur la dernière question passe en finished', () => { /* … */ }) })

Le test de GARDE est le plus précieux : il verrouille la propriété du ch. 6.3 (« les états impossibles sont impossibles ») contre les refontes futures. Note d’hygiène : le store étant un singleton module, le beforeEach(reset) est obligatoire — sinon les tests fuient l’un dans l’autre.

Ce qu’on ne teste PAS (en unitaire)

  • Les composants du kit UI (Button rend un label…) : coût > valeur — la RNTL (ch. 14.3) testera les ÉCRANS qui les composent.
  • Les libs (TanStack Query cache-t-il ? SQLite persiste-t-il ?) : ce sont LEURS tests.
  • Les styles : c’est l’œil (et Maestro pour les parcours, ch. 14.4).

La cible : lib/ à ~100 % (c’est gratuit), les stores sur leurs transitions et gardes, et STOP. ~30 tests, < 2 s d’exécution — le genre de suite qu’on lance vraiment.

💡 Pour un dev Next.js — C’est le Jest que vous connaissez (jest-expo ≈ next/jest). La leçon transférable est architecturale : la règle « lib/ n’importe pas react-native » posée au ch. 2.5 est EXACTEMENT votre « la logique métier n’importe pas React » côté Next.js — et dans les deux mondes, elle transforme « écrire des tests » de corvée en formalité.

⚠️ Piège — Si un test de lib/ a besoin d’un mock RN, ce n’est pas un problème de test : c’est une fuite d’architecture (du RN s’est glissé dans lib/). Le test est votre détecteur : corrigez l’import, pas le mock. (Vrai aussi pour les stores : le nôtre n’importe que lib/ et zustand — testable nu. Si un store importe expo-*, extraire.)

✏️ Exercices

1. Écrivez la suite complète d’answerState (ch. 6.3) : les 4 états × les phases pertinentes — la table de vérité du composant central du jeu.

✅ Solution

8 assertions : en phase playing → tout est ‘idle’ ; en answered(choiceIndex: 1, correct: false) sur correctIndex 0 → bouton 0 = ‘correct’, bouton 1 = ‘incorrect’, boutons 2-3 = ‘disabled’ ; en answered timeout (choiceIndex null) → correct révélé, rien en incorrect, reste disabled ; en finished → ‘idle’ (ou selon votre choix — le test DOCUMENTE la décision). Une table de vérité testée = le composant AnswerButton devient refactorable sans peur.

2. Écrivez seeded(n) (le mini-LCG du test de draw) et vérifiez la propriété « distribution » : sur 1000 tirages de 1 parmi 4, chaque question sort entre 200 et 300 fois.

✅ Solution

export function seeded(seed: number): () => number { let s = seed return () => { s = (s * 1664525 + 1013904223) % 2 ** 32 return s / 2 ** 32 } }

Le test de distribution (boucle 1000×, compteur par id, expect 200-300) attrape les biais de mélange (le fameux sort(() => random-0.5) échouerait). Tester une PROPRIÉTÉ statistique plutôt qu’une valeur : l’outil pour tout code aléatoire.

3. Le bug régression : cassez volontairement computePoints (inversez le ratio), lancez la suite, et mesurez le temps entre pnpm test et le diagnostic exact. Comparez au même bug découvert « à la main » en jouant.

✅ Solution

La suite : ~2 s, le test « réponse instantanée » échoue avec expected 1000/received 100 — diagnostic immédiat, ligne connue. À la main : il faut JOUER une partie, remarquer que les points semblent inversés (subtil !), suspecter, fouiller. Minutes vs secondes — et surtout : le test le trouvera ENCORE dans 6 mois, pas vous. C’est l’argument définitif pour tester la logique de jeu.

🧠 Quiz

1. Pourquoi les tests de lib/ n’ont-ils besoin d’aucun mock ?

Réponse

Par construction : lib/ n’importe pas react-native (règle du ch. 2.5) — fonctions pures, entrées/sorties. Un mock nécessaire y signale une fuite d’architecture.

2. Comment rend-on testable une fonction aléatoire ?

Réponse

En injectant le générateur (rng: () => number = Math.random) : les tests passent un rng seedé → déterminisme (et tests de propriétés statistiques pour la distribution).

3. Comment teste-t-on un store Zustand sans React ?

Réponse

Via getState() : appeler les actions, asserter l’état — avec un beforeEach(reset) car le store est un singleton de module (isolation des tests).

4. Quel est le test le plus précieux de la machine d’état ?

Réponse

Le test de garde (pas de double réponse, pas d’answer hors playing) : il verrouille l’invariant central contre les refontes — les états impossibles RESTENT impossibles.

5. Quelle est la cible de couverture du cours ?

Réponse

lib/ exhaustif (gratuit), stores sur transitions+gardes, et STOP — ~30 tests en < 2 s. Pas de ratio dogmatique : la valeur par test, pas le pourcentage.


👉 Chapitre suivant : 14.3 — React Native Testing Library — tester les écrans : ce que voit l’utilisateur.