ConstructiCat Logo
CodeBust.
Browse section ▾

Code difficile à tester.

Du code de production dont la conception (couplage fort, dépendances cachées, état global, IO non déterministe ou interfaces uniquement asynchrones) force les tests à des contorsions maladroites, ou rend une unité tout simplement impossible à exercer en isolation.

##Signs and Symptoms

Cette odeur vit dans le code de production, mais on la découvre en écrivant des tests. Le signe révélateur est qu'un test unitaire ne peut pas être écrit proprement — le test doit se battre contre la conception pour mettre le code testé dans un état connu et en observer le résultat.

Guettez ces symptômes :

  • Blocs d'initialisation gigantesques. Vous devez construire une toile de collaborateurs juste pour instancier la classe testée (« je ne peux pas tester OrderService sans aussi câbler une BD, une passerelle et un chargeur de configuration »).
  • Aucune jointure pour injecter une doublure. Le code appelle new ConcreteThing(), atteint un singleton statique (Database.getInstance()), ou frappe directement le réseau/système de fichiers/horloge, si bien qu'il n'y a nulle part où substituer un stub ou un faux.
  • Non-déterminisme intégré. La logique dépend de Date.now(), Math.random(), process.env, ou d'un timing à l'horloge murale que le test ne peut contrôler, produisant des résultats instables ou non reproductibles.
  • Aucun point d'observation / aucun point de contrôle. Le résultat que vous voulez affirmer est enfoui dans un état privé ou un effet de bord, si bien que les tests y accèdent par réflexion, casts ou getters réservés aux tests.
  • Interface uniquement asynchrone. La seule façon de piloter le code est de démarrer un timer/thread/file puis de sleep() ou sonder, car l'achèvement n'est jamais directement observable.
  • Vous modifiez le code de production juste pour le tester. Passer un private en public, ajouter des crochets de test if (testMode) { ... }, ou sous-classer uniquement pour atteindre les internes.
// Difficile à tester : dépendances cachées + câblées en dur, état global, non-déterminisme
class OrderService {
  placeOrder(cart) {
    const db = Database.getInstance();            // singleton global (aucune jointure)
    const gateway = new StripeGateway(API_KEY);   // dép. concrète câblée en dur -> réseau réel
    const id = Math.random().toString(36).slice(2); // non déterministe
    const charge = gateway.charge(cart.total);    // appel HTTP réel dans l'unité
    db.save({ id, at: Date.now(), charge });       // horloge non injectée
    return id;
  }
}
// Pour « tester unitairement » ceci, vous devez frapper Stripe, monkey-patcher Date/Math globalement,
// et inspecter un singleton partagé — c.-à-d. un test indirect via des interfaces maladroites.

##Reasons for the Problem

Pourquoi cela arrive

  • Test-après sur du code hérité. Meszaros nomme la cause racine : l'absence de conception pour la testabilité. La testabilité émerge naturellement du TDD, mais quand les tests sont écrits « après », rien n'a poussé la conception à exposer des points de contrôle et d'observation, qui doivent donc être rajoutés a posteriori.
  • Couplage fort et dépendances câblées en dur. Instancier des collaborateurs concrets avec new (ou les tirer de singletons statiques) signifie qu'on ne peut pas les remplacer par des doublures de test — le test exerce l'unité et tout ce qu'elle touche.
  • État global/partagé et entrées cachées. Singletons, statiques, temps ambiant, aléa et lectures d'environnement sont des entrées que le test n'a jamais déclarées et ne peut pas fixer, si bien que le comportement est implicite et incontrôlable.
  • Asynchronie et effets de bord au cœur. Quand la logique métier est emmêlée à des threads, des files, des IO ou l'UI, il n'y a aucune valeur pure à affirmer.

Pourquoi c'est nuisible

  • Fiabilité. Les tests forcés d'utiliser des IO réelles, des sleeps, des singletons partagés ou des horloges globales deviennent lents, instables et dépendants de l'ordre — le chemin classique vers un test erratique/fragile.
  • Maintenabilité. Une mise en place énorme et des tests qui fouillent dans les internes couplent la suite de tests aux détails d'implémentation, si bien que des refactorings inoffensifs cassent les tests (Test fragile / Fixture fragile).
  • Fausse confiance. Les chemins de code les plus difficiles et les plus importants ne sont testés qu'à travers des interfaces grossières et indirectes — ou pas du tout. Les chiffres de couverture paraissent bons alors que la logique risquée est à peine exercée.
  • Lisibilité. Un test dominé par l'échafaudage masque le seul comportement qu'il est censé spécifier, et ne documente donc rien.

Dans l'Open Catalog of Test Smells, le Code difficile à tester est le pendant, côté production, d'odeurs de test comme le Test indirect et le Test fragile : la mauvaise conception est la cause, les tests maladroits sont le symptôme.

##Treatment

Corrigez la conception, pas le test. L'objectif est de donner à chaque unité un point de contrôle (un moyen de la mettre dans un état connu) et un point d'observation (un moyen de lire le résultat).

  1. Introduisez des jointures via l'injection de dépendances. Passez les collaborateurs en paramètres (injection par constructeur) au lieu de les construire ou de les rechercher. Injectez aussi les entrées ambiantes — horloge, générateur d'id/uuid, source d'aléa — pour qu'elles deviennent des paramètres contrôlables.
  2. Dépendez d'abstractions. Programmez contre une interface et substituez un stub/faux/mock dans les tests. Cela supprime directement la Dépendance câblée en dur de Designite et réduit la Dépendance excessive.
  3. Appliquez le patron Humble Object. Repoussez les parties véritablement difficiles à tester (UI, glue asynchrone, IO brutes) dans un adaptateur mince sans logique, et déplacez la logique de décision dans un objet ordinaire et synchrone que vous pouvez tester directement.
  4. Extrayez des fonctions pures. Séparez le calcul des effets de bord ; affirmez sur les valeurs retournées, effectuez les IO uniquement à la frontière.
  5. Rendez l'asynchrone observable. Renvoyez une promesse/future ou exposez un signal d'achèvement, et injectez l'ordonnanceur pour que les tests utilisent de faux timers au lieu de sleep.
  6. Pour le code hérité que vous ne pouvez pas encore reconcevoir, utilisez une sous-classe spécifique au test ou une jointure étroitement délimitée (techniques de Feathers) pour mettre le code sous test, puis refactorisez vers l'injection — plutôt que de laisser des crochets if (testMode) permanents en production.
  7. Évitez l'état global/statique. Remplacez les singletons par des dépendances passées explicitement pour que les tests ne partagent ni ne réinitialisent un état caché.
// APRÈS : dépendances, horloge et id sont injectés -> testable en isolation
class OrderService {
  constructor(db, gateway, clock = () => Date.now(), newId = uuid) {
    this.db = db; this.gateway = gateway; this.clock = clock; this.newId = newId;
  }
  placeOrder(cart) {
    const id = this.newId();
    const charge = this.gateway.charge(cart.total); // une abstraction PaymentGateway
    this.db.save({ id, at: this.clock(), charge });
    return id;
  }
}

// Test : déterministe, sans réseau, sans patch global, rapide.
const svc = new OrderService(fakeDb, fakeGateway, () => 1000, () => 'id-1');
expect(svc.placeOrder({ total: 50 })).toBe('id-1');
expect(fakeGateway.charge).toHaveBeenCalledWith(50);
expect(fakeDb.saved[0]).toEqual({ id: 'id-1', at: 1000, charge: fakeGateway.result });

##Detected by