POO : tests et pièges courants
Concevoir pour tester
Section intitulée « Concevoir pour tester »Du code orienté objet bien conçu se teste sans base de données, réseau ni horloge réelle : on injecte des doubles à la place des dépendances (Polymorphisme et interfaces).
interface DepotCommandes { enregistrer(c: Commande): Promise<void>;}interface Notificateur { confirmer(c: Commande): Promise<void>;}
class ServiceCommande { constructor( private readonly depot: DepotCommandes, private readonly notificateur: Notificateur, ) {}
async passer(numero: string, totalCentimes: number): Promise<Commande> { if (totalCentimes <= 0) throw new RangeError('total invalide'); const commande = new Commande(numero, totalCentimes); await this.depot.enregistrer(commande); await this.notificateur.confirmer(commande); return commande; }}Tester avec Vitest
Section intitulée « Tester avec Vitest »import { describe, expect, it, vi } from 'vitest';
describe('ServiceCommande', () => { it('enregistre puis notifie', async () => { const depot = { enregistrer: vi.fn().mockResolvedValue(undefined) }; const notificateur = { confirmer: vi.fn().mockResolvedValue(undefined) }; const service = new ServiceCommande(depot, notificateur);
const commande = await service.passer('C-1', 2500);
expect(depot.enregistrer).toHaveBeenCalledWith(commande); expect(notificateur.confirmer).toHaveBeenCalledOnce(); });
it('refuse un total invalide sans rien enregistrer', async () => { const depot = { enregistrer: vi.fn() }; const service = new ServiceCommande(depot, { confirmer: vi.fn() });
await expect(service.passer('C-2', 0)).rejects.toThrow(RangeError); expect(depot.enregistrer).not.toHaveBeenCalled(); });});Un objet simple avec les bonnes méthodes suffit comme double : le typage structurel de TypeScript accepte { enregistrer: … } là où l’interface DepotCommandes est attendue (voir Vitest : mocks, espions et temps).
Types de doubles
Section intitulée « Types de doubles »| Double | Rôle | Exemple |
|---|---|---|
| Faux (fake) | Implémentation simplifiée mais fonctionnelle | DepotMemoire |
| Bouchon (stub) | Renvoie des réponses prédéfinies | vi.fn().mockResolvedValue(x) |
| Espion (spy) | Enregistre les appels reçus | expect(f).toHaveBeenCalledWith(…) |
| Simulacre (mock) | Espion + attentes préprogrammées | vi.fn() avec assertions |
Préférer un faux (dépôt en mémoire) quand plusieurs tests s’en servent : il produit des tests plus lisibles et moins fragiles que de nombreux mockResolvedValue.
Tester le comportement, pas l’implémentation
Section intitulée « Tester le comportement, pas l’implémentation »// Fragile : dépend de la façon dont la classe calculeexpect(panier['lignes'].size).toBe(2);
// Robuste : dépend de ce que la classe prometpanier.ajouter(clavier);panier.ajouter(souris, 2);expect(panier.totalCentimes).toBe(9900);Un test qui accède à l’état privé (panier['lignes']) casse à chaque refactorisation. Tester par l’interface publique : si c’est difficile, la classe est mal conçue (trop de responsabilités, dépendances cachées).
Tester une classe avec du temps
Section intitulée « Tester une classe avec du temps »import { afterEach, beforeEach, expect, it, vi } from 'vitest';
class Session { private readonly creeeLe: number; constructor( private readonly dureeMs: number, private readonly maintenant: () => number = Date.now, ) { this.creeeLe = this.maintenant(); } get active(): boolean { return this.maintenant() - this.creeeLe < this.dureeMs; }}
it('expire après la durée', () => { let t = 1000; const session = new Session(500, () => t); expect(session.active).toBe(true); t += 600; expect(session.active).toBe(false);});Injecter une horloge (une fonction maintenant) rend la classe testable sans simuler Date ; sinon utiliser vi.useFakeTimers().
Pièges courants
Section intitulée « Pièges courants »1. Héritage excessif ou inadapté
Section intitulée « 1. Héritage excessif ou inadapté »| Symptôme | Remède |
|---|---|
| Hiérarchie de 3 niveaux et plus | Aplatir : composition, interfaces |
| Sous-classe qui lève « non supporté » | Revoir : interfaces plus petites (SOLID) |
| Classe qui hérite pour réutiliser du code | Composition (déléguer) |
2. « Classe divine »
Section intitulée « 2. « Classe divine » »Une classe énorme (Manager, Utils, Service qui fait tout) : trop de responsabilités, tests lourds. Extraire par responsabilité.
3. État partagé et global
Section intitulée « 3. État partagé et global »class Compteur { static total = 0; // état global : les tests s'influencent static incrementer() { Compteur.total++; }}Éviter les membres static modifiables, les singletons, les variables de module modifiables : les tests deviennent dépendants de l’ordre. Instancier et injecter.
4. Getters et setters mécaniques
Section intitulée « 4. Getters et setters mécaniques »class Personne { private _nom = ''; get nom() { return this._nom; } set nom(v: string) { this._nom = v; } // aucune règle : équivaut à un champ public}Un accesseur qui ne fait que lire et écrire n’apporte rien : utiliser un simple attribut. Un accesseur se justifie quand il valide, calcule ou protège un invariant (Encapsulation et héritage).
5. this perdu
Section intitulée « 5. this perdu »Méthode passée en rappel : boutons.forEach(obj.methode). Utiliser une fonction fléchée, bind, ou appeler via l’objet (Classes et objets).
6. Mutabilité involontaire
Section intitulée « 6. Mutabilité involontaire »class Equipe { constructor(private membres: string[]) {} liste() { return this.membres; } // renvoie la référence : l'appelant peut modifier l'état interne}
const e = new Equipe(['a']);e.liste().push('intrus');liste(): readonly string[] { return this.membres; } // type en lecture seuleliste2(): string[] { return [...this.membres]; } // copie défensiveRenvoyer des copies ou des types readonly ; copier aussi les tableaux reçus dans le constructeur.
7. Constructeurs qui font trop
Section intitulée « 7. Constructeurs qui font trop »Un constructeur qui ouvre une connexion, lit un fichier ou fait un appel réseau est difficile à tester et à faire échouer proprement. Garder le constructeur simple (affectations et vérifications) et utiliser une fabrique asynchrone pour les travaux lourds :
class Connexion { private constructor(private readonly socket: unknown) {} static async ouvrir(adresse: string): Promise<Connexion> { const socket = await connecter(adresse); return new Connexion(socket); }}8. Abstractions prématurées
Section intitulée « 8. Abstractions prématurées »Une interface pour une seule implémentation, une fabrique pour un seul type : de la complexité sans bénéfice. Introduire l’abstraction quand une seconde variante ou un besoin de test apparaît.
9. Comparaison d’objets
Section intitulée « 9. Comparaison d’objets »new Argent(100) === new Argent(100); // false : identitéDéfinir une méthode equals, ou utiliser des objets-valeur comparés par leurs champs (toEqual dans les tests).
10. Duplication par copier-coller dans les sous-classes
Section intitulée « 10. Duplication par copier-coller dans les sous-classes »Si deux sous-classes ont le même code, le remonter dans une classe abstraite ou l’extraire dans un composant partagé (composition).
Liste de contrôle d’une classe
Section intitulée « Liste de contrôle d’une classe »- Une seule raison de changer.
- État privé ; invariants protégés par les méthodes.
- Dépendances injectées (constructeur), sous forme d’interfaces.
- Pas d’état global ni de
staticmodifiable. - Petites méthodes, noms qui expriment une intention (
retirer, passetSolde). - Testable par son interface publique, avec des doubles simples.
readonlyet objets immuables quand c’est possible.
Refactoriser en sécurité
Section intitulée « Refactoriser en sécurité »- Écrire (ou compléter) des tests de comportement avant de modifier.
- Procéder par petites étapes, tests verts à chaque étape.
- Commits atomiques (voir Husky : commitlint).
- Vérifier types et analyse après chaque étape (
tsc --noEmit, ESLint).
Dépannage
Section intitulée « Dépannage »| Symptôme | Piste |
|---|---|
| Tests qui réussissent seuls et échouent ensemble | État partagé (static, singleton, variable de module) : l’injecter ou le réinitialiser |
| Test difficile à écrire | Classe trop couplée : extraire une interface, injecter la dépendance |
| Test cassé à chaque refactorisation | Test lié à l’implémentation (état privé, ordre des appels) : tester le comportement |
TypeError: x is not a function avec un double |
Le faux n’implémente pas toutes les méthodes utilisées |
| Double qui ne vérifie pas le bon appel | Assertion sur la mauvaise instance de vi.fn() |
| Modification inattendue d’un objet partagé | Référence renvoyée par un accesseur : copie ou type readonly |
Ubuntu 24.04 et 26.04 : différences à connaître
Section intitulée « Ubuntu 24.04 et 26.04 : différences à connaître »Aucune.