Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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

NiveauCe qu'il vérifieOutilVitesseDans AppliFrais
UnitaireUne fonction, seuleJestQuelques millisecondesvaliderFrais, lireMontant, formatEuros, dernierJourDuMois
Unitaire avec doubluresUne logique qui dépend d'autres modules, remplacés par des imitationsJest (jest.fn)Quelques millisecondessynchroniser, avec une fausse API et un faux dépôt
ComposantUn composant rendu, ses textes, ses réactions aux appuisReact Native Testing LibraryQuelques dizaines de millisecondesBouton, Champ
IntégrationLe client d'API contre une vraie API de testUn script Node.js, ou JestUne secondeLe script du chapitre 6
Bout en bout (E2E)Un parcours complet, dans l'application installéeMaestroPlusieurs secondes par parcoursSe connecter, ouvrir une fiche, ajouter un frais
ManuelCe qu'un automate voit mal : l'ergonomie, le rendu, le comportement réelToi, sur des appareilsLongLa 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.

bash
npx expo install jest-expo jest @types/jest @testing-library/react-native -- --dev

Puis, dans package.json :

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 :

ts
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.

bash
npm test
text
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.

ts
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(…).

tsx
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é.

yaml
# .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.

DimensionCe qu'il faut couvrirComment, sans acheter dix téléphones
SystèmeAndroid et iOS, si l'application vise les deuxTon téléphone, celui d'un camarade, l'émulateur et le simulateur
VersionsLa plus ancienne version prise en charge (Android 7 pour le SDK 58) et la plus récentePlusieurs images dans le Device Manager d'Android Studio
Tailles d'écranUn petit téléphone, un grand, une tabletteProfils d'émulateur
Réglages d'affichageMode sombre, police au maximum, langueRéglages du téléphone
RéseauWi-Fi, 4G lente, aucun réseau, réseau qui coupe au milieu d'un envoiMode avion, réglages réseau de l'émulateur
PermissionsAccordée, refusée, refusée définitivement, retirée dans les réglagesRéglages du téléphone, réinstallation
Cycle de vieMise en arrière-plan pendant une saisie, rotation, appel entrantBouton 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 testRésultat attenduAndroid 16, PixeliPhone, iOS 26
R1Connexion avec un mauvais mot de passe« Identifiant ou mot de passe incorrect. », champ mot de passe vidéOKOK
R2Ajout d'un frais en mode avionRetour au détail ; le profil affiche « Frais en attente d'envoi : 1 »OKOK
R3Désactivation du mode avionLe frais est envoyé, le compteur revient à 0OKKO : envoyé seulement au retour au premier plan
R4Refus définitif de la caméraFenêtre proposant d'ouvrir les réglages ; le frais reste saisissableOKOK
R5Police système au maximumTextes lisibles, rien de coupé, boutons utilisablesKO : 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ômeOù regarder
Une erreur JavaScriptL'écran d'erreur de l'application en développement, et le terminal de npx expo start
Une requête qui échoueL'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'erreurLes 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'APKLa configuration d'app.json (permissions, plugins) et les variables d'environnement, appliquées seulement à la compilation
Une lenteurL'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
ts
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.

  1. dernierJourDuMois renvoie le 29 février pour une date de février 2028.
  2. Le bouton « Enregistrer » est désactivé pendant l'envoi.
  3. Un visiteur se connecte, ajoute un frais et le retrouve dans sa fiche.
  4. Le formulaire reste lisible avec la plus grande taille de police d'un iPhone.
  5. Après un 401 et 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
  1. Unitaire : une fonction pure, une date en entrée, une date en sortie.
  2. Composant : rendre Bouton avec desactive, appuyer, vérifier que onPress n'est pas appelé (c'est l'un des tests du chapitre).
  3. Bout en bout (Maestro) : un parcours complet dans l'application installée, contre une API de test.
  4. Recette manuelle : le rendu réel dépend du système et de l'appareil, un automate le juge mal.
  5. Unitaire avec doublures : on remplace fetch par 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 testRésultat attendu
Saisir un frais, passer sur une autre application pendant une minute, revenirLa 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 écranLe champ en cours de saisie et le bouton restent visibles
Tourner le téléphone en paysageL'application reste en portrait ("orientation": "portrait") ou s'adapte sans perte de saisie

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Générer l'APK et publier sur le Play Store et l'App StoreRevoir le sommaire du cours