Chapitre 1121 minIntermédiaire
Tester une application mobile : Jest, émulateur et téléphone
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- choisir le bon niveau de test pour chaque vérification ;
- écrire des tests unitaires avec Jest et des doublures ;
- tester un composant avec React Native Testing Library ;
- construire une matrice de recette sur de vrais appareils.
3 exercices corrigés
Tester une application mobile, c'est combiner des tests automatisés (tests unitaires des fonctions avec Jest, tests de composants avec React Native Testing Library, tests de bout en bout avec Maestro) et des tests manuels sur de vrais appareils, selon un cahier de recette qui couvre les tailles d'écran, les versions du système, l'absence de réseau et les permissions refusées. Ce chapitre installe Jest dans AppliFrais, teste la validation, la synchronisation et deux composants, décrit un parcours Maestro, puis construit la matrice de recette. Les principes généraux des tests (unitaires, intégration, non-régression) sont dans le cours Tests (les types de tests, cahier de recette).
Que tester, et à quel niveau
| Niveau | Ce qu'il vérifie | Outil | Vitesse | Dans AppliFrais |
|---|---|---|---|---|
| Unitaire | Une fonction, seule | Jest | Quelques millisecondes | validerFrais, lireMontant, formatEuros, dernierJourDuMois |
| Unitaire avec doublures | Une logique qui dépend d'autres modules, remplacés par des imitations | Jest (jest.fn) | Quelques millisecondes | synchroniser, avec une fausse API et un faux dépôt |
| Composant | Un composant rendu, ses textes, ses réactions aux appuis | React Native Testing Library | Quelques dizaines de millisecondes | Bouton, Champ |
| Intégration | Le client d'API contre une vraie API de test | Un script Node.js, ou Jest | Une seconde | Le script du chapitre 6 |
| Bout en bout (E2E) | Un parcours complet, dans l'application installée | Maestro | Plusieurs secondes par parcours | Se connecter, ouvrir une fiche, ajouter un frais |
| Manuel | Ce qu'un automate voit mal : l'ergonomie, le rendu, le comportement réel | Toi, sur des appareils | Long | La matrice de recette |
La règle de la pyramide des tests s'applique : beaucoup de tests unitaires, rapides et précis ; quelques tests de composants ; peu de tests de bout en bout, lents et plus fragiles. C'est pour cela que la logique d'AppliFrais est sortie des écrans dans des fonctions pures (validation.ts, format.ts, synchro.ts) : elles se testent sans téléphone.
Installer Jest avec jest-expo
Jest est le cadre de test le plus utilisé en JavaScript. Le préréglage jest-expo le configure pour un projet Expo : il transforme le TypeScript et le JSX, et remplace les modules natifs (SecureStore, caméra…) par des imitations, puisque les tests tournent dans Node.js, pas sur un téléphone.
npx expo install jest-expo jest @types/jest @testing-library/react-native -- --dev
Puis, dans package.json :
{
"scripts": {
"test": "jest"
},
"jest": {
"preset": "jest-expo"
}
}
Et dans tsconfig.json, pour que l'éditeur connaisse describe, it et expect : "compilerOptions": { "types": ["jest"] }. Jest trouve automatiquement les fichiers qui finissent par .test.ts ou .test.tsx, ou qui sont dans un dossier __tests__. Place ce dossier à la racine du projet, hors de src/app : tout fichier de src/app est pris pour un écran par Expo Router.
Tester les fonctions
__tests__/validation.test.ts vérifie les règles de saisie du chapitre 5 :
import { lireMontant, validerFrais } from '@/lib/validation';
describe('lireMontant', () => {
it('accepte la virgule et le point', () => {
expect(lireMontant('23,50')).toBe(23.5);
expect(lireMontant('23.5')).toBe(23.5);
});
it('refuse les montants mal écrits', () => {
expect(lireMontant('1 200')).toBeNull();
expect(lireMontant('12,345')).toBeNull();
expect(lireMontant('-4')).toBeNull();
expect(lireMontant('')).toBeNull();
});
});
describe('validerFrais', () => {
const AUJOURDHUI = '2026-11-02';
it('renvoie le frais nettoyé quand tout est valide', () => {
const { frais, erreurs } = validerFrais({ date: '2026-10-14', libelle: ' Repas client ', montant: '23,50' }, AUJOURDHUI);
expect(erreurs).toEqual({});
expect(frais).toEqual({ date: '2026-10-14', libelle: 'Repas client', montant: 23.5 });
});
it('refuse le 30 février et une date future', () => {
expect(validerFrais({ date: '2026-02-30', libelle: 'Repas', montant: '10' }, AUJOURDHUI).erreurs.date).toBeDefined();
expect(validerFrais({ date: '2026-11-03', libelle: 'Repas', montant: '10' }, AUJOURDHUI).erreurs.date).toBe(
'La date ne peut pas être dans le futur.',
);
});
it('signale toutes les erreurs en une fois', () => {
const { frais, erreurs } = validerFrais({ date: '14/10/2026', libelle: 'ab', montant: '0' }, AUJOURDHUI);
expect(frais).toBeUndefined();
expect(Object.keys(erreurs).sort()).toEqual(['date', 'libelle', 'montant']);
});
});
describe regroupe les tests d'une même fonction, it décrit un comportement attendu, en français, et expect(…) le vérifie avec un matcher : toBe pour une valeur simple, toEqual pour comparer le contenu d'objets, toBeNull, toBeDefined, toBeUndefined. Comme la date du jour est un paramètre de validerFrais, le test donne toujours le même résultat, quel que soit le jour où il tourne.
npm test
Test Suites: 3 passed, 3 total
Tests: 12 passed, 12 total
Les trois suites sont ce fichier et les deux qui suivent. Un détail de conception rend ces tests possibles : les fonctions testées sont dans des modules sans effet de bord à l'import. Le fichier notifications.ts, lui, appelle setNotificationHandler dès son chargement ; l'importer dans un test échoue avec les imitations de jest-expo. C'est pourquoi dernierJourDuMois est rangée dans format.ts : sa place est là où on peut la tester. Choisis tes cas de test aux limites des règles : 0 € (refusé), 5 000 € (accepté), 3 caractères (accepté), 2 caractères (refusé), la date du jour (acceptée), le lendemain (refusé). Les bogues se cachent presque toujours là.
Tester la synchronisation avec des doublures
synchroniser appelle l'API et la base locale. Dans un test, on les remplace par des doublures (mocks) : de fausses fonctions dont on choisit le comportement, et dont on vérifie les appels. jest.fn() en crée une ; mockResolvedValue lui fait renvoyer une promesse résolue, mockRejectedValueOnce une promesse rejetée, une seule fois.
import { ErreurApi, ErreurReseau } from '@/lib/api';
import type { FraisEnAttente } from '@/lib/db';
import { synchroniser } from '@/lib/synchro';
const frais = (cle: string): FraisEnAttente => ({ cle, idFiche: 12, date: '2026-10-20', libelle: 'Parking', montant: 12, photo: null });
function depotAvec(liste: FraisEnAttente[]) {
return {
lire: async () => [...liste],
retirer: jest.fn(async (cle: string) => {
liste.splice(liste.findIndex((f) => f.cle === cle), 1);
}),
};
}
it('envoie tout et vide la file quand le réseau répond', async () => {
const api = { ajouterFrais: jest.fn().mockResolvedValue({ id: 60 }), envoyerJustificatif: jest.fn() };
const depot = depotAvec([frais('a'), frais('b')]);
const bilan = await synchroniser(api, depot, () => new FormData());
expect(bilan.envoyes).toBe(2);
expect(api.ajouterFrais).toHaveBeenCalledWith(12, { date: '2026-10-20', libelle: 'Parking', montant: 12 }, 'a');
expect(await depot.lire()).toHaveLength(0);
});
it("s'arrête sans rien perdre si le réseau coupe", async () => {
const api = {
ajouterFrais: jest.fn().mockResolvedValueOnce({ id: 60 }).mockRejectedValueOnce(new ErreurReseau('hors ligne')),
envoyerJustificatif: jest.fn(),
};
const depot = depotAvec([frais('a'), frais('b'), frais('c')]);
const bilan = await synchroniser(api, depot, () => new FormData());
expect(bilan.envoyes).toBe(1);
expect((await depot.lire()).map((f) => f.cle)).toEqual(['b', 'c']);
});
it('retire et signale un frais refusé définitivement', async () => {
const api = {
ajouterFrais: jest.fn().mockRejectedValue(new ErreurApi(409, { title: 'Fiche non modifiable' })),
envoyerJustificatif: jest.fn(),
};
const depot = depotAvec([frais('a')]);
const bilan = await synchroniser(api, depot, () => new FormData());
expect(bilan.refuses).toHaveLength(1);
expect(bilan.refuses[0].raison).toBe('Fiche non modifiable');
expect(await depot.lire()).toHaveLength(0);
});
Le deuxième test vérifie le point le plus important de la file d'attente : quand le réseau coupe au milieu, rien n'est perdu, et les frais restants gardent leur ordre. Ce cas est presque impossible à reproduire à la main de façon fiable ; avec une doublure, il se rejoue en une milliseconde.
Tester un composant avec React Native Testing Library
React Native Testing Library (RNTL) rend un composant dans Node.js et permet de le manipuler comme un utilisateur : trouver un texte, appuyer, taper, vérifier ce qui s'affiche. Depuis sa version 14, compatible avec React 19, render et les événements sont asynchrones : on écrit await render(…) et await fireEvent.press(…).
import { fireEvent, render, screen } from '@testing-library/react-native';
import { Bouton } from '@/components/Bouton';
import { Champ } from '@/components/Champ';
it('appelle onPress quand on appuie sur le bouton', async () => {
const onPress = jest.fn();
await render(<Bouton titre="Enregistrer" onPress={onPress} />);
await fireEvent.press(screen.getByText('Enregistrer'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it("n'appelle pas onPress quand le bouton est désactivé", async () => {
const onPress = jest.fn();
await render(<Bouton titre="Enregistrer" onPress={onPress} desactive />);
await fireEvent.press(screen.getByText('Enregistrer'));
expect(onPress).not.toHaveBeenCalled();
});
it('affiche le message d’erreur sous le champ', async () => {
await render(<Champ libelle="Montant (€)" value="abc" erreur="Un montant en euros, par exemple 23,50." />);
expect(screen.getByLabelText('Montant (€)')).toBeOnTheScreen();
expect(screen.getByText('Un montant en euros, par exemple 23,50.')).toBeOnTheScreen();
});
it('transmet la saisie au parent', async () => {
const onChangeText = jest.fn();
await render(<Champ libelle="Libellé" value="" onChangeText={onChangeText} />);
await fireEvent.changeText(screen.getByLabelText('Libellé'), 'Repas client');
expect(onChangeText).toHaveBeenCalledWith('Repas client');
});
Les requêtes de RNTL cherchent les éléments comme un utilisateur ou un lecteur d'écran les perçoit : getByText (un texte affiché), getByLabelText (un libellé d'accessibilité), getByRole ('button'…). Un test qui trouve le champ par son libellé vérifie au passage qu'il a un libellé : l'accessibilité et la testabilité vont ensemble. Les variantes queryBy… renvoient null au lieu d'échouer (pour vérifier une absence), et findBy… attendent qu'un élément apparaisse (après un chargement).
Les tests de bout en bout avec Maestro
Un test de bout en bout pilote l'application installée, sur un émulateur ou un téléphone : il touche l'écran, tape du texte et vérifie ce qui s'affiche, comme un utilisateur. Maestro est l'outil que recommande Expo : un parcours s'écrit en YAML, sans code, et désigne les éléments par leur texte ou leur libellé d'accessibilité.
# .maestro/ajouter-frais.yaml
appId: com.gsb.applifrais
---
- launchApp:
clearState: true
- tapOn: "Identifiant"
- inputText: "lmartin"
- tapOn: "Mot de passe"
- inputText: "Visiteur#2026"
- tapOn: "Se connecter"
- assertVisible: "Mes fiches"
- tapOn: "Fiche octobre 2026, En cours de saisie"
- tapOn: "Ajouter un frais"
- tapOn: "Libellé"
- inputText: "Parking gare Part-Dieu"
- tapOn: "Montant.*"
- inputText: "12"
- tapOn: "Enregistrer"
- assertVisible: "Parking gare Part-Dieu"
Maestro lit chaque texte comme une expression régulière qui doit correspondre au texte entier : "Montant.*" désigne le champ dont le libellé commence par « Montant », car les parenthèses de « Montant (€) » ont un sens particulier dans une expression régulière. appId est l'identifiant de l'application (le package Android d'app.json, chapitre 12). Le parcours se lance sur l'émulateur avec maestro test .maestro/ajouter-frais.yaml, contre une API de test dont les données sont connues, jamais contre la production. Maestro s'installe à part (instructions sur maestro.dev) et fonctionne sur une version compilée de l'application : un development build ou un APK. Les services en ligne d'Expo peuvent exécuter ces parcours à chaque modification, dans la chaîne d'intégration continue (cours CI/CD).
Tester sur de vrais appareils : la matrice de recette
Les tests automatisés ne voient pas tout : un bouton caché par l'encoche, un texte coupé avec une grande police, une application qui plante quand on refuse la caméra. La recette manuelle se prépare avec une matrice d'appareils et une liste de cas.
| Dimension | Ce qu'il faut couvrir | Comment, sans acheter dix téléphones |
|---|---|---|
| Système | Android et iOS, si l'application vise les deux | Ton téléphone, celui d'un camarade, l'émulateur et le simulateur |
| Versions | La plus ancienne version prise en charge (Android 7 pour le SDK 58) et la plus récente | Plusieurs images dans le Device Manager d'Android Studio |
| Tailles d'écran | Un petit téléphone, un grand, une tablette | Profils d'émulateur |
| Réglages d'affichage | Mode sombre, police au maximum, langue | Réglages du téléphone |
| Réseau | Wi-Fi, 4G lente, aucun réseau, réseau qui coupe au milieu d'un envoi | Mode avion, réglages réseau de l'émulateur |
| Permissions | Accordée, refusée, refusée définitivement, retirée dans les réglages | Réglages du téléphone, réinstallation |
| Cycle de vie | Mise en arrière-plan pendant une saisie, rotation, appel entrant | Bouton d'accueil, rotation de l'émulateur |
Un cahier de recette liste les cas, le résultat attendu et le résultat obtenu, pour chaque appareil testé. Extrait pour AppliFrais, avec des résultats d'exemple :
| N° | Cas de test | Résultat attendu | Android 16, Pixel | iPhone, iOS 26 |
|---|---|---|---|---|
| R1 | Connexion avec un mauvais mot de passe | « Identifiant ou mot de passe incorrect. », champ mot de passe vidé | OK | OK |
| R2 | Ajout d'un frais en mode avion | Retour au détail ; le profil affiche « Frais en attente d'envoi : 1 » | OK | OK |
| R3 | Désactivation du mode avion | Le frais est envoyé, le compteur revient à 0 | OK | KO : envoyé seulement au retour au premier plan |
| R4 | Refus définitif de la caméra | Fenêtre proposant d'ouvrir les réglages ; le frais reste saisissable | OK | OK |
| R5 | Police système au maximum | Textes lisibles, rien de coupé, boutons utilisables | KO : badge d'état coupé | OK |
Chaque cas « KO » devient un ticket dans le gestionnaire de ton dépôt, avec l'appareil, les étapes pour reproduire et une capture d'écran. C'est exactement ce qu'on attend de la recette dans une réalisation professionnelle, et ce que la commission de l'E6 peut te demander de montrer.
Déboguer ce qui casse
| Symptôme | Où regarder |
|---|---|
| Une erreur JavaScript | L'écran d'erreur de l'application en développement, et le terminal de npx expo start |
| Une requête qui échoue | L'onglet Réseau de React Native DevTools (touche j), et le journal de l'API |
| Un plantage de l'application compilée, sans écran d'erreur | Les journaux du système : adb logcat pour Android (dans Android Studio, la fenêtre Logcat), la Console de macOS pour un iPhone branché |
| Un comportement différent entre Expo Go et l'APK | La configuration d'app.json (permissions, plugins) et les variables d'environnement, appliquées seulement à la compilation |
| Une lenteur | L'analyseur de performances du menu développeur ; une liste en ScrollView au lieu de FlatList ; des rendus inutiles |
Exercice 1 sur 3 Écrire les tests de formatEuros Réussi À revoir
Écris un fichier de test pour formatEuros (chapitre 5) avec quatre cas : un entier, un nombre à une décimale, un nombre à deux décimales, et zéro.
Voir la réponseMasquer la réponse de l'exercice 1
import { formatEuros } from '@/lib/format';
describe('formatEuros', () => {
it('ajoute deux décimales à un entier', () => {
expect(formatEuros(12)).toBe('12,00 €');
});
it('complète une seule décimale', () => {
expect(formatEuros(23.5)).toBe('23,50 €');
});
it('garde deux décimales et la virgule', () => {
expect(formatEuros(389.2)).toBe('389,20 €');
expect(formatEuros(0.99)).toBe('0,99 €');
});
it('affiche zéro', () => {
expect(formatEuros(0)).toBe('0,00 €');
});
});
Un cas de plus mérite réflexion : formatEuros(1200) donne '1200,00 €', sans espace des milliers. Le test rend ce choix visible ; si on veut '1 200,00 €', on modifie la fonction et le test ensemble.
Tu avais trouvé ?
Exercice 2 sur 3 Choisir le niveau de test Réussi À revoir
Pour chaque vérification, choisis le niveau le plus adapté : unitaire, unitaire avec doublures, composant, bout en bout ou recette manuelle.
dernierJourDuMoisrenvoie le 29 février pour une date de février 2028.- Le bouton « Enregistrer » est désactivé pendant l'envoi.
- Un visiteur se connecte, ajoute un frais et le retrouve dans sa fiche.
- Le formulaire reste lisible avec la plus grande taille de police d'un iPhone.
- Après un
401et un rafraîchissement réussi, le client d'API rejoue la requête une seule fois.
Voir la réponseMasquer la réponse de l'exercice 2
- Unitaire : une fonction pure, une date en entrée, une date en sortie.
- Composant : rendre
Boutonavecdesactive, appuyer, vérifier queonPressn'est pas appelé (c'est l'un des tests du chapitre). - Bout en bout (Maestro) : un parcours complet dans l'application installée, contre une API de test.
- Recette manuelle : le rendu réel dépend du système et de l'appareil, un automate le juge mal.
- Unitaire avec doublures : on remplace
fetchpar une doublure qui renvoie 401, puis 200, et on compte les appels. Le script d'intégration du chapitre 6 le vérifie aussi contre l'API de démonstration.
Tu avais trouvé ?
Exercice 3 sur 3 Compléter le cahier de recette Réussi À revoir
Propose trois cas de test supplémentaires pour l'écran « Nouveau frais », avec le résultat attendu, en pensant aux spécificités du mobile.
Voir la réponseMasquer la réponse de l'exercice 3
Exemples de cas pertinents :
| Cas de test | Résultat attendu |
|---|---|
| Saisir un frais, passer sur une autre application pendant une minute, revenir | La saisie est toujours là (l'écran n'a pas été réinitialisé) |
| Appuyer deux fois très vite sur « Enregistrer » | Un seul frais créé : le bouton est désactivé pendant l'envoi, et la clé d'idempotence évite le doublon |
| Couper le réseau juste après l'appui sur « Enregistrer » | Le frais est mis en attente, puis envoyé une seule fois au retour du réseau |
| Ouvrir le clavier sur un petit écran | Le champ en cours de saisie et le bouton restent visibles |
| Tourner le téléphone en paysage | L'application reste en portrait ("orientation": "portrait") ou s'adapte sans perte de saisie |
Tu avais trouvé ?