Aller au contenu

POO : tests et pièges courants

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;
}
}
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).

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.

// Fragile : dépend de la façon dont la classe calcule
expect(panier['lignes'].size).toBe(2);
// Robuste : dépend de ce que la classe promet
panier.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).

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

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)

Une classe énorme (Manager, Utils, Service qui fait tout) : trop de responsabilités, tests lourds. Extraire par responsabilité.

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.

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

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

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 seule
liste2(): string[] { return [...this.membres]; } // copie défensive

Renvoyer des copies ou des types readonly ; copier aussi les tableaux reçus dans le constructeur.

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);
}
}

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.

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

  • 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 static modifiable.
  • Petites méthodes, noms qui expriment une intention (retirer, pas setSolde).
  • Testable par son interface publique, avec des doubles simples.
  • readonly et objets immuables quand c’est possible.
  1. Écrire (ou compléter) des tests de comportement avant de modifier.
  2. Procéder par petites étapes, tests verts à chaque étape.
  3. Commits atomiques (voir Husky : commitlint).
  4. Vérifier types et analyse après chaque étape (tsc --noEmit, ESLint).
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.