Chapitre 321 minIntermédiaire
Écrire un bon test unitaire : AAA, nommage et cas à tester
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- structurer un test en trois temps : préparer, agir, vérifier ;
- nommer un test pour qu'il se lise comme une phrase ;
- rendre un code testable en sortant ses dépendances cachées ;
- trouver les cas à tester avec les classes d'équivalence et les valeurs limites ;
- construire une table de décision pour des règles combinées.
5 exercices corrigés3 schémas
Un bon test unitaire vérifie un seul comportement d'une unité de code, dans une situation précise, avec un nom qui se lit comme une phrase et une structure en trois temps : préparer (Arrange), agir (Act), vérifier (Assert). Écrire le test est la partie facile ; la vraie compétence consiste à choisir les cas qui ont le plus de chances de révéler un défaut. Ce chapitre, valable dans tous les langages, donne les règles d'écriture et les techniques pour trouver les cas : classes d'équivalence, valeurs limites et tables de décision.
Qu'est-ce qu'une « unité » ?
L'unité est le plus petit morceau de code qui a un comportement observable : une fonction, une méthode, parfois une classe entière quand ses méthodes n'ont de sens qu'ensemble. Un test unitaire l'appelle par son interface publique (ce que le reste du programme utilise) et vérifie ce qu'elle renvoie ou ce qu'elle change, sans regarder comment elle s'y prend.
| Un test unitaire vérifie… | Un test unitaire ne vérifie pas… |
|---|---|
| Ce qu'une fonction renvoie pour des entrées données | Le nom des variables internes, l'ordre des instructions |
| L'état d'un objet après un appel de méthode | Une méthode privée (elle est testée à travers les méthodes publiques qui l'utilisent) |
| L'exception levée pour une entrée invalide | Le code du framework ou de la bibliothèque (Express, React, PDO sont déjà testés) |
| Les appels faits à une dépendance, quand c'est le comportement attendu (envoyer un courriel) | Des getters et setters sans logique |
La structure AAA : préparer, agir, vérifier
Tout test unitaire, dans tous les langages, suit le même schéma en trois temps.
test('refuse un matériel déjà réservé sur une partie de la période', () => {
// Arrange : le contexte et les données
const aujourdhui = '2027-03-08';
const reservations = [{ materiel: 'PC-07', debut: '2027-03-10', fin: '2027-03-14' }];
const demande = { materiel: 'PC-07', debut: '2027-03-12', fin: '2027-03-18' };
// Act : un seul appel au code testé
const resultat = verifierReservation(demande, reservations, aujourdhui);
// Assert : ce qu'on attend
expect(resultat.acceptee).toBe(false);
expect(resultat.motif).toBe('PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.');
});
Trois règles d'usage :
- Une seule action dans la partie Act. Si tu appelles deux fois le code testé avec des données différentes, ce sont deux tests.
- Pas de logique dans un test : ni
if, ni boucle, ni calcul de l'attendu. Un test qui contient unifpeut lui-même être faux. Pour plusieurs jeux de données, on utilise un test paramétré (test.each,[Theory],#[DataProvider]). - Les commentaires
// Arrange,// Act,// Assertsont utiles quand on débute ; une ligne vide entre les trois parties suffit ensuite.
Le même découpage s'appelle Étant donné / Quand / Alors (Given / When / Then) dans le développement piloté par le comportement (BDD) et dans les critères d'acceptation des user stories. C'est la même idée : un contexte, un événement, un résultat observable.
Nommer un test pour qu'il se lise comme une phrase
Quand un test échoue, son nom est la première chose que tu lis, souvent dans un journal de CI, des semaines après l'avoir écrit. Il doit dire la situation et le résultat attendu, sans qu'on ait besoin d'ouvrir le code.
| Nom faible | Pourquoi | Nom clair |
|---|---|---|
test1 | Ne dit rien | refuse un prêt de 16 jours |
verifierReservation fonctionne | Quel cas ? Quel résultat ? | accepte un matériel libre sur toute la période |
test date | Trop vague | refuse une date de début passée |
cas limite | Quelle limite ? | accepte un prêt de 15 jours, la durée maximale |
Les conventions varient selon les langages, l'idée reste la même :
| Langage | Convention courante | Exemple |
|---|---|---|
| JavaScript (Vitest, Jest) | Une phrase dans describe et test : « verifierReservation › refuse un prêt de 16 jours » | describe('verifierReservation', () => { test('refuse un prêt de 16 jours', …) }) |
| C# (xUnit) | Methode_Situation_ResultatAttendu | Verifier_DureeDeSeizeJours_Refuse |
| PHP (PHPUnit) | Une méthode test… ou marquée #[Test], en camelCase | refuseUnPretDeSeizeJours |
| Java (JUnit, sujets d'E7) | Une méthode marquée @Test, souvent testMethodeSituation | testPossedeDroitTableNonAutorisee |
Un test, un comportement
Un test vérifie un comportement, ce qui ne veut pas dire une seule ligne expect. Vérifier qu'une réservation refusée a acceptee à false et le bon motif, c'est vérifier un seul comportement (le refus pour chevauchement) sous deux angles. En revanche, ceci mélange trois comportements :
// À éviter : trois comportements dans un test
test('verifierReservation', () => {
expect(verifierReservation(demandeLibre, reservations, aujourdhui).acceptee).toBe(true);
expect(verifierReservation(demandeEnConflit, reservations, aujourdhui).acceptee).toBe(false);
expect(verifierReservation(demandeTropLongue, reservations, aujourdhui).acceptee).toBe(false);
});
Si la deuxième assertion échoue, la troisième n'est jamais exécutée : tu ne sais pas si la règle de durée marche. Et le nom ne dit pas lequel des trois cas a cassé. Trois tests, trois noms, trois diagnostics.
Les qualités d'un bon test : FIRST
| Lettre | Qualité | Ce qu'elle exige | Ce qui la viole |
|---|---|---|---|
| F | Fast, rapide | Quelques millisecondes : tu dois pouvoir lancer toute la suite à chaque modification | Un test unitaire qui se connecte à MySQL |
| I | Independent, indépendant | Chaque test prépare ses propres données et ne dépend ni de l'ordre ni des autres | Un test qui suppose qu'un autre a déjà créé la réservation n° 2 |
| R | Repeatable, reproductible | Même résultat sur ton poste, celui d'un collègue et dans la CI, aujourd'hui et dans un an | Un test qui utilise la date du jour réelle, ou un service sur Internet |
| S | Self-validating, auto-vérifiant | Vert ou rouge, sans qu'un humain lise une sortie | Un test qui fait console.log du résultat |
| T | Timely, écrit au bon moment | Écrit avec le code, ou juste avant (TDD) | Des tests écrits six mois après, pour « faire de la couverture » |
Rendre le code testable : sortir les dépendances cachées
Imagine que verifierReservation lise elle-même la date du jour :
// Difficile à tester : la date du jour est cachée dans la fonction
export function verifierReservation(demande, reservations) {
const aujourdhui = new Date().toISOString().slice(0, 10);
// …
}
Le test « accepte une réservation qui commence aujourd'hui » devrait changer de date chaque jour, et le test « refuse une date de début passée » deviendrait faux au fil du temps. La solution est de passer la dépendance en paramètre : la fonction reçoit aujourdhui, l'application lui donne la vraie date, le test lui donne une date fixe.
// Facile à tester : la date du jour est une entrée comme les autres
export function verifierReservation(demande, reservations, aujourdhui) { /* … */ }
C'est la même idée que l'injection de dépendances en programmation objet (injection de dépendances en C#) : une classe reçoit ses dépendances (l'horloge, la base, la messagerie) au lieu de les créer. Les dépendances qu'il faut sortir ainsi sont toujours les mêmes : l'heure, le hasard, la base de données, le réseau, le système de fichiers, les variables d'environnement. Le chapitre 7 montre comment les remplacer par des doublures quand on ne peut pas simplement passer une valeur.
Choisir les cas : les classes d'équivalence
Puisqu'on ne peut pas tout tester, on découpe les entrées possibles en classes d'équivalence : des groupes de valeurs que le programme doit traiter de la même façon. Si le programme marche pour une valeur d'une classe, il marche très probablement pour toutes les autres. On teste alors une valeur par classe, valide ou invalide.
Pour la durée d'un prêt, la règle « un prêt dure au plus 15 jours, premier et dernier jour compris » donne trois classes :
| Classe | Durée | Résultat attendu | Une valeur représentative |
|---|---|---|---|
| Fin avant le début | 0 jour ou moins | Refus : « La date de fin précède la date de début. » | Du 20 au 19 mars |
| Durée valide | De 1 à 15 jours | Accepté (si le reste est bon) | Du 15 au 18 mars (4 jours) |
| Trop long | 16 jours ou plus | Refus : « Un prêt dure au plus 15 jours. » | Du 15 mars au 10 avril (27 jours) |
Choisir les cas : les valeurs limites
Les défauts se cachent aux frontières entre les classes : un < à la place d'un <=, un + 1 oublié. L'analyse des valeurs limites consiste à tester, pour chaque frontière, la dernière valeur d'une classe et la première de la suivante.
| Frontière | Valeurs à tester | Défaut qu'elles détectent |
|---|---|---|
| Entre « fin avant début » et « durée valide » | 0 jour (fin = veille du début) et 1 jour (fin = début) | fin <= debut au lieu de fin < debut : un prêt d'un jour serait refusé |
| Entre « durée valide » et « trop long » | 15 jours et 16 jours | > 15 remplacé par >= 15, ou un + 1 oublié dans le calcul de la durée |
On applique la même méthode à la date de début : hier (refusé), aujourd'hui (accepté), demain (accepté). Le test « aujourd'hui » est celui que l'on oublie le plus souvent, et c'est lui qui trouve le <= à la place du <.
Choisir les cas : les positions de deux périodes
Le chevauchement de deux périodes est un cas d'école. Une réservation existe du 10 au 14 mars ; la nouvelle demande peut se placer de dix façons différentes par rapport à elle. Chacune est une classe d'équivalence, et plusieurs sont des valeurs limites.
Les cas 2 et 3, puis 9 et 10, sont les frontières : un jour d'écart fait passer le résultat d'« acceptée » à « refusée ». Le cas 9, « commence le jour où l'autre finit », est exactement celui qui manquait à Prêt'Matos dans l'histoire du chapitre 1.
Choisir les cas : la table de décision
Quand le résultat dépend de plusieurs conditions combinées, on dresse une table de décision : une colonne par combinaison utile, une ligne par condition, puis le résultat attendu. Chaque colonne devient un test.
Prenons la route POST /api/reservations/:id/sortie, qui enregistre la sortie d'un matériel :
| Condition | T1 | T2 | T3 | T4 |
|---|---|---|---|---|
| L'appelant a un jeton valide | non | oui | oui | oui |
| L'appelant a le rôle « logistique » | – | non | oui | oui |
| La réservation existe | – | – | non | oui |
| Résultat attendu | 401 | 403 | 404 | 200, sortie enregistrée |
Le tiret signifie « peu importe » : sans jeton, la réponse est 401 quel que soit le reste. Quatre combinaisons suffisent là où huit seraient possibles. Les tests T1 et T2 sont des tests de sécurité : ils prouvent que la route refuse ce qu'elle doit refuser.
La liste de contrôle des cas oubliés
Une fois les classes et les limites trouvées, passe en revue cette liste. Chaque ligne a déjà caché un bug dans un projet d'étudiant :
| Cas | Pour Prêt'Matos |
|---|---|
| Zéro, vide, absent | Aucune réservation existante ; une liste de prêts vide ; un champ materiel absent |
| Un seul élément | Une seule réservation, un seul prêt à rappeler |
| Plusieurs éléments | Plusieurs réservations, dont une seule en conflit ; plusieurs prêts dont un échoue à l'envoi |
| Les limites | 15 et 16 jours ; aujourd'hui et hier ; périodes contiguës |
| Le format inattendu | 14/03/2027 au lieu de 2027-03-14 ; un nombre au lieu d'une chaîne |
| Les dates pièges | Fin février, année bissextile, passage d'année, changement d'heure |
| Les droits | Le bon rôle, le mauvais rôle, pas de jeton, la ressource d'un autre utilisateur |
| L'erreur d'une dépendance | La base ne répond pas, le serveur de courriel est injoignable |
Exercices
Exercice 1 sur 5 Réécrire un test en AAA Réussi À revoir
Réorganise ce test selon la structure AAA et donne-lui un nom clair.
test('test retard', () => {
expect(joursDeRetard('2027-03-14', '2027-03-17')).toBe(3);
const r = joursDeRetard('2027-03-14', '2027-03-14');
expect(r).toBe(0);
});
Voir la réponseMasquer la réponse de l'exercice 1
Le test mélange deux comportements : on le coupe en deux tests, chacun en trois temps.
test('compte les jours après le retour prévu', () => {
const retourPrevu = '2027-03-14';
const aujourdhui = '2027-03-17';
const retard = joursDeRetard(retourPrevu, aujourdhui);
expect(retard).toBe(3);
});
test('vaut 0 le jour du retour prévu', () => {
const retourPrevu = '2027-03-14';
const retard = joursDeRetard(retourPrevu, retourPrevu);
expect(retard).toBe(0);
});
Tu avais trouvé ?
Exercice 2 sur 5 Trouver le cas manquant Réussi À revoir
Un développeur a écrit ces tests pour periodesSeChevauchent, avec la réservation existante du 10 au 14 mars :
| Demande | Attendu |
|---|---|
| du 12 au 18 | chevauche |
| du 5 au 10 | chevauche |
| du 1er au 31 | chevauche |
| du 15 au 20 | ne chevauche pas |
| du 1er au 9 | ne chevauche pas |
- Quelles positions de la figure des dix positions sont testées ?
- Quelle valeur limite manque ? Écris le test correspondant.
- Quel défaut ce test aurait-il détecté dans l'histoire du chapitre 1 ?
Voir la réponseMasquer la réponse de l'exercice 2
- Les positions 8 (chevauche la fin), 3 (finit le premier jour), 7 (englobe), 10 (commence le lendemain) et 2 (finit la veille).
- Il manque la position 9 : une demande qui commence le dernier jour de la réservation (du 14 au 18). C'est la limite symétrique de la position 3, testée à gauche mais pas à droite.
test('une période qui commence le dernier jour chevauche', () => {
const pc07 = { debut: '2027-03-10', fin: '2027-03-14' };
expect(periodesSeChevauchent(pc07, { debut: '2027-03-14', fin: '2027-03-18' })).toBe(true);
});
- Le changement de
<=en<dans la seconde moitié de la condition (b.debut <= a.fin). Avec ce test, la modification de Yanis passait au rouge immédiatement. Le chapitre 12 montre qu'un outil peut trouver ce genre de trou automatiquement.
Tu avais trouvé ?
Exercice 3 sur 5 Classes d'équivalence et valeurs limites Réussi À revoir
L'équipe ajoute la prolongation d'un prêt : « Un salarié peut prolonger son prêt d'un nombre de jours compris entre 1 et 7, à condition que la durée totale du prêt ne dépasse pas 15 jours. » Julie a un prêt du 10 au 14 mars (5 jours).
- Donne les classes d'équivalence du nombre de jours demandés.
- Donne les valeurs limites à tester pour le prêt de Julie, avec le résultat attendu de chacune.
Voir la réponseMasquer la réponse de l'exercice 3
- Trois classes selon la première règle : moins de 1 jour (0 ou négatif : refusé), de 1 à 7 jours (possible), plus de 7 jours (refusé). La seconde règle découpe encore la classe du milieu : la durée totale doit rester de 15 jours au plus.
- Pour un prêt de 5 jours, la durée totale reste valide tant que la prolongation fait au plus 10 jours : c'est donc la règle des 7 jours qui est la plus restrictive ici.
| Jours demandés | Durée totale | Attendu | Pourquoi tester |
|---|---|---|---|
| 0 | 5 | refusé | limite basse, côté invalide |
| 1 | 6 | accepté | limite basse, côté valide |
| 7 | 12 | accepté | limite haute, côté valide |
| 8 | 13 | refusé | limite haute, côté invalide |
Pour tester la règle des 15 jours, il faut un autre prêt, plus long : avec un prêt de 10 jours, prolonger de 5 jours donne 15 (accepté) et de 6 jours donne 16 (refusé). Une règle combinée demande des données choisies pour atteindre chaque frontière.
Tu avais trouvé ?
Exercice 4 sur 5 Table de décision Réussi À revoir
La route DELETE /api/reservations/:id annule une réservation. Règles : il faut être connecté (sinon 401) ; la réservation doit exister (sinon 404) ; seul son auteur peut l'annuler, ou un membre de la logistique (sinon 403) ; une réservation dont le matériel est déjà sorti ne peut pas être annulée (409). Sinon, la réponse est 204.
Dresse la table de décision et donne le nombre de tests nécessaires.
Voir la réponseMasquer la réponse de l'exercice 4
| Condition | T1 | T2 | T3 | T4 | T5 | T6 |
|---|---|---|---|---|---|---|
| Connecté | non | oui | oui | oui | oui | oui |
| La réservation existe | – | non | oui | oui | oui | oui |
| Auteur ou logistique | – | – | non | oui (auteur) | oui (logistique) | oui |
| Matériel déjà sorti | – | – | – | non | non | oui |
| Attendu | 401 | 404 | 403 | 204 | 204 | 409 |
Six tests. T4 et T5 donnent le même résultat mais testent deux chemins d'autorisation différents (l'auteur, puis la logistique) : les deux sont nécessaires, car un défaut peut toucher l'un sans l'autre.
Tu avais trouvé ?
Exercice 5 sur 5 Le test qui dépend du jour Réussi À revoir
Ce test passait en mars et échoue en avril. Explique pourquoi et corrige le code et le test.
export function estEnRetard(pret) {
const aujourdhui = new Date().toISOString().slice(0, 10);
return aujourdhui > pret.fin;
}
test("un prêt qui finit le 30 mars n'est pas en retard", () => {
expect(estEnRetard({ fin: '2027-03-30' })).toBe(false);
});
Voir la réponseMasquer la réponse de l'exercice 5
La fonction lit la date réelle : le résultat dépend du jour où l'on lance le test. Après le 30 mars 2027, le prêt est en retard et le test échoue, alors que le code n'a pas changé. Le test n'est pas reproductible (le R de FIRST).
On passe la date en paramètre :
export function estEnRetard(pret, aujourdhui) {
return aujourdhui > pret.fin;
}
test("un prêt n'est pas en retard le jour de son retour", () => {
expect(estEnRetard({ fin: '2027-03-30' }, '2027-03-30')).toBe(false);
});
test('un prêt est en retard le lendemain de son retour', () => {
expect(estEnRetard({ fin: '2027-03-30' }, '2027-03-31')).toBe(true);
});
La comparaison de chaînes > fonctionne ici parce que le format AAAA-MM-JJ se trie dans l'ordre chronologique. Et toISOString() donne la date en temps universel (UTC) : entre minuit et 1 h ou 2 h du matin à Paris, elle renvoie encore la veille. Encore une raison de ne pas lire l'horloge au cœur d'une règle métier.
Tu avais trouvé ?