← Retour au blog
Craftsmanship Le Test-Driven Development et l'injection de dépendances travaillant ensemble pour guider la conception logicielle.

Le Test-Driven Development et l'injection de dépendances, la bonne voie

Comment le TDD et l'injection de dépendances guident la conception logicielle : Test-First vs Test-Last vs TDD, baby steps, fakes plutôt que mocks, et l'inversion des dépendances.

📅 ✍️ Antoine Coulon
tdddependency-injectiontestingsoftware-designtypescript

Pour celles et ceux qui découvrent l’aventure de la construction de skott, une rapide introduction pour planter le décor.

skott est un devtool tout-en-un qui analyse, recherche et visualise automatiquement les dépendances de projets JavaScript, TypeScript (JSX/TSX) et Node.js (ES6, CommonJS). Il parcourt en profondeur un répertoire, parse tous les fichiers supportés, résout les imports de modules, construit le graphe complet et expose une API de graphe à partir de celui-ci.

Avec ce graphe, on peut faire beaucoup de choses, comme le visualiser dans une application web :

Un aperçu de l'application web de skott affichant le graphe de dépendances d'un projet.

Si vous connaissez les outils d’analyse statique (sinon, voyez mon article précédent sur le sujet), vous connaissez déjà une partie des difficultés. Dans le contexte de skott, en voici quelques-unes :

Ça fait déjà beaucoup, même dans une version simplifiée.

La question est : comment gérer cette complexité tout en restant assez rapide, confiant et en sécurité pour ajouter/supprimer/modifier des fonctionnalités, corriger des bugs et mener de gros refactorings ? Autrement dit, comment rendre possible une véritable agilité logicielle ?

Les tests

La première chose qui vient à l’esprit, ce sont les tests. Écrire de bons tests apporte sécurité et confiance. Mais il y a un revers : selon ce qui est testé (le système sous test) et comment, les tests peuvent conduire à une fausse confiance, faire passer en douce de mauvaises décisions de conception comme le couplage du code à des détails d’implémentation, et rendre le refactoring pénible et risqué. En tant qu’ingénieurs logiciels, c’est quelque chose que nous devrions éviter à tout prix.

Dans mon article précédent Don’t target 100% coverage, je montre comment des tests écrits d’une certaine manière peuvent produire des comportements inattendus et un faux sentiment de sécurité. Je vous recommande de le lire si vous n’êtes pas familier avec le Test-First, le Test-Last, le Test-Driven Development, le mutation testing et le code coverage.

Test-First, Test-Last et Test-Driven Development

Voyons les différences entre ces trois approches.

Test-First

Écrire le test avant d’écrire la moindre ligne de la fonctionnalité visée. Cela pose un plan précis de ce qui doit être implémenté, mais passe à côté de la partie la plus importante, le comment, parce que le Test-First ne fournit pas de boucle de feedback incrémentale.

Le Test-First peut être utile et aide généralement à augmenter la couverture de spécification, mais il omet le fait que de nouvelles contraintes et idées se découvrent en chemin. Il ne laisse pas les tests guider l’implémentation, et il favorise l’écriture de gros blocs de code d’un seul coup : on passe de 0 ligne à X lignes, où X est tout ce qui était nécessaire pour faire passer le test. Parmi ces blocs, certains peuvent être inutiles ou trop coûteux à introduire compte tenu de la complexité affrontée d’un seul coup.

Test-Last

Probablement l’approche la plus répandue : écrire tous les tests après que tout le code de la fonctionnalité a été écrit. L’objectif est d’apporter de la sécurité autour de code déjà produit, mais est-ce vraiment le cas ? Pas tout à fait. Le Test-Last est souvent utilisé pour convertir un désir de confiance (plus de tests, plus de couverture) en un sentiment de sécurité sournois et trompeur.

Souvenez-vous de la loi de Goodhart : quand une mesure devient un objectif, elle cesse d’être une bonne mesure.

Résultat : beaucoup de tests inutiles et bruyants sont ajoutés, couvrant des parties du code qui n’en ont pas besoin, soit parce que des tests de plus haut niveau le font déjà, soit parce qu’ils répètent des choses déjà testées. Comment sauriez-vous même qu’un test est inutile ou redondant ? Vous ajoutez un test, il passe, mais est-ce grâce au code que vous venez d’ajouter, ou était-ce déjà le cas ?

Parce que le Test-Last introduit les tests à la toute dernière étape du cycle de vie de la fonctionnalité, il s’accompagne de douleur : des design smells, des détails d’implémentation cachés, du code qui n’est pas facilement testable. C’est du temps gâché, parce que vous auriez pu vous en rendre compte plus tôt. Il tend aussi à favoriser les mocks (le type de test-double, spécifiquement), qui sont un smell dans la plupart des cas, ils introduisent un couplage structurel entre les tests et le code (asserter que X appelle Y avec les paramètres abc), réduisant drastiquement la flexibilité et la capacité à refactorer.

Dans ce cas, les tests deviennent fragiles : ils dépendent de détails d’implémentation et cassent au prochain changement de fonction, vous faisant détester les tests eux-mêmes.

« Les tests m’empêchent de refactorer, ils cassent tout le temps quand je change le code, je perds en flexibilité, ce n’est pas pratique. »

Existe-t-il une meilleure façon d’atteindre efficacité et sécurité tout en gardant un code hautement flexible, facilement refactorable, et abstrait des détails d’implémentation dont nous ne voulons pas dépendre ?

Test-Driven Development (TDD)

Disclaimer : je ne prône pas le TDD à tout prix, ce n’est pas une silver bullet. Le but est d’offrir un aperçu d’une discipline très mal comprise et sous-estimée. C’est ensuite votre choix de l’essayer, de la blâmer, ou de me blâmer moi. Mais croyez-moi : une fois que vous devenez correct en TDD, vous ne revenez jamais en arrière.

Le Test-Driven Development est une version plus évoluée du Test-First. C’est une discipline de développement logiciel qui vous pousse à trouver le chemin le plus rapide et le meilleur pour écrire chaque ligne de code visant une spécification métier, à travers des étapes finement décomposées (les fameux baby steps), en gérant la complexité de manière incrémentale.

À l’aide des tests, le TDD guide l’écriture de juste assez de code pour faire passer un test en échec ❌ au VERT ✅, dans des cycles de boucle de feedback très courts.

S’il n’y a aucun prérequis, aucun test en échec, pourquoi ajouterais-je une ligne de code ? Rien dans mon système ne le justifie encore. Peut-être que le comportement est déjà implémenté ? La première étape est de s’assurer que nous avons un test en échec : c’est notre premier checkpoint.

Une fois que nous l’avons, nous écrivons le code nécessaire pour le faire passer. Maintenant nous sommes sûrs d’avoir fait quelque chose d’utile : du code justifié par une spécification qui est passée du rouge au vert.

Juste après, la troisième étape du TDD entre en jeu : le refactoring. Vous êtes libre de refactorer et de produire le meilleur code tout en gardant le test vert. C’est pour cela que le TDD aide fondamentalement à concevoir le logiciel : il vous permet de refactorer le code à l’infini et en sécurité (à tout moment) tout en garantissant que le comportement attendu reste intact.

Le TDD requiert de bonnes compétences en ingénierie logicielle pour être efficace. Il crée une confrontation perpétuelle entre les choix de conception à faire et les exigences actuelles du système. Mais il nécessite de solides compétences en conception et en refactoring en amont, sans quoi vous risquez de vous retrouver bloqué ou de prendre les mauvaises décisions (ou pire, aucune du tout).

Le TDD est avant tout une affaire de boucles de feedback, et l’ingénierie logicielle en général aussi. Pensez aux boucles de feedback avec lesquelles nous travaillons tous les jours :

Tous ces éléments exploitent le principe fail fast : identifier les défaillances rapidement plutôt que de les laisser perdurer ou, pire, laisser les clients découvrir les bugs. La boucle de feedback est le fondement de l’amélioration continue.

Le TDD consiste à avoir la boucle de feedback la plus courte vers le code écrit, en vérifiant si le système produit le résultat attendu à mesure que vous ajoutez, supprimez ou modifiez du code. Grâce aux tests automatisés, la boucle est suffisamment rapide dans la plupart des cas, tant que vous respectez la nature Fast des principes F.I.R.S.T.

Un inconvénient : le TDD peut devenir contre-productif s’il est mal appliqué. À mon avis, mieux vaut ne pas pratiquer le TDD que de le pratiquer de la mauvaise manière. La courbe d’apprentissage est raide, elle requiert de fortes compétences techniques et un changement de mentalité.

Mise en pratique sur de petits exemples

Revenons à notre question initiale : comment gérer la complexité tout en restant assez rapide, confiant et en sécurité pour ajouter/supprimer/modifier des fonctionnalités, corriger des bugs et améliorer continuellement le code par le refactoring ?

Pour l’instant, la seule approche que j’ai trouvée qui fonctionne pour tout cela, c’est le Test-Driven Development. Vous pourriez peut-être faire la même chose sans lui, mais à quel coût, avec quelle confiance, et à quelle vitesse ? Sans introduire la moindre régression ? J’y suis passé moi aussi ; maintenant je ne pourrais plus jamais m’en passer.

Prenons une version très simplifiée d’une fonctionnalité de skott.

Étant donné deux modules JavaScript dont l’un dépend de l’autre, les deux devraient être analysés, et un graphe devrait contenir deux sommets reliés par une arête représentant cette relation :

index.js

import { add } from "./feature.js";

// Le reste du code qui consomme la fonction, peu importe pour notre cas

feature.js

export function add(a, b) {
  return a + b;
}

Nous voulons :

  1. lire les deux fichiers,
  2. trouver les déclarations de modules et constater que index.js importe feature.js,
  3. construire un graphe de deux sommets, index.js et feature.js, avec une arête dirigée de index.js vers feature.js (on dit que index.js est « adjacent à » feature.js).

Le résultat attendu de notre système est donc un graphe de la forme suivante :

{
  "index.js": {
    "adjacentTo": ["feature.js"]
  },
  "feature.js": {
    "adjacentTo": []
  }
}

C’est le résultat attendu que nous voulons que skott produise.

Test-First

Le Test-First suggérerait de commencer par écrire le test avec l’ensemble des attentes que nous avons vis-à-vis de notre système :

describe("Graph construction", () => {
  describe("When having two JavaScript modules", () => {
    describe("When the first module imports the second", () => {
      test("Should produce a graph with two nodes and one edge from the index module to the imported module", () => {
        createFile("feature.js").withContent(
          `
            export function add(a, b) {
                return a + b;
            }
          `
        );
        createFile("index.js").withContent(
          `
            import { add } from './feature.js';
          `
        );

        const graph = new GraphResolver();

        expect(graph.resolve()).toEqual({
          "index.js": {
            adjacentTo: ["feature.js"],
          },
          "feature.js": {
            adjacentTo: [],
          },
        });
      });
    });
  });
});

Le test comprend les trois composants principaux : Arrange / Act / Assert. L’exécuter échouera à coup sûr, puisque nous n’avons encore aucun code. Mais comment le faire passer ?

Petit rappel de toutes les étapes que nous devons franchir : parcours des fichiers, parsing des fichiers, extraction des modules, résolution des modules, construction du graphe. Le chemin est long, avec de nombreux composants impliqués : nous risquons donc d’y passer un certain temps. Malheureusement le test ne sera pas utile pendant tout ce temps ; il n’assert que le résultat final une fois que tout est déjà produit.

Test-Last

Rien ne se passe ici : le Test-Last ne veut pas que nous écrivions le moindre test pour l’instant. Bon courage.

Test-Driven Development

Enfin quelque chose qui nous aide à atteindre le comportement souhaité. Comme dit, le TDD veut que nous adoptions une approche incrémentale et que nous laissions le code croître en complexité au fil des étapes. Par conception, il veut l’approche baby step : ajouter le minimum de code pour faire passer le test.

Voici le premier test qui vient à l’esprit :

describe("Graph construction", () => {
  describe("When not having any modules", () => {
    test("Should produce an empty graph", () => {
      const graph = new GraphResolver();

      expect(graph.resolve()).toEqual({});
    });
  });
});

D’abord les choses importantes : nous nous concentrons sur la forme du contrat que nous voulons exposer. Cela contraint notre réflexion, un problème à la fois. Un des bénéfices du TDD est que le test devient le premier client du code lui-même, laissant le design émerger progressivement.

Voici la façon la plus rapide de faire passer le test :

class GraphResolver {
  resolve() {
    return {};
  }
}

Facile : une classe brute avec une méthode renvoyant un objet vide en dur.

Vient ensuite la question : quel est le prochain test ? N’oubliez pas, le but final est de parcourir les fichiers et de construire les dépendances entre eux.

L’efficacité du TDD vient du choix des tests : il n’y a pas de magie. Le développeur est responsable de trouver le bon ordre ; chaque étape manquée ou surdimensionnée est une occasion ratée de bénéficier pleinement du TDD. Mais ne vous inquiétez pas : si vous vous retrouvez dans cette situation, vous pouvez toujours rétrograder vers une étape plus petite.

Alors, quelle est la suite ? Après le cas « aucun module », introduisons un module à analyser :

describe("When having one JavaScript module", () => {
    test("Should produce a graph with the module", () => {
      /**
       * ARRANGE
       *
       * Comment créer un contexte de système de fichiers incluant ce fichier
       * spécifiquement pour ce test ?
       */
      const graph = new GraphResolver();

      expect(graph.resolve()).toEqual({
        "index.js": {},
      });
    });
  });

Même si cela semble simple, la complexité augmente dès lors que nous introduisons la notion de fichier dans la partie Arrange. Notre outil est basé sur le système de fichiers, il doit donc le lire à un moment donné. Cela signifie-t-il que le test lui-même devrait lire le système de fichiers ? Pas du tout.

Mais pourquoi ne pas utiliser le vrai système de fichiers tout de suite, via l’API Node.js ? Parce qu’introduire le vrai système de fichiers est exactement ce que nous voulons éviter dans les tests unitaires : nous les voulons rapides, isolés et reproductibles d’une exécution à l’autre, et la couche du système de fichiers ne coche aucun de ces critères. Nous voulons une version totalement maîtrisable et spécialisée du module de système de fichiers de Node.js. Avons-nous besoin de tout en faker ? Non, seulement le sous-ensemble dont nous avons besoin pour l’instant.

Ce petit test apporte tout un nouveau niveau de réflexion autour de la fonctionnalité. On pourrait arguer qu’il complique la tâche pour peu de gain, mais il nous force à construire un code testable sur lequel nous avons un contrôle total, ce qui nous amène déjà à concevoir des solutions.

Ce que nous voulons en premier, c’est un système de fichiers en mémoire minimal capable de faker un vrai :

test("Should produce a graph with the module", () => {
  const fileSystem = new FakeFileSystem();
  fileSystem.createFile("index.js").empty();
});

Maintenant que nous disposons de ce contexte en mémoire, nous voulons qu’il soit utilisable au sein du contexte de GraphResolver, autrement dit, injecté dedans. Cela nous conduit naturellement vers l’injection de dépendances (DI).

L’injection de dépendances

L’injection de dépendances est un pattern qui découple l’utilisation des dépendances de leur création. C’est le processus consistant à injecter les dépendances d’un service depuis l’extérieur ; le service lui-même ne sait pas comment les créer.

La dépendance (le module de système de fichiers) est créée à l’extérieur du contexte de GraphResolver et injectée immédiatement :

class FakeFileSystem {
 // ...
}

const fileSystem = new FakeFileSystem();
fileSystem.createFile("index.js").empty();

class GraphResolver {
  constructor(private readonly fileSystem: FakeFileSystem) {}
}

// Injection de dépendances
const graphResolver = new GraphResolver(fileSystem);

Nous pouvons maintenant faker le comportement requis du module de système de fichiers :

class FakeFileSystem {
  fs = {};

  createFile(name) {
    return {
      empty: () => {
        this.fs[name] = "";
      },
    };
  }

  readFiles() {
    return Object.keys(this.fs);
  }
}

Un simple fake qui émule les opérations dont nous avons besoin pour l’instant : createFile et readFiles. Rien de plus ; nous ne couvrons que ce qui est nécessaire dans le contexte du test.

Pour faire passer le test, nous consommons cette implémentation dans le service GraphResolver :

class GraphResolver {
  graph = {};

  constructor(private readonly fileSystem: FakeFileSystem) {}

  resolve() {
    for (const filename of this.fileSystem.readFiles()) {
      this.graph[filename] = {};
    }

    return this.graph;
  }
}

Et ça passe, conformément à nos attentes. Remarquez à quel point nous restons concentrés sur le comportement désiré actuel : nous n’avons produit que le strict minimum nécessaire. La méthode readFiles ne renvoie que le nom du fichier, puisque le test ne requiert qu’un enregistrement incluant ce nom, même si nous savons qu’il nous faudra aussi le contenu des fichiers plus tard.

Nous sommes aussi entièrement en mémoire et isolés du vrai système de fichiers : la dépendance ne produit que le résultat dont nous avons besoin, et nous n’avons pas à faker toute l’API du système de fichiers de Node.js.

Ici je saute volontairement quelques étapes intermédiaires, sachant pertinemment que nous voudrons lire tous les fichiers du répertoire à un moment donné. Plutôt que d’introduire une méthode readFile (fichier unique), je vais directement à readFiles. En règle générale, suivez une séquence de Transformations (la Transformation Priority Premise, par Robert C. Martin) pour produire le code minimal qui fait passer le test en boucles courtes.

Après l’étape verte vient la troisième phase du TDD : le refactoring.

Le refactoring consiste à changer le design du système sans altérer son comportement.

Nous pouvons utiliser cette phase pour améliorer une petite chose dans la configuration de la DI. La DI peut être utilisée de manière à se découpler des implémentations concrètes. Ci-dessus, GraphResolver est directement couplé à une instance de FakeFileSystem, ne laissant aucune place à la flexibilité et n’offrant aucun moyen d’injecter autre chose respectant le même contrat. Pire, cela fait fuiter des détails d’implémentation : FakeFileSystem expose createFile, qui n’existe que pour le test mais n’a aucune valeur pour GraphResolver. GraphResolver ne devrait connaître que ce qui le concerne : la méthode readFiles.

Nous introduisons donc une interface générique, faisant dépendre GraphResolver d’abstractions, et non d’une implémentation concrète. Cela nous amène au principe d’inversion des dépendances (le D de SOLID). De cette façon, skott obtient une manière agnostique du runtime de parcourir les systèmes de fichiers :

interface FileSystem {
   readFiles(): string[];
}

class FakeFileSystem implements FileSystem {
  // inchangé
}

class GraphResolver {
  constructor(private readonly fileSystem: FileSystem) {}
                                           // ^ ceci change
}

Tous les tests passent toujours : nous avons juste joué avec les interfaces et le compilateur statique pour resserrer les bons types dans GraphResolver.

Maintenant, commençons à introduire des dépendances entre modules JavaScript. Comme dit, celles-ci peuvent être modélisées par un graphe dirigé, où les fichiers sont des sommets et les relations des arêtes dirigées.

En terminologie de graphe, un sommet A qui dépend d’un autre sommet B est dit « adjacent à B ».

Un concept à la fois. Cette fois nous n’avons même pas besoin d’un nouveau test, nous pouvons mettre à jour le précédent :

expect(graph.resolve()).toEqual({
-        "index.js": {},
+        "index.js": {
+          adjacentTo: [],
+        },
      });

Cela échoue ; maintenant nous le faisons passer :

resolve() {
    for (const filename of this.fileSystem.readFiles()) {
-      this.graph[filename] = {};
+      this.graph[filename] = {
+        adjacentTo: [],
+      };
    }

    return this.graph;
  }

Ajouter deux modules ne nous ferait pas avancer. Il nous faut un test qui nous guide vers le cas où un import crée une arête entre deux modules :

describe("When having two modules with one dependency", () => {
    test("Should produce a graph with both modules and a dependency between the two", () => {
      const fileSystem = new FakeFileSystem();
      const fileWithModuleImport = `import "./feature.js";`;

      fileSystem.createFile("index.js").content(fileWithModuleImport);
      fileSystem.createFile("feature.js").empty();

      const graph = new GraphResolver(fileSystem);

      expect(graph.resolve()).toEqual({
        "index.js": {
          adjacentTo: ["feature.js"],
        },
        "feature.js": {
          adjacentTo: [],
        },
      });
    });
  });

En écrivant toujours le code le plus simple qui fait passer le test, nous ajoutons un petit if dans resolve. Selon la Transformation Priority Premise, c’est la transformation intermédiaire : (unconditional → if) qui scinde le chemin d'exécution :

  resolve() {
    for (const [fileName, fileContent] of this.fileSystem.readFiles()) {
+      if (fileContent.includes("import")) {
+        const moduleName = fileContent.split("./")[1].split("'")[0];
+
+        this.graph[fileName] = {
+         adjacentTo: [moduleName],
+        };
+
+       continue;
+     }

      this.graph[fileName] = {
        adjacentTo: [],
      };
    }

    return this.graph;
  }

Test passant ✅. Remarquez à quel point ce split est moche (peut-être l’instruction la plus moche que j’aie jamais écrite), mais peu importe : il fait passer le test au vert. Souvenez-vous, après cette étape vous êtes libre de refactorer autant que vous le souhaitez.

Améliorons légèrement le code pendant le refactoring :

resolve() {
    for (const [fileName, fileContent] of this.fileSystem.readFiles()) {
      if (fileContent.includes("import")) {
+        const moduleImportParser = /import '(.*)';/g;
+        const [moduleImport] = moduleImportParser.exec(fileContent);
         // path vient du module "path" de Node.js
+        const moduleName = path.basename(moduleImport);

        this.graph[fileName] = {
          adjacentTo: [moduleName],
        };

        continue;
      }

      this.graph[fileName] = {
        adjacentTo: [],
      };
    }

    return this.graph;
  }

Le parsing dans les deux étapes précédentes est un détail d’implémentation (il fait partie de notre propre logique métier) et devrait rester caché, permettant de lourds refactorings sans casser la surface de l’API. Tant que resolve renvoie le graphe attendu, tout va bien, car c’est ce qui compte le plus.

À mesure que vous ajoutez progressivement des cas pour parser de manière fiable tous les types d’imports de modules (et il y en a beaucoup, y compris d’amusants cas limites), vous réaliserez qu’une RegExp ne passe pas à l’échelle et devient trop complexe à gérer. Mais rien ne vous empêche d’introduire un parser conforme à ECMAScript (meriyah, acorn, swc) qui fait le travail à votre place. Le meilleur : il reste caché dans les entrailles de votre cas d’usage, permettant un refactoring et une amélioration infinis.

Je ne rentrerai pas dans l’implémentation du parser lui-même (vous pouvez consulter le code source de skott pour un exemple complet), mais j’espère que vous pouvez désormais imaginer les prochaines étapes, et percevoir les avantages qu’apporte le TDD.

En résumé

En déroulant quelques cas d’usage, nous avons vu comment le Test-Driven Development guide de manière incrémentale le développement d’une fonctionnalité.

Non seulement il fait remonter les contraintes très tôt grâce à la boucle de feedback, mais il nous force aussi naturellement à rendre le code facilement testable (rapide, isolé, reproductible) via l’injection de dépendances. Il réduit la complexité que nous affrontons à chaque nouvelle étape, tout en allant plus vite et plus en sécurité. Et nous pouvons abuser des phases de lourd refactoring pour rendre le code plus propre (clean code et bon design étant des prérequis à un refactoring efficace), confiants que le comportement du système correspond toujours à nos attentes.

Avis personnel : je ne me sentirais pas confiant avec les fonctionnalités de skott si je n’avais pas construit plus de 130 tests unitaires avec le Test-Driven Development. Des fonctionnalités peuvent être ajoutées sans crainte, et le refactoring mené avec aisance et confiance.

Cela ne veut pas dire que skott ne peut pas produire de bugs : cela veut dire que pour les cas d’usage que skott est censé gérer, il fonctionne très probablement comme attendu. Un bug sera généralement un comportement système manquant ou un cas limite. Et c’est très bien : pour le corriger, il suffit d’ajouter le cas de test reproduisant le comportement manquant, et de laisser le flux du TDD opérer.

C’était le chapitre final de l’aventure de la construction de skott. L’ensemble du projet est open source sur GitHub.