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;DR —
jest-expoconfigure 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ètre —
Date.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.