Reading notes
Test-Driven Development: By Example

Test Driven Development: By Example

Preface

  • TDD permet de gérer la peur pendant le développement : comme on avance pas à pas, avec chaque pas solidement ancré, on n’a plus peur de tomber, et donc on peut se concentrer sur les feedbacks, réfléchir et tenter des choses tranquillement.
  • Plus on a un code complexe à développer, et plus on doit faire de petites étapes, pour pouvoir se reposer entre chaque étape, et que la difficulté soit en petits morceaux.
  • Erich Gamma utilise l’expression test infected pour désigner le fait que la manière d’écrire le code est influencée par le TDD : on réfléchit plus souvent à ce qu’on fait, et on développe un code plus modulaire pour qu’il soit testable.
  • Bien que le TDD soit très utile, il y a certains domaines pour lesquels il ne permet pas de faire de petites étapes, par exemple la sécurité et la concurrence, qui ne peuvent pas vraiment être reproduits par des tests automatisés.

Introduction

  • Kent raconte le moment où un client a demandé à leur boss et au tech lead, Ward Cunningham, s’ils pouvaient faire en sorte que leur produit puisse gérer le multi-currency.
    • WyCash est un produit permettant de gérer des produits financiers, développé par une petite équipe en quelques années en utilisant la POO.
      • Au départ, l’objet Dollar avait été sous-traité, avec pour résultat qu’il contenait du calcul et du formatage.
      • L’équipe de Ward a finalement récupéré la responsabilité de l’objet, et l’a petit à petit vidé de sa logique de calcul et de formatage.
      • L’une des fonctionnalités les plus complexes était le weighted average. Son code avait petit à petit été rassemblé en un seul endroit appelé AveragedColumn.
    • Ward est parti sur une petite expérimentation sur AveragedColumn, pour vérifier si le multi-currency y était possible dans un temps raisonnable.
      • Il a remplacé Dollar par un objet Currency plus générique.
      • Il a finalement fait passer les tests existants, et s’est retrouvé suffisamment confiant pour dire à son boss que le multi-currency pouvait être implémenté.
  • Pour pouvoir profiter de telles opportunités business, il faut :
    • De la méthode : que l’équipe de dev pratique le design incrémental au quotidien, et puisse utiliser cette expérience pour faire ce gros changement.
    • De la motivation : que l’équipe de devs comprenne l'importance de la tâche d’un point de vue business, pour se lancer dans un grand changement de design.
    • De l’opportunité : que le code et les tests associés soient dans un état qui permettent de facilement mettre en place ce changement sans tout casser.
  • Au final, le TDD permet à une équipe de développeurs moyens de faire la même chose. Il suffit de suivre deux règles avec discipline :
    • Écrire un test qui échoue avant d’écrire du code.
    • Enlever la duplication.

Part I - The Money Example

  • Les étapes fondamentales du TDD sont :
    • 1 - Ajouter un test rapidement.
    • 2 - Jouer tous les tests, et voir un seul en erreur.
    • 3 - Faire un petit changement.
    • 4 - Jouer tous les tests et les voir en succès.
    • 5 - Refactorer pour enlever la duplication.
  • Parmi les choses qui seront surprenantes :
    • A quel point chaque test couvre peu de fonctionnalités.
    • A quel point les changements initiaux sont petits et sales.
    • A quel point on joue souvent les tests.
    • Le nombre important d’étapes pour le refactoring.

1 - Multi-Currency Money

  • Le logiciel gère des actions d’entreprises, et permet à un client de calculer le montant total de ses actions en dollars, en fonction du prix de chaque action.

    • La nouvelle fonctionnalité “multi-currency” consiste à ce que le montant des actions puisse être renseigné dans d’autres devises, sachant qu’à la fin on voudra quand même calculer le total de l’ensemble des actions en dollars.
    • On a l’information du taux de conversion entre devises dans une table.
  • La première chose à faire en TDD est de se poser la question des** fonctionnalités qu’on veut, exprimées sous forme de tests**, qui s’ils passent prouveront que le code fait ce qu’on veut.

  • On va créer une todo list, qu’on va maintenir tout au long de nos changements : dès qu’on a une nouvelle idée de chose qu’il faudra implémenter, on l’ajoute, et dès qu’on en a fini une on la coche ou la barre.

    • Dans l’état actuel, on a deux idées :
      • $5 + 10CHF = $10 si le taux est de 2:1
      • $5 2 = $10
  • On commence par écrire un test pour la fonctionnalité de multiplication :

    it("multiplies money value with given value", () => {
      const five = new Dollar(5);
      five.times(2);
      expect(five.amount).toBe(10);
    });
    • Des problèmes nous viennent en tête à propos de notre test :
      • Quid des side-effects dans la classe Dollar ?
      • La variable membre amount devrait être privée.
      • On les met dans notre todo list, et on garde notre objectif de faire passer le test au vert rapidement.
        • $5 + 10CHF = $10 si le taux est de 2:1
        • $5 2 = $10
        • Mettre "amount" en privé
        • Quid des side-effects de Dollar ?
  • Il nous faut déjà régler les erreurs de compilation.

    • On va les régler une par une :
      • Créer la classe Dollar.
      • Lui ajouter une méthode times qui ne fait rien.
      • Lui ajouter une variable membre amount.
      class Dollar {
        public amount: number;
        constructor(amount: number) {
          this.amount = 0;
        }
       
        times(multiplier: number) {}
      }
  • On peut enfin jouer le test et le voir échouer.

  • On fait passer le test de la manière la plus rapide :

    class Dollar {
      public amount: number;
      constructor(amount: number) {
        this.amount = 10;
      }
     
      times(multiplier: number) {}
    }
  • On va maintenant enlever la duplication :

    • Elle se situe ici entre le code et le test.

    • On peut commencer par remplacer 10 par 5 * 2.

      class Dollar {
        public amount: number;
        constructor(amount: number) {
          this.amount = 5 * 2;
        }
       
        times(multiplier: number) {}
      }
    • On peut ensuite déplacer le 5 * 2 dans la méthode times.

      class Dollar {
        constructor(public amount: number) {}
       
        times(multiplier: number) {
          return 5 * 2;
        }
      }
    • Puis on peut remplacer le 5 qui est en fait la variable member amount.

      class Dollar {
        constructor(public amount: number) {}
       
        times(multiplier: number) {
          this.amount *= 2;
        }
      }
    • Et enfin on peut remplacer le 2 qui est le paramètre multiplier.

      class Dollar {
        constructor(public amount: number) {}
       
        times(multiplier: number) {
          this.amount *= multiplier;
        }
      }
    • Le test passe toujours.

  • La raison pour laquelle on enlève la duplication est pour enlever des dépendances, parce que la duplication est le symptôme de la dépendance.

    • L’idée est de faire en sorte que le prochain test puisse être passé au vert en un seul changement, et non pas en ayant à changer le code en plusieurs endroits.
  • Les étapes au moment du refactoring sont extrêmement petites. Kent ne code pas toujours comme ça, mais il peut coder comme ça.

    • Il faut s’exercer à le faire : si on sait coder par étapes extrêmement petites, alors on saura doser jusqu'à la bonne étape, alors que si on ne sait coder que par grandes étapes, on ne saura pas si on descend suffisamment petit.

2 - Degenerate Objects

  • Le TDD consiste vraiment à privilégier le fait que ça marche en premier, avant de faire en sorte d’avoir du clean code.
    • Si on a une solution évidente en tête, on peut toujours l’écrire, mais si la solution “évidente” met une minute, il vaut mieux commencer par une solution qui marche en quelques secondes.
  • On en est à cet état de la todo list :
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5* 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
  • Avec la version actuelle, on a un problème de side-effect : si on appelle plusieurs fois times() sur un objet Dollar, la valeur du dollar sera modifiée plusieurs fois.
    • Une idée qui nous vient immédiatement est de faire en sorte que times() retourne une nouvelle instance de Dollar au lieu de modifier celle sur laquelle il est appelé.
    • On va donc changer le test pour qu’il vérifie que ce side-effect ne se produise plus.
      it("multiplies money value with given value", () => {
        const five = new Dollar(5);
        let product = five.times(2);
        expect(product.amount).toBe(10);
        product = five.times(3);
        expect(product.amount).toBe(15);
      });
      • Le fait d’avoir un sentiment de code smell, et de pouvoir le retranscrire facilement sous forme de test est un skill qui arrive avec l’expérience.
    • Puis on change la déclaration de Dollar pour que le code compile.
      class Dollar {
        // ...
        times(multiplier: number) {
          this.amount *= multiplier;
          return new Dollar();
        }
      }
    • Et enfin on fait passer le test en changeant l’implémentation de times de la bonne manière.
      class Dollar {
        // ...
        times(multiplier: number) {
          return new Dollar(this.amount * multiplier);
        }
      }
  • Et voilà un item de plus fait sur notre todo list :
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5* 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
  • Ca fait deux techniques pour faire passer le test :
    • 1 - Utiliser d’abord des valeurs en dur, puis remplacer graduellement par des variables et du vrai code comme dans le chapitre 1.
    • 2 - Écrire directement l’implémentation évidente comme dans ce chapitre.
    • Kent utilise la 2 tant que tout va bien, et dès qu’il tombe sur des tests qui restent rouges, il repasse sur la 1 et reprend une approche plus incrémentale pour faire passer le test au vert. Puis il repasse à la 2 dès qu’il reprend confiance.
    • Il y a une 3ème technique exposée dans le chapitre d’après.

3 - Equality for All

  • Dollar est en fait un Value Object.
    • Ça a l’avantage qu’il ne posera pas de problèmes d’aliasing, c’est-à-dire qu’une instance quelque part n’affectera pas une autre instance ailleurs dans le code.
    • Il lui manque une méthode equals() parce que deux value objects qui ont les mêmes attributs sont censés être égaux, et une méthode hashCode() pour qu’il puisse être la clé d’une hashMap.
    • On ajoute ces éléments à notre todo list :
      • $5 + 10CHF = $10 si le taux est de 2:1
      • $5* 2 = $10
      • Mettre "amount" en privé
      • Quid des side-effects de Dollar ?
      • equals()
      • hashCode()
  • On va implémenter equals(), pour ça on commence par un test.
    it("equals to object with the same attributes", () => {
      expect(new Dollar(5).equals(new Dollar(5))).toBe(true);
    });
  • On fait ensuite une implémentation minimale.
    class Dollar {
      // ...
      equals(object: Dollar) {
        return true;
      }
    }
  • On va alors ajouter un deuxième cas dans le test, pour créer une triangulation.
    it("equals to object with the same attributes", () => {
      expect(new Dollar(5).equals(new Dollar(5))).toBe(true);
      expect(new Dollar(5).equals(new Dollar(6))).toBe(false);
    });
  • On va donc généraliser le code pour qu’il réponde aux deux exemples.
    class Dollar {
      // ...
      equals(object: Dollar) {
        return this.amount === object.amount;
      }
    }
  • On peut cocher equals :
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
  • La technique de la triangulation est à utiliser quand on n’arrive pas à trouver la solution, elle permet de réfléchir d’un autre point de vue.

4 - Privacy

  • Si on regarde notre premier test, on peut maintenant le refactorer en utilisant la méthode equals qu’on vient d’implémenter.
    • On va commencer par modifier la première assertion :
      it("multiplies money value with given value", () => {
        const five = new Dollar(5);
        let product = five.times(2);
        expect(product.equals(new Dollar(10))).toBe(true);
        product = five.times(3);
        expect(product.amount).toBe(15);
      });
    • Puis on peut modifier la 2ème assertion :
      it("multiplies money value with given value", () => {
        const five = new Dollar(5);
        let product = five.times(2);
        expect(product.equals(new Dollar(10))).toBe(true);
        product = five.times(3);
        expect(product.equals(new Dollar(15))).toBe(true);
      });
    • Et enfin on peut inliner la variable product qui n’est plus utile :
      it("multiplies money value with given value", () => {
        const five = new Dollar(5);
        expect(five.times(2).equals(new Dollar(10))).toBe(true);
        expect(five.times(3).equals(new Dollar(15))).toBe(true);
      });
  • L’attribut amount dans Dollar n’étant plus utilisé nulle part en dehors de sa classe, on peut le rendre privé.
    class Dollar {
      private amount: number;
      // ...
    }
  • On peut alors cocher l’élément dans la todo list :
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
  • On vient d’utiliser dans le test une fonctionnalité qu’on a développée dans le code, pour réduire le couplage entre le test et le code.
    • Cet usage nous expose au potentiel échec de notre test si la fonctionnalité equals se met à ne plus marcher.
    • On va le faire malgré ce risque, parce qu’on estime que l’avantage est plus important que l’inconvénient.

5 - Franc-ly Speaking

  • Le 1er item de la todo list est trop complexe pour l’implémenter d’un coup. On va donc ajouter un autre item qui consiste à faire la même chose que ce qu’on a fait avec des dollars, mais cette fois avec des francs suisses.

    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
    • 5 CHF * 2 = 10 CHF
  • On peut copier notre test de multiplication de dollars, et l’adapter pour les francs :

    it("multiplies francs value with given value", () => {
      const five = new Franc(5);
      expect(five.times(2).equals(new Franc(10))).toBe(true);
      expect(five.times(3).equals(new Franc(15))).toBe(true);
    });
  • Ensuite on va faire passer le test le plus vite possible. Pour ça, on duplique la classe Dollar :

    class Franc {
      constructor(private amount: number) {}
     
      times(multiplier: number) {
        return new Franc(this.amount * multiplier);
      }
     
      equals(object: Franc) {
        return this.amount === object.amount;
      }
    }
  • On peut cocher l’item de multiplication avec des francs, mais on doit ajouter d’autres problèmes à régler à propos de la duplication entre Franc et Dollar.

    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
    • 5 CHF * 2 = 10 CHF
    • Duplication entre Dollar et Franc
    • equals à mettre en commun
    • times à mettre en commun

6 - Equality for All, Redux

  • On va s’attaquer à la duplication entre les deux classes, et mettre en commun le equals. On joue les tests après chaque étape de refactoring.

  • On va créer une classe mère Money.

    class Money {}
  • On peut alors faire hériter Dollar de Money :

    class Dollar extends Money {
      // …
    }
  • On peut maintenant déplacer la variable amount vers Money.

    class Money {
      constructor(protected amount: number) {}
    }
  • On va passer à equals, en changeant le type de ce qu’il prend en paramètre.

    class Dollar extends Money {
      // …
      equals(object: Money) {
        return this.amount === object.amount;
      }
    }
  • On peut alors déplacer equals vers Money.

    class Money {
      // …
      equals(object: Money) {
        return this.amount === object.amount;
      }
    }
  • On peut maintenant s’occuper du equals dans Franc. Mais on se rend compte qu’il n’est pas couvert par des tests. On va alors ajouter ces tests avant de faire le refactoring.

    it("equals to object with the same attributes", () => {
      expect(new Dollar(5).equals(new Dollar(5))).toBe(true);
      expect(new Dollar(5).equals(new Dollar(6))).toBe(false);
      expect(new Franc(5).equals(new Franc(5))).toBe(true);
      expect(new Franc(5).equals(new Franc(6))).toBe(false);
    });
  • On peut faire hériter Franc de Money, supprimer la variable membre amount dans Franc.

    class Franc extends Money {
      // …
    }
  • On peut alors refactorer equals suffisamment pour qu’il soit le même que le parent, auquel cas on pourra le supprimer.

  • Voilà, on peut cocher la mise en commun d’equals.

    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
    • 5 CHF * 2 = 10 CHF
    • Duplication entre Dollar et Franc
    • equals à mettre en commun
    • times à mettre en commun
    • Comparer les Francs et les Dollars

7 - Apples and Oranges

  • On va traiter la comparaison entre les Francs et les Dollars. Pour ça on ajoute un cas dans le test d’égalité, disant que les Dollars ne sont pas égaux aux Francs.
    it("equals to object with the same attributes", () => {
      expect(new Dollar(5).equals(new Dollar(5))).toBe(true);
      expect(new Dollar(5).equals(new Dollar(6))).toBe(false);
      expect(new Franc(5).equals(new Franc(5))).toBe(true);
      expect(new Franc(5).equals(new Franc(6))).toBe(false);
      expect(new Franc(5).equals(new Dollar(5))).toBe(false);
    });
  • Le test échoue. On peut le faire passer en vérifiant la classe au moment de l’égalité.
    class Money {
      // …
      equals(object: Money) {
        return (
          this.amount === object.amount && this.constructor === object.constructor
        );
      }
    }
  • Il y a un code smell à utiliser un élément technique de TypeScript pour la vérification d’égalité, on aurait sans doute besoin du concept de devise, mais on met de côté cette idée pour l’instant en l’ajoutant à notre todo list.
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
    • 5 CHF * 2 = 10 CHF
    • Duplication entre Dollar et Franc
    • equals à mettre en commun
    • times à mettre en commun
    • Ajouter le concept devise
    • Comparer les Francs et les Dollars

8 - Makin’ Objects

  • On aimerait aller vers la mise en commun de times, pour aboutir à l’élimination des classes Dollar et Franc qui ne sont pas suffisamment différentes pour justifier leur existence, mais c’est un peu complexe.
  • On peut déjà typer la méthode equals dans les deux classes, pour dire qu’elle retourne un Money.
    class Dollar extends Money {
      // ...
      times(multiplier: number): Money {
        return new Dollar(this.amount * multiplier);
      }
    }
  • On peut ensuite faire en sorte de ne plus utiliser les classes Dollar et Franc dans les tests. Pour ça on va remplacer l’instanciation des classes par des méthodes factory sur la classe Money.
    it("multiplies dollars value with given value", () => {
      const five = new Money.dollar(5);
      expect(five.times(2).equals(Money.dollar(10))).toBe(true);
      expect(five.times(3).equals(Money.dollar(15))).toBe(true);
    });
  • Le compilateur indique que Money n’a pas de méthode times. On va l’ajouter en mode abstract, et en profiter pour faire de Money une classe abstract aussi.
    abstract class Money {
      // ...
      abstract times(multiplier: number): Money;
    }
  • Puis on crée la méthode factory.
    class Money {
      // ...
      static dollar(amount: number) {
        return new Dollar(amount);
      }
    }
  • On peut maintenant remplacer toutes les instanciations de Dollar dans les tests par la méthode factory.
  • On fait le même changement pour Franc, en changeant un test, ajoutant la méthode factory dans Money, puis enlevant toutes les instanciations dans les tests.
  • On remarque au passage que le test de multiplication du Franc sera redondant avec celui de Dollar dès que la méthode times sera mise en commun.
  • On l’ajoute à notre todo list :
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
    • 5 CHF * 2 = 10 CHF
    • Duplication entre Dollar et Franc
    • equals à mettre en commun
    • times à mettre en commun
    • Ajouter le concept devise
    • Comparer les Francs et les Dollars
    • Supprimer le test de multiplication du Franc

9 - Times We’re Livin’ In

  • On va s’attaquer au concept de devise, en commençant par un test.

    it("returns the currency", () => {
      expect(Money.dollar(1).currency()).toBe("USD");
      expect(Money.franc(1).currency()).toBe("CHF");
    });
  • On ajoute currency dans Money en tant que méthode abstraite.

    class Money {
      abstract currency(): string;
      // ...
    }
  • Puis on l’implémente dans les classes filles.

    class Dollar extends Money {
      // ...
      currency() {
        return "USD";
      }
    }
  • On peut refactorer pour avoir la devise dans une variable membre, sur Dollar et Franc.

    class Dollar extends Money {
      private _currency: string;
     
      constructor(protected amount: number) {
        this._currency = currency;
      }
      // ...
      currency() {
        return this._currency;
      }
    }
  • On peut alors faire remonter la déclaration de la variable _currency et de la méthode currency dans la classe parente.

    class Money {
      protected _currency: string;
      // ...
      currency() {
        return this._currency;
      }
    }
  • On remarque qu’on instancie les classes Franc et Dollar dans leurs méthodes times. On peut les remplacer par l’appel à la méthode factory du parent.

    • Il s’agit d’une digression, mais tant qu’elle est rapide c’est OK.
    • Jim Coplien a dit à Kent la règle qu’une digression ne doit pas être elle-même interrompue par une autre digression.
    class Dollar extends Money {
      // ...
      times(multiplier: number) {
        return Money.dollar(this.amount * multiplier);
      }
    }
  • On remarque que le constructeur des deux classes Dollar et Franc est presque identique, il n’y a que _currency qu’il faut extraire pour le déplacer dans Money. On va l’extraire dans la méthode factory.

    • D’abord on ajoute currency au constructeur de Dollar et Franc.
      class Dollar extends Money {
        // ...
        constructor(
          protected amount: number,
          currency: string
        ) {
          this._currency = "USD";
        }
      }
    • Puis on peut mettre la valeur de la currency en dur dans les méthodes factory.
      class Money {
        dollar(amount: number) {
          return new Dollar(amount, "USD");
        }
      }
    • Et enfin on peut assigner le paramètre currency du constructeur à la variable membre.
      class Dollar extends Money {
        // ...
        constructor(
          protected amount: number,
          protected _currency: string
        ) {}
      }
  • On va ensuite faire la même chose pour Franc, cette fois avec une grande étape plutôt que 3 petites, parce qu’on se sent à l’aise.

  • Les deux constructeurs sont identiques. On peut pousser l’implémentation dans la classe mère.

    class Money {
      // ...
      constructor(
        protected amount: number,
        protected _currency: string
      ) {}
    }
     
    class Dollar extends Money {
      // ...
      constructor(amount: number, currency: string) {
        super(amount, currency);
      }
    }
  • On peut cocher l'histoire des devises :

    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
    • 5 CHF * 2 = 10 CHF
    • Duplication entre Dollar et Franc
    • equals à mettre en commun
    • times à mettre en commun
    • Ajouter le concept devise
    • Comparer les Francs et les Dollars
    • Supprimer le test de multiplication du Franc

10 - Interesting Times

  • On va maintenant essayer de rendre les deux méthodes times identiques pour pouvoir les remonter dans la classe parente.
  • On va défaire ce qu’on a fait au chapitre précédent : on va inliner l’appel à la méthode factory dans les deux méthodes times.
    class Dollar extends Money {
      // ...
      times(multiplier: number) {
        return new Dollar(this.amount * multiplier, "USD");
      }
    }
  • On peut alors remplacer la valeur de la devise en dur par la variable membre _currency.
    class Dollar extends Money {
      // ...
      times(multiplier: number) {
        return new Dollar(this.amount * multiplier, this._currency);
      }
    }
  • On se demande maintenant si on peut remplacer l’instanciation de Dollar par celle de Money pour que les méthodes times soient identiques. Kent propose qu’au lieu d’y réfléchir, on essaye, et on laisse les tests nous dire si ça marche.
    • On fait le remplacement dans les méthodes times.
      class Dollar extends Money {
        // ...
        times(multiplier: number) {
          return new Money(this.amount * multiplier, this._currency);
        }
      }
    • Pour pouvoir compiler, il faut que la classe Money ne soit plus abstraite.
      class Money {
        // ...
        times(amount: number) {
          return null;
        }
      }
    • On obtient une erreur de l’un des tests, il semblerait que la méthode equals ne passe pas parce qu’elle vérifie que la classe est exactement la bonne en vérifiant l’égalité par rapport au constructeur de Franc et Dollar.
    • On pourrait écrire un nouveau test et modifier equals, mais on a déjà un test rouge, et il ne faut pas écrire de test sur un test rouge. On va donc revenir en arrière sur notre changement de Dollar par Money, le temps de corriger equals.
      class Dollar extends Money {
        // ...
        times(multiplier: number) {
          return new Dollar(this.amount * multiplier, this._currency);
        }
      }
  • On va ensuite ajouter un test pour vérifier l’égalité d’un Money et d’un Dollar avec la même valeur et la même devise.
    it("money and dollar with same parameters are equal", () => {
      expect(new Money(10, "USD").equals(new Dollar(10, "USD"))).toBe(true);
    });
  • Le test échoue, et on peut le faire passer.
    class Dollar extends Money {
      // ...
      equals(object: Money) {
        return (
          this.amount === object.amount && this.currency() === object.currency()
        );
      }
    }
  • On peut maintenant retourner à nouveau un Money dans les deux implémentations de times, et les tests passent.
    class Dollar extends Money {
      // ...
      times(multiplier: number) {
        return new Money(this.amount * multiplier, this._currency);
      }
    }
  • Les deux implémentations de times étant identiques, on peut le remonter.
    class Money {
      // ...
      times(multiplier: number) {
        return new Money(this.amount * multiplier, this._currency);
      }
    }
  • Et on peut cocher la méthode times à mettre en commun :
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
    • 5 CHF * 2 = 10 CHF
    • Duplication entre Dollar et Franc
    • equals à mettre en commun
    • times à mettre en commun
    • Ajouter le concept devise
    • Comparer les Francs et les Dollars
    • Supprimer le test de multiplication du Franc

11 - The Root of All Evil

  • On va supprimer l’utilisation des classes Dollar et Franc pour pouvoir les supprimer.
    • D’abord on remplace par Money dans les méthodes factory.
      class Money {
        // ...
        dollar(amount: number) {
          return new Money(amount, "USD");
        }
      }
    • On venait juste de faire un test pour vérifier l’égalité entre instances de Money et Dollar, ce test peut être supprimé.
    • On en profite pour revoir l’autre test qui porte sur l’égalité pour enlever des assertions qui ont l’air redondantes avec les autres.
      it("equals to object with the same attributes", () => {
        expect(Money.dollar(5).equals(Money.dollar(5))).toBe(true);
        expect(Money.dollar(5).equals(Money.dollar(6))).toBe(false);
        expect(Money.franc(5).equals(Money.dollar(5))).toBe(false);
      });
    • On remarque que maintenant que Franc et Dollar disparaissent, le test de multiplication utilisant des francs n’est plus vraiment nécessaire, celui qui teste en utilisant des dollars est suffisant . On le supprime.
  • On peut alors supprimer effectivement les classes Dollar et Franc qui ne sont plus utilisées nulle part.
  • Notre todo list :
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 2 = $10
    • Mettre "amount" en privé
    • Quid des side-effects de Dollar ?
    • equals()
    • hashCode()
    • 5 CHF * 2 = 10 CHF
    • Duplication entre Dollar et Franc
    • equals à mettre en commun
    • times à mettre en commun
    • Ajouter le concept devise
    • Comparer les Francs et les Dollars
    • Supprimer le test de multiplication du Franc

12 - Addition, Finally

  • On va réécrire notre todo list, en enlevant ce qui n’est pas pertinent :
    • $5 + 10CHF = $10 si le taux est de 2:1
  • L’addition avec des devises différentes est trop complexe pour être implémentée d’un coup, on va donc ajouter un cas plus simple d’addition.
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 + $5 = $10
  • On va ajouter un test pour ce cas simple.
    it("adds two money values in the resulting one", () => {
      const sum = Money.dollar(5).plus(Money.dollar(5));
      expect(sum.equals(Money.dollar(10))).toBe(true);
    });
  • Et on va écrire le code du premier coup.
    class Money {
      // ...
      plus(addend: Money) {
        return new Money(this.amount + addend.amount, this._currency);
      }
    }
  • On se pose un peu et on réfléchit à notre test : on aimerait que l’essentiel du code ne soit pas au courant qu’il y a plusieurs devises.
    • On pourrait convertir immédiatement une valeur monétaire en une monnaie de référence, mais ça ne permet pas de faire varier facilement les taux de change.
    • On peut créer un objet imposteur, qui va avoir la même interface, mais se comporter différemment. Il va s’agir d’une expression, pouvant prendre la forme la plus simple qui est la valeur monétaire, mais aussi des formes plus complexes comme une somme de plusieurs valeurs monétaires.
      • Il s’agit ici d’une idée de design qui nous est venue.
      • Le TDD ne garantit pas qu’on en aura, mais il permet de faire en sorte que le code soit dans un état qui permet facilement d’appliquer les idées de design qui nous viennent.
      • Et il nous fait réfléchir au design en réfléchissant d’abord à la manière d’utiliser notre code.
  • On va donc réécrire notre test, on a déjà l’assertion finale en tête.
    it("adds two money values in the resulting one", () => {
      // ...
      expect(Money.dollar(10).equals(reduced)).toBe(true);
    });
  • On va introduire un élément qui fait la réduction vers la valeur monétaire à partir de la somme obtenue. Ça peut être une banque.
    it("adds two money values in the resulting one", () => {
      // ...
      const reduced = bank.reduce(sum, "USD");
      expect(Money.dollar(10).equals(reduced)).toBe(true);
    });
    • On aurait pu tout autant choisir de mettre la logique de réduction dans l’objet sum lui-même, mais on fait ce choix dans un premier temps parce que :
      • 1 - Les expressions semblent être un élément central, et donc on préfère les laisser simples et indépendants, pour qu’ils soient facilement testables et réutilisables.
      • 2 - Il risque d’y avoir beaucoup d’opérations du même genre, et les mettre dans le concept d’expression risque de le faire trop grossir.
  • On peut alors introduire la banque, la somme qui est une expression, et la monnaie initiale.
    it("adds two money values in the resulting one", () => {
      const five = Money.dollar(5);
      const sum = five.plus(five);
      const bank = new Bank();
      const reduced = bank.reduce(sum, "USD");
      expect(Money.dollar(10).equals(reduced)).toBe(true);
    });
  • On va maintenant faire l’implémentation.
    • D’abord on fait compiler :
      interface Expression {}
      class Money implements Expression {
        // ...
        plus(addend: number): Expression {
          return new Money(this.amount + addend.amount, this._currency);
        }
      }
      class Bank {
        reduce(source: Expression, to: string) {
          return null;
        }
      }
    • Ensuite on peut faire une fausse implémentation pour faire passer le test.
      class Bank {
        reduce(source: Expression, to: string) {
          return Money.dollar(10);
        }
      }

13 - Make It

  • Notre addition a une valeur en dur pour l’implémentation. Il faut enlever la duplication avec la valeur 10 dans les tests.

  • Ici Kent trouve que le cas est plus difficile que quand il s’agissait de juste ajouter une variable au lieu de la valeur en dur, et donc on va plutôt avancer en ajoutant un point plus précis dans notre todo list, pour obtenir un objet Money à partir de la somme.

    • Et on ne coche pas l’addition dans notre todo list parce qu’il reste de la duplication et qu’on va l’enlever.
      • $5 + 10CHF = $10 si le taux est de 2:1
      • $5 + $5 = $10
      • Retourner Money à partir de $5 + $5
  • On va écrire un test plus précis pour nous guider sur ce qu’est la somme. Ce test sera probablement supprimé plus tard parce qu’il va trop dans le bas niveau, mais là il nous aide à avancer.

    it("returns a sum when using plus", () => {
      const five = Money.dollar(5);
      const sum = five.plus(five);
      expect(sum.augend.equals(five)).toBe(true);
      expect(sum.addend.equals(five)).toBe(true);
    });
  • Puis on crée le code pour faire passer le test au vert.

    class Sum implements Expression {
      constructor(
        public augend: Money,
        public addend: Money
      ) {}
    }
     
    class Money implements Expression {
      // ...
      plus(addend: Money): Expression {
        return new Sum(this, addend);
      }
    }
  • Puis on écrit un autre test précis sur le cas de la réduction de l’objet sum.

    it("reduces a sum of dollars to a dollar", () => {
      const sum = new Sum(Money.dollar(3), Money.dollar(4));
      const bank = new Bank();
      const result = bank.reduce(sum, "USD");
      expect(Money.dollar(7).equals(result)).toBe(true);
    });
  • Puis on écrit l’implémentation de Bank.reduce pour passer le test :

    class Bank {
      reduce(source: Expression, to: string) {
        const sum = source;
        const amount = sum.augend.amount + sum.addend.amount;
        return new Money(amount, to);
      }
    }
    • On passe le test, mais on aimerait que la méthode soit utilisable avec n’importe quelle Expression et pas juste les Sum. Et on aimerait que les champs publics amount soient encapsulés.
  • On va donc déplacer une partie du code dans la classe Sum pour que la responsabilité de l’addition soit au bon endroit.

    class Bank {
      reduce(source: Expression, to: string) {
        const sum = source;
        return sum.reduce(to);
      }
    }
     
    class Sum implements Expression {
      // ...
      reduce(to: string) {
        const amount = this.augend.amount + this.addend.amount;
        return new Money(amount, to);
      }
    }
  • Il nous vient aussi à l’idée que Bank.reduce doit marcher quand il s’agit de traiter une expression qui est un objet Money. On l’ajoute à la todo list.

    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 + $5 = $10
    • Retourner Money à partir de $5 + $5
    • Bank.reduce(Money)
  • Et on va écrire un test pour ça.

    it("reduces a dollar to a dollar", () => {
      const bank = new Bank();
      const result = bank.reduce(Money.dollar(1), "USD");
      expect(Money.dollar(1).equals(result)).toBe(true);
    });
  • Puis on fait passer le test rapidement.

    class Bank {
      reduce(source: Expression, to: string) {
        if (source instanceof Money) return source;
        const sum = source;
        return sum.reduce(to);
      }
    }
  • Le test passe, mais c’est très moche. On voudrait en réalité que ça marche quelle que soit la classe, et idéalement avec une interface uniforme. On va donc utiliser le polymorphisme.

  • La première étape est d’ajouter reduce() sur Money aussi.

    class Bank {
      reduce(source: Expression, to: string) {
        if (source instanceof Money) return source.reduce(to);
        const sum = source;
        return sum.reduce(to);
      }
    }
     
    class Money implements Expression {
      // ...
      reduce(to: string) {
        return this;
      }
    }
  • On peut alors ajouter reduce() à Expression, et rendre Bank.reduce plus agréable.

    interface Expression {
      reduce(to: string);
    }
     
    class Bank {
      reduce(source: Expression, to: string) {
        return source.reduce(to);
      }
    }
  • On peut cocher Bank.reduce dans notre todo list, et en même temps il nous vient en tête la possibilité d’utiliser Bank.reduce avec une conversion de devise, et la possibilité de donner l’objet bank à reduce.

    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 + $5 = $10
    • Retourner Money à partir de $5 + $5
    • Bank.reduce(Money)
    • Reduce avec une conversion
    • Reduce(Bank, string)

14 - Change

  • On va s’intéresser au cas de la réduction avec conversion.

    it("reduces money from different currencies", () => {
      const bank = new Bank();
      bank.addRate("CHF", "USD", 2);
      const result = bank.reduce(Money.franc(2), "USD");
      expect(Money.dollar(1).equals(result)).toBe(true);
    });
  • On fait passer le test rapidement.

    class Money implements Expression {
      // ...
      reduce(to: string) {
        const rate = this.currency === "CHF" && to === "USD" ? 2 : 1;
        return new Money(this.amount / rate, to);
      }
    }
  • On préférerait que la logique concernant les taux ne soit que dans la banque et pas dans les expressions. On va donc passer la banque en paramètre à Expression.reduce(), comme on l’avait déjà pressenti en le mettant dans notre todo list.

    interface Expression {
      reduce(bank: Bank, to: string);
    }
     
    class Sum implements Expression {
      // ...
      reduce(bank: Bank, to: string) {
        const amount = this.augend.amount + this.addend.amount;
        return new Money(amount, to);
      }
    }
     
    class Money implements Expression {
      // ...
      reduce(bank: Bank, to: string) {
        const rate = this.currency === "CHF" && to === "USD" ? 2 : 1;
        return new Money(this.amount / rate, to);
      }
    }
  • On peut maintenant déplacer la logique concernant le taux dans la banque.

    class Bank {
      // ...
      rate(from: string, to: string) {
        return from === "CHF" && to === "USD" ? 2 : 1;
      }
    }
     
    class Money implements Expression {
      // ...
      reduce(bank: Bank, to: string) {
        const rate = bank.rate(this.currency, to);
        return new Money(this.amount / rate, to);
      }
    }
  • Il reste encore de la duplication entre le test et le code du taux dans la banque qu’il faut généraliser. On va créer un objet Pair pour porter les deux devises, et l’utiliser comme clé dans une hashmap, avec les taux comme valeur.

    • L’objet en question a besoin d’être un value object et donc avoir equals et hashCode., mais on n’a pas besoin de le tester directement parce qu’il est déjà testé via la banque.
    • Pour le hashCode on va mettre une valeur en dur pour l’instant.
    class Pair {
      constructor(private from: string, private to: string) {}
     
      equals(other: Pair) {
        return this.from === other.from && this.to === other.to;
      }
     
      hashCode() {
        return 0;
      }
    }
     
    class Bank {
      constructor(private rates: Map<number, number>) {}
     
      addRate(from: string, to: string, rate: number) {
        rates.set(new Pair(from, to).hashCode(), rate);
      }
     
      rate(from: string, to: string) {
        return this.rates.get(new Pair(from, to).hashCode());
      }
    }
  • On tombe sur un test rouge, et en fait il se trouve que le cas où on demande le taux entre deux mêmes devises n’est pas par défaut traité comme un taux de conversion de 1.

    • On va donc écrire un test pour expliciter ce cas-là, et faire repasser les tests au vert.
    it("uses a rate of 1 for the same currencies", () => {
      const bank = new Bank();
      expect(bank.rate("USD", "USD")).toBe(1);
    });
     
    class Bank {
      // ...
      rate(from: string, to: string) {
        if (from === to) return 1;
        return this.rates.get(new Pair(from, to).hashCode());
      }
    }
  • On a donc réglé le cas du reduce avec conversion, le fait que reduce prenne la banque en paramètre, et plus généralement le cas de l’addition avec la même devise.

    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 + $5 = $10
    • Retourner Money à partir de $5 + $5
    • Bank.reduce(Money)
    • Reduce avec une conversion
    • Reduce(Bank, string)

15 - Mixed Currencies

  • On va traiter la fameuse addition multi-devise. On écrit d’abord un test.

    it("adds two expressions with different currencies", () => {
      const fiveBucks = Money.dollar(5);
      const tenFrancs = Money.francs(10);
      const bank = new Bank();
      bank.addRate("CHF", "USD", 2);
      const result = bank.reduce(fiveBucks.plus(tenFrancs), "USD");
      expect(result.equals(Money.dollar(10))).toBe(true);
    });
  • On obtient quelques erreurs de typage, et le test échoue, on obtient 15 au lieu de 10. On va corriger le code de Sum et appliquer le reduce sur les deux éléments de l’addition.

    class Sum implements Expression {
      // ...
      reduce(bank: Bank, to: string) {
        const amount =
          this.augend.reduce(bank, to).amount +
          this.addend.reduce(bank, to).amount;
        return new Money(amount, to);
      }
    }
  • On va généraliser les objets de type Money, en acceptant dans la plupart des cas des Expression.

    class Sum implements Expression {
      constructor(
        public augend: Expression,
        public addend: Expression
      ) {}
      // ...
    }
     
    class Money implements Expression {
      // ...
      plus(addend: Expression) {
        return new Sum(this, addend);
      }
     
      times(multiplier: number): Expression {
        return new Money(this.amount * multiplier, this.currency);
      }
    }
  • On va devoir ajouter plus() et times() dans Expression. On commence par plus() qui est nécessaire pour le test qu’on a écrit.

    interface Expression {
      // ...
      plus(addend: Expression): Expression;
    }
  • On doit alors avoir le plus dans tous les objets qui implémentent l’interface, y compris dans Sum, dans lequel on peut pour l’instant mettre une fausse implémentation.

    class Sum implements Expression {
      // ...
      plus(addend: Expression) {
        return null;
      }
    }
  • On a donc notre addition avec différentes devises qui marche, et on ajoute dans la todo list l’implémentation de Sum.plus, et le fait de mettre times dans Expression aussi.

    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 + $5 = $10
    • Retourner Money à partir de $5 + $5
    • Bank.reduce(Money)
    • Reduce avec une conversion
    • Reduce(Bank, string)
    • Implémentation de Sum.plus
    • Mettre times dans Expression

16 - Abstraction, Finally

  • On va traiter l’implémentation de Sum.plus.
    it("adds a sum and a money", () => {
      const fiveBucks = Money.dollar(5);
      const tenFrancs = Money.francs(10);
      const bank = new Bank();
      bank.addRate("CHF", "USD", 2);
      const sum = new Sum(fiveBucks, tenFrancs).plus(fiveBucks);
      const result = bank.reduce(sum, "USD");
      expect(result.equals(Money.dollar(15))).toBe(true);
    });
  • Et ensuite le code.
    class Sum implements Expression {
      // ...
      plus(addend: Expression) {
        return new Sum(this, addend);
      }
    }
  • Pour Kent Beck, le TDD va en moyenne amener à produire autant de code de test que de code de production.
  • On passe maintenant à times qu’on veut remonter dans l’interface Expression. Il s’agit d'abord de le faire marcher sur Sum.
    it("multiplies a sum with a value", () => {
      const fiveBucks = Money.dollar(5);
      const tenFrancs = Money.francs(10);
      const bank = new Bank();
      bank.addRate("CHF", "USD", 2);
      const sum = new Sum(fiveBucks, tenFrancs).times(2);
      const result = bank.reduce(sum, "USD");
      expect(result.equals(Money.dollar(20))).toBe(true);
    });
  • Et le code.
    class Sum implements Expression {
      // ...
      times(multiplier: number) {
        return new Sum(
          this.augend.times(multiplier),
          this.addend.times(multiplier)
        );
      }
    }
  • Et on peut ajouter times à Expression.
    interface Expression {
      // ...
      times(multiplier: number): Expression;
    }
  • On peut donc cocher nos deux derniers items.
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 + $5 = $10
    • Retourner Money à partir de $5 + $5
    • Bank.reduce(Money)
    • Reduce avec une conversion
    • Reduce(Bank, string)
    • Implémentation de Sum.plus
    • Mettre times dans Expression
  • Il reste un élément dans notre todo list : le fait de retourner un objet Money à partir d’une addition dans l'objet Money. On va d’abord écrire le test, qui n’est pas tip top vu qu’il doit tester l’instance de classe renvoyée.
    it("returns a money from the plus operation", () => {
      const sum = Money.dollar(1).plus(Money.dollar(1));
      expect(sum instanceof Money).toBe(true);
    });
  • En regardant le code, on voit mal comment on ferait ça de manière à peu près propre.
    class Money implements Expression {
      // ...
      plus(addend: Expression) {
        return new Sum(this, addend);
      }
    }
  • On décide d’abandonner ce point et de supprimer ce test.
    • $5 + 10CHF = $10 si le taux est de 2:1
    • $5 + $5 = $10
    • Retourner Money à partir de $5 + $5
    • Bank.reduce(Money)
    • Reduce avec une conversion
    • Reduce(Bank, string)
    • Implémentation de Sum.plus
    • Mettre times dans Expression

17 - Money Retrospective

  • Pour aller plus loin, on pourrait transformer Expression en classe, pour porter la logique commune entre classes filles, comme par exemple la méthode plus.
  • Kent considère que le code n’est jamais vraiment fini. Il conseille de manière générale de faire en sorte que le code qu’on touche souvent soit super solide, à la fois d’un point de vue du code et des tests, alors que le code qui est plus périphérique peut être un peu plus négligé.
    • L’important c’est d’être en permanence dans une situation où on a confiance dans le code qu’on maintient.
  • Quand on a traité sa todo list, c’est le bon moment pour prendre un peu de recul et se demander si le design est cohérent, et s’il y a de la duplication de fond qui persiste.
  • Kent est lui-même surpris par le fait que le choix des mots et les métaphores qu’on prend pour désigner les objets influencent grandement la manière dont on voit le problème, et donc le design du code qui en résulte.
  • Quelques stats sur le Money example :
    • Pour coder cet exemple, Kent a joué les tests 125 fois, avec une minute entre les jeux en moyenne.
    • On se retrouve bien avec environ autant de code de test que de code de production, même si ici on pourrait factoriser le code des tests.
    • En moyenne, il a fallu changer un à deux endroits (faire passer le test, enlever la duplication) pour répondre à un test qui a été écrit.
  • Quand les développeurs adoptent une technique comme le TDD, le rôle des testeurs se rapproche de facilitateur entre les personnes qui ont les idées et ceux qui développent le code.
  • Parmi les métriques qu’on peut surveiller à propos des tests, il y a le coverage, et le defect insertion (connu plus récemment comme mutation testing). Le TDD devrait amener à avoir un score très élevé sur les deux.
  • Parmi les choses notables dans cette partie, on peut retenir :
    • Les 3 techniques pour faire passer un test rapidement : la valeur en dur, la triangulation et l’implémentation évidente.
    • Le fait qu’enlever la duplication entre le code et le test aide à faire avancer le design.
    • Le fait de pouvoir passer à du plus bas niveau sur les tests quand on est face à une difficulté, et à l’inverse passer sur du plus haut niveau quand ça devient plus facile.

II - The xUnit Example

18 - First Steps to xUnit

  • Il s’agit d’implémenter un framework de test, donc on part du principe qu’on n’en a pas. C’est un exemple un peu plus compliqué que le premier.

  • En réfléchissant un peu aux premières fonctionnalités, on a cette todo list en tête.

    • Exécuter la méthode à tester
    • Exécuter setUp en premier
    • Exécuter tearDown à la fin
    • Exécuter tearDown même si la méthode à tester échoue
    • Exécuter plusieurs tests
    • Montrer les résultats
  • Pour vérifier qu’on exécute la méthode à tester, on peut choisir une méthode qui ne fait que mettre une variable à true. Le framework va initialiser la variable à false au début, va exécuter la méthode, et vérifier qu’elle est passée à true.

    • Puisqu’on n’a pas de framework de test, on écrit notre test directement dans un module qu’on va exécuter à la main, et on observe le résultat dans la sortie standard.
      const test = WasRun("testMethod");
      console.log(test.wasRun);
      test.testMethod();
      console.log(test.wasRun);
  • On va écrire du code pour faire passer le test. D’abord la classe de test qui contient aussi la méthode à tester.

    class WasRun {
      public wasRun: boolean;
      constructor() {
        this.wasRun = false;
      }
     
      testMethod() {}
    }
  • On n’a plus d’erreurs à l’exécution, mais on obtient deux fois false. Il faut le corps de la méthode testée.

    class WasRun {
      // ...
      testMethod() {
        this.wasRun = true;
      }
    }
  • Maintenant qu’on a notre test au “vert”, on va faire des refactorings.

    • On va commencer par appeler une méthode plus générique dans le test pour lancer l’exécution du test, et utiliser le nom de la méthode donnée dans le constructeur de WasRun pour la méthode qui sera appelée.
      const test = WasRun("testMethod");
      console.log(test.wasRun);
      test.run();
      console.log(test.wasRun);
  • Côté implémentation, il faut créer la méthode run() pour faire passer rapidement le test au vert.

    class WasRun {
      // ...
      run() {
        this.testMethod();
      }
    }
  • On peut ensuite appeler la méthode à tester dynamiquement, depuis le nom donné au constructeur.

    class WasRun {
      public wasRun: boolean;
      constructor(private name: string) {
        this.wasRun = false;
      }
     
      run() {
        const method = (this as any)[this.name];
        method && method();
      }
    }
  • Comme notre classe WasRun fait maintenant deux choses (savoir si la méthode a été appelée, et appeler la méthode dynamiquement), on peut créer une classe mère pour porter la 2ème fonctionnalité.

    • D’abord on la crée et on lui donne la variable name.

      class TestCase {
        constructor(private name: string) {}
      }
       
      class WasRun extends TestCase {
        public wasRun: boolean;
        constructor(name: string) {
          super(name);
          this.wasRun = false;
        }
       
        run() {
          const method = (this as any)[this.name];
          method && method();
        }
        // ...
      }
    • Puis on y déplace la méthode run().

      class TestCase {
        constructor(private name: string) {}
       
        run() {
          const method = (this as any)[this.name];
          method && method();
        }
      }
       
      class WasRun extends TestCase {
        public wasRun: boolean;
        constructor(name: string) {
          super(name);
          this.wasRun = false;
        }
        // ...
      }
  • On peut maintenant utiliser notre classe TestCase dans le test lui-même, et en profiter pour remplacer les affichages par des assertions.

    class TestCaseTest extends TestCase {
      testRunning() {
        const test = WasRun("testMethod");
        assert.strictEqual(test.wasRun, false);
        test.run();
        assert.strictEqual(test.wasRun, true);
      }
    }
     
    new TestCaseTest("testRunning").run();
  • On a donc complété la 1ère étape de notre todo list.

    • Exécuter la méthode à tester
    • Exécuter setUp en premier
    • Exécuter tearDown à la fin
    • Exécuter tearDown même si la méthode à tester échoue
    • Exécuter plusieurs tests
    • Montrer les résultats

19 - Set the Table

  • Il arrive souvent que la partie arrange des tests se répète, pour autant, Kent déconseille de ne la jouer qu’une fois pour gagner en performance, parce que le couplage entre les tests est très problématique.

  • On va créer un test pour notre fonctionnalité suivante : la méthode setUp.

    class TestCaseTest extends TestCase {
      // ...
      testSetUp() {
        const test = WasRun("testMethod");
        test.run();
        assert.strictEqual(test.wasSetUp, true);
      }
    }
     
    new TestCaseTest("testSetUp").run();
  • Pour faire passer le test, il nous faut ajouter la méthode setUp dans WasRun, et l’appeler dans le parent TestCase qui est le code de production (le code du framework de test).

    class WasRun extends TestCase {
      public wasRun: boolean;
      public wasSetUp: boolean;
     
      constructor(name: string) {
        super(name);
        this.wasRun = false;
        this.wasSetUp = false;
      }
     
      setUp() {
        this.wasSetUp = true;
      }
    }
     
    class TestCase {
      constructor(private name: string) {}
     
      run() {
        this.setUp();
        const method = (this as any)[this.name];
        method && method();
      }
     
      setUp() {}
    }
  • On peut maintenant faire des refactorings.

    • On commence par déplacer l’assignation initiale de la variable wasRun dans la méthode setUp dans WasRun.

      class WasRun extends TestCase {
        // ...
       
        constructor(name: string) {
          super(name);
          this.wasSetUp = false;
        }
       
        setUp() {
          this.wasRun = false;
          this.wasSetUp = true;
        }
      }
    • Vu que la méthode setUp est testée, on peut maintenant se permettre de ne plus tester qu’à l’instanciation de WasRun, la variable wasRun est false.

      class TestCaseTest extends TestCase {
        testRunning() {
          const test = WasRun("testMethod");
          test.run();
          assert.strictEqual(test.wasRun, true);
        }
        // ...
      }
    • On va ensuite pouvoir surcharger la méthode setUp sur notre classe de test TestCaseTest, pour soulager chaque méthode de test de la phase arrange. On sait que ce setUp sera appelé au bon moment puisque c’est maintenant une fonctionnalité testée de notre framework de test.

      class TestCaseTest extends TestCase {
        private test: WasRun | null;
        constructor() {
          this.test = null;
        }
       
        setUp() {
          this.test = new WasRun("testMethod");
        }
       
        testRunning() {
          this.test.run();
          assert.strictEqual(test.wasRun, true);
        }
       
        testSetUp() {
          this.test.run();
          assert.strictEqual(test.wasSetUp, true);
        }
      }
  • On a donc terminé l’implémentation de la fonctionnalité setUp.

    • Exécuter la méthode à tester
    • Exécuter setUp en premier
    • Exécuter tearDown à la fin
    • Exécuter tearDown même si la méthode à tester échoue
    • Exécuter plusieurs tests
    • Montrer les résultats

20 - Cleaning Up After

  • On réfléchit à la fonctionnalité tearDown, et il nous vient l’idée de vouloir tester correctement l’ordre d’exécution entre setUp, la méthode testée, et tearDown, on se dit qu’on peut le faire avec un log plutôt que des flags.

    • Exécuter la méthode à tester
    • Exécuter setUp en premier
    • Exécuter tearDown à la fin
    • Exécuter tearDown même si la méthode à tester échoue
    • Exécuter plusieurs tests
    • Montrer les résultats
    • Logger un string dans WasRun pour vérifier l'ordre d'exécution
  • On va refactorer WasRun pour qu’il utilise un log en interne.

    class WasRun extends TestCase {
      // ...
      setUp() {
        this.wasRun = false;
        this.wasSetUp = true;
        this.log = "setUp ";
      }
    }
  • Puis on peut changer le test de setUp pour se baser sur le log.

    class TestCaseTest extends TestCase {
      // ...
      testSetUp() {
        this.test.run();
        assert.strictEqual(this.test.log, "setUp ");
      }
    }
  • On peut alors supprimer le flag wasSetUp sur WasRun, et ajouter le log dans la méthode de test.

    class WasRun extends TestCase {
      // ...
      testMethod() {
        this.wasRun = false;
        this.log = `${this.log}testMethod `;
      }
    }
  • On peut alors mettre à jour le contenu du test de setUp qui joue l’ensemble du code et donc contient l’ensemble du log.

    class TestCaseTest extends TestCase {
      // ...
      testSetUp() {
        this.test.run();
        assert.strictEqual(this.test.log, "setUp testMethod");
      }
    }
    • On va en profiter pour supprimer le test de la méthode principale qui fait maintenant doublon, et renommer celui de setUp en testTemplateMethod.
      class TestCaseTest extends TestCase {
        // ...
        testTemplateMethod() {
          this.test.run();
          assert.strictEqual(this.test.log, "setUp testMethod");
        }
      }
    • Vu que la méthode setUp dans TestCaseTest n’est plus utile qu’à un seul test, on peut rapatrier son contenu dans le test (on défait le refactoring qu’on avait fait).
      class TestCaseTest extends TestCase {
        // ...
        testTemplateMethod() {
          const test = WasRun("testMethod");
          test.run();
          assert.strictEqual(test.log, "setUp testMethod");
        }
      }
  • On a donc terminé l’ajout de log dans WasRun pour vérifier l’ordre d’exécution.

    • Exécuter la méthode à tester
    • Exécuter setUp en premier
    • Exécuter tearDown à la fin
    • Exécuter tearDown même si la méthode à tester échoue
    • Exécuter plusieurs tests
    • Montrer les résultats
    • Logger un string dans WasRun pour vérifier l'ordre d'exécution
  • On peut maintenant ajouter le cas de tearDown dans le seul test qui teste déjà l’ordre d’exécution des deux autres méthodes.

    class TestCaseTest extends TestCase {
      // ...
      testTemplateMethod() {
        const test = WasRun("testMethod");
        test.run();
        assert.strictEqual(test.log, "setUp testMethod tearDown ");
      }
    }
  • Et on peut modifier le code directement pour faire passer le test.

    class TestCase {
      // ...
      run() {
        this.setUp();
        const method = (this as any)[this.name];
        method && method();
        this.tearDown();
      }
     
      setUp() {}
     
      tearDown() {}
    }
     
    class WasRun extends TestCase {
      // ...
      setUp {
        this.log = "setUp ";
      }
     
      testMethod() {
        this.wasRun = false;
        this.log = `${this.log}testMethod `;
      }
     
      tearDown {
        this.log = `${this.log}tearDown `;
      }
    }
  • On a donc implémenté tearDown.

    • Exécuter la méthode à tester
    • Exécuter setUp en premier
    • Exécuter tearDown à la fin
    • Exécuter tearDown même si la méthode à tester échoue
    • Exécuter plusieurs tests
    • Montrer les résultats
    • Logger un string dans WasRun pour vérifier l'ordre d'exécution

21 - Counting

  • On veut que notre framework montre le nombre de tests qui ont été joués, et le nombre d’échecs, pour qu’on puisse aussi se rendre compte si des tests arrêtent d’être joués.

  • On va écrire un test pour matérialiser le comportement qu’on veut.

    class TestCaseTest extends TestCase {
      // ...
      testResult() {
        const test = WasRun("testMethod");
        const result = test.run();
        assert.strictEqual(result.summary(), "1 run, 0 failed");
      }
    }
  • On fait passer le test rapidement par une implémentation en dur.

    class TestResult {
      summary() {
        return "1 run, 0 failed";
      }
    }
     
    class TestCase {
      // ...
      run() {
        this.setUp();
        const method = (this as any)[this.name];
        method && method();
        this.tearDown();
        return new TestResult();
      }
    }
  • On peut maintenant enlever la duplication petit à petit. D’abord pour le nombre de tests joués qu’on met dans une variable.

    class TestResult {
      private runCount: number;
     
      constructor() {
        this.runCount = 0;
      }
     
      summary() {
        return `${this.runCount} run, 0 failed`;
      }
    }
  • On peut ensuite faire s’incrémenter la nouvelle variable avec une méthode appelée à chaque test exécuté.

    class TestResult {
      // ...
      testStarted() {
        this.runCount++;
      }
    }
     
    class TestCase {
      // ...
      run() {
        const result = new TestResult();
        result.testStarted();
        this.setUp();
        const method = (this as any)[this.name];
        method && method();
        this.tearDown();
        return result;
      }
    }
  • Notre test teste seulement le cas de test réussi et pas le cas de test échoué. Donc on va écrire un autre test pour ça avant de transformer le “0 failed” en variable aussi.

    class TestCaseTest extends TestCase {
      // ...
      testFailedResult() {
        const test = WasRun("testBrokenMethod");
        const result = test.run();
        assert.strictEqual(result.summary(), "1 run, 1 failed");
      }
    }
     
    class WasRun extends TestCase {
      // ...
      testBrokenMethod() {
        throw new Error("");
      }
    }
  • On a donc terminé l’affichage de résultats, mais on ajoute un élément supplémentaire dans notre todo list à propos de l’affichage de tests échoués, qui est matérialisé pour notre test rouge.

    • Exécuter la méthode à tester
    • Exécuter setUp en premier
    • Exécuter tearDown à la fin
    • Exécuter tearDown même si la méthode à tester échoue
    • Exécuter plusieurs tests
    • Montrer les résultats
    • Logger un string dans WasRun pour vérifier l'ordre d'exécution
    • Montrer les tests échoués

22 - Dealing with Failure

  • Vu qu’on a un peu de mal, on va partir sur un test de plus bas niveau pour guider l’implémentation des résultats de tests échoués. Le test va directement instancier TestResult et utiliser son interface.

    class TestCaseTest extends TestCase {
      // ...
      testFailedResultFormatting() {
        const result = TestResult();
        result.testStarted();
        result.testFailed();
        assert.strictEqual(result.summary(), "1 run, 1 failed");
      }
    }
  • Côté implémentation, on peut ajouter testFailed() et le compteur associé.

    class TestResult {
      private runCount: number;
      private errorCount: number;
     
      constructor() {
        this.runCount = 0;
        this.errorCOunt = 0;
      }
      // ...
      testFailed() {
        this.errorCount++;
      }
     
      summary() {
        return `${this.runCount} run, ${this.errorCount} failed`;
      }
    }
  • Et on doit appeler la méthode dans TestCase dans le cas où une exception arrive à l’exécution.

    class TestCase {
      // ...
      run() {
        const result = new TestResult();
        result.testStarted();
        this.setUp();
        try {
          const method = (this as any)[this.name];
          method && method();
        }
        catch() {
          result.testFailed();
        }
        this.tearDown();
        return result;
      }
    }
  • On a bien le nombre d’erreurs qui sont montrées, mais il nous vient aussi l’idée que les erreurs dans le setUp devraient aussi être gérées et affichées (Kent ne le montrera pas).

    • Exécuter la méthode à tester
    • Exécuter setUp en premier
    • Exécuter tearDown à la fin
    • Exécuter tearDown même si la méthode à tester échoue
    • Exécuter plusieurs tests
    • Montrer les résultats
    • Logger un string dans WasRun pour vérifier l'ordre d'exécution
    • Montrer les tests échoués
    • Gérer et montrer les erreurs au setUp

23 - How Suite It Is

  • On va traiter le cas de l’exécution de plusieurs tests par un même objet, de manière à obtenir l’affichage des résultats des tests cumulés.

  • On va créer un test pour ça.

    class TestCaseTest extends TestCase {
      // ...
      testSuite() {
        const suite = TestSuite();
        suite.add(new WasRun("testMethod"));
        suite.add(new WasRun("testBrokenMethod"));
        const result = suite.run();
        assert.strictEqual(result.summary(), "2 run, 1 failed");
      }
    }
  • On va utiliser le pattern Composite, qui implique de traiter un ensemble d’opérations comme une opération qui existe déjà. La classe TestSuite va se comporter comme la classe TestCase mais avec plusieurs tests joués.

    class TestSuite {
      private tests: TestCase[];
     
      constructor() {
        this.tests = [];
      }
     
      add(test: TestCase) {
        this.tests.push(test);
      }
     
      run() {
        const result = new TestResult();
        for (test of this.tests) {
          test.run(result);
        }
        return result;
      }
    }
  • Pour respecter le pattern Composite, il faut que TestSuite.run() soit appelé exactement comme TestCase.run(), et donc il va falloir que TestCase.run prenne result en paramètre.

    class TestCaseTest extends TestCase {
      // ...
      testSuite() {
        const suite = TestSuite();
        suite.add(new WasRun("testMethod"));
        suite.add(new WasRun("testBrokenMethod"));
        const result = TestResult();
        suite.run(result);
        assert.strictEqual(
          result.summary(),
          "2 run, 1 failed"
        );
      }
    }
     
    class TestSuite {
      // ...
      run(result: TestResult) {
        for (test of this.tests) {
          test.run(result);
        }
      }
    }
     
    class TestCase {
      // ...
      run(result: TestResult) {
        result.testStarted();
        this.setUp();
        try {
          const method = (this as any)[this.name];
          method && method();
        }
        catch() {
          result.testFailed();
        }
        this.tearDown();
      }
    }
  • On peut donc utiliser TestSuite pour déclarer les tests et les jouer tous en même temps.

    const suite = new TestSuite();
    suite.add(new TestCaseTest("testTemplateMethod"));
    suite.add(new TestCaseTest("testResult"));
    suite.add(new TestCaseTest("testFailedResultFormatting"));
    suite.add(new TestCaseTest("testFailedResult"));
    suite.add(new TestCaseTest("testSuite"));
    const result = TestResult();
    suite.run(result);
    console.log(result.summary());
  • On peut ensuite corriger les tests qui échouent.

    class TestCaseTest extends TestCase {
      // ...
      testTemplateMethod() {
        const test = new WasRun("testMethod");
        const result = new TestResult();
        test.run(result);
        assert.strictEqual(test.log(), "setUp testMethod tearDown ");
      }
      // ...
    }
  • On peut ensuite mettre en commun la création de l’objet result dans setUp.

    class TestCaseTest extends TestCase {
      // ...
      setUp() {
        this.result = new TestResult();
      }
     
      testTemplateMethod() {
        const test = new WasRun("testMethod");
        test.run(this.result);
        assert.strictEqual(test.log(), "setUp testMethod tearDown ");
      }
      // ...
    }
  • Remarque de Kent : on est ici (Python ou JavaScript) en présence d’un langage de script, plutôt que d’un langage objet, c’est pour ça qu’on doit préfixer les variables membres par this ou self, alors que les variables globales sont accessibles par défaut. Dans un langage objet ce serait le contraire.

  • On a implémenté le fait de jouer plusieurs tests, mais on a l’idée de construire une suite automatiquement à partir d’une classe TestCase. On va laisser le reste des éléments de la liste pour une autre fois.

    • Exécuter la méthode à tester
    • Exécuter setUp en premier
    • Exécuter tearDown à la fin
    • Exécuter tearDown même si la méthode à tester échoue
    • Exécuter plusieurs tests
    • Montrer les résultats
    • Logger un string dans WasRun pour vérifier l'ordre d'exécution
    • Montrer les tests échoués
    • Gérer et montrer les erreurs au setUp
    • Créer TestSuite à partir d'une classe TestCase

24 - xUnit Retrospective

  • xUnit a déjà été implémenté dans la plupart des langages, mais Kent conseille de le réimplémenter soi-même.
    • xUnit est un des exemples les plus emblématiques, et contient énormément de choses en peu de lignes. Le refaire permet d’aller vers l’excellence technique en améliorant sa maîtrise du code.
    • Kent aime bien explorer un nouveau langage en réimplémentant xUnit, ça lui permet d’en comprendre la plupart des caractéristiques en peu de temps.
  • Si on l’implémente, il faudra penser à différencier les échecs dues aux assertions qui échouent, et les autres échecs.

III - Patterns for Test-Driven Development

25 - Test-Driven Development Patterns

  • Les tests permettent de diminuer le niveau de stress en permettant d’être plus confiant sur le logiciel, et en obtenant moins d’erreurs en production. Donc dès que le niveau de stress monte, il faut jouer les tests encore plus souvent.
  • Les tests doivent être complètement isolés, c’est-à-dire qu’on puisse par exemple les jouer dans le désordre, ou en jouer une partie.
    • L’un des avantages d’avoir des tests isolés c’est que ça pousse à avoir du code hautement cohésif et faiblement couplé.
  • Kent fait une liste des prochaines actions sur un bout de papier, ça lui permet de gérer son stress quant à la suite de ce qu’il a à faire, d’être sûr qu’il n’oublie rien, tout en ne se concentrant que sur un élément à la fois.
    • Il fait la même chose avec des listes s’étalant sur la semaine et plusieurs mois.
    • Dans le cadre du TDD les prochaines actions à noter sont d’abord les tests ciblant les cas d’usage, puis les différents refactorings.
    • Si on s’arrête avec des éléments qui restent dans la liste, il faut décider si on ne les veut plus. Si on les veut toujours, il faut reprendre la liste la prochaine fois qu’on travaille sur la fonctionnalité.
  • Écrire les tests d’abord permet de guider le design et de gérer le périmètre de ce qu’on développe, mais ça permet aussi de réduire le stress quand il est modéré. Quand le stress est vraiment important, le risque de se remettre à ne pas tester est grand lui aussi.
  • Pour Kent, le test doit être écrit en commençant par la partie assert. On remonte ensuite petit à petit vers le haut en introduisant les lignes manquantes.
    • L’intérêt est que ça permet de diviser l'écriture de tests elle-même en plus petites étapes qu’on peut faire une à une.
  • Les données utilisées dans les tests doivent être le plus simples possibles. Si 1 fait l’affaire, il n’y a pas besoin d’aller chercher la valeur 2, et si une liste de 3 éléments fait l’affaire pour mener au même design, il n’y a pas besoin d’en faire une liste de 10 éléments.
    • Dans certains cas, on peut avoir besoin de données réalistes plutôt que de données de tests. Par exemple si on fait un refactoring d’un système dont on a besoin de s’assurer qu’il fonctionne exactement comme avant, si on teste notre système en parallèle d’un autre, si on récupère des événements externes etc.
  • Dans la mesure du possible, la relation entre les données de test doit être évidente.
    • On va chercher à faire apparaître les valeurs en dur dans le test, plutôt que de les mettre dans des constantes à importer.
    • Si un résultat est censé être obtenu à partir des données initiales, on peut faire apparaître le calcul pour montrer la relation explicitement. Par exemple reprendre les valeurs 100 / 2 * (1 - 0.015) dans l’assert, plutôt que la valeur finale 49.25.
      • NDLR : ce point en particulier va à l’encontre du conseil de Vladimir Khorikov dans Unit Testing, où il dit préférer ne pas reproduire le code de production dans les tests pour avoir deux versions différentes du calcul, celle des tests étant complètement en dur.

26 - Red Bar Patterns

  • On traite un test à la fois.
  • Pour choisir le prochain test à traiter :
    • On s’assure qu’il nous apprendra quelque chose.
    • On s’assure qu’on est confiant de pouvoir implémenter.
  • Le 1er test qu’on traite doit être simple pour qu’on le traite rapidement. Typiquement, on peut choisir des inputs qui font que le système ne fait rien, ou renvoie l’output le plus simple possible.
  • Pour faire en sorte que les personnes de son équipe se mettent au TDD, Kent propose de leur demander de reformuler la fonctionnalité sous forme de tests, c’est-à-dire d’inputs, d’exécution et d’outputs obtenus dans ce cas.
  • Le seul moment où on écrit des tests après le code, c’est dans le cas des learning tests, où il s’agit de tester le fonctionnement d’une librairie déjà existante. On s’assure qu’on comprend comment elle marche pour la partie qui nous intéresse, et que ce fonctionnement ne changera pas à l’avenir et ne cassera pas notre logiciel.
  • Quand on a un bug, la première chose à faire est d’écrire le plus petit test possible qui le reproduit, pour ensuite le faire passer.
    • Il faut aussi se demander comment on aurait pu faire pour que ce test soit écrit dès le départ dans le cadre du développement en TDD.
  • Kent conseille de prendre souvent des pauses, parce que la bonne idée viendra souvent à ce moment-là. Et si elle ne vient pas, c’est un bon moment pour revoir ses objectifs.
  • Parfois, quand on s’embourbe dans un code bordélique, il est préférable de jeter le travail qu’on vient de faire et de le refaire en repartant de zéro.
    • Dans la même idée, changer de partenaire de pair programming peut permettre d’avoir un œil nouveau sur ce qui a été fait, et peut amener à recommencer autrement.

27 - Testing Patterns

  • Quand un test prend trop de temps pour être passé au vert, il vaut mieux le supprimer et écrire un test plus bas niveau à la place. On peut par exemple décomposer le problème en deux ou trois morceaux qu’on va faire passer à part avant de repartir sur le plus gros test qui les fait marcher ensemble.

    • L’effort supplémentaire pour maintenir le cycle court en vaut la peine.
    • Un test qui prend 10 minutes à passer au vert c’est déjà trop pour Kent.
  • Quand on est en présence d’éléments lourds à tester (comme une base de données), on peut créer un mock, un objet qui se comporte de manière similaire à l’objet réel mais qui fait tout in-memory.

    • Les avantages sont la fiabilité des tests, la lisibilité du code et la rapidité d’exécution, et le désavantage principal est la possibilité que le comportement diverge par rapport au vrai objet. Pour être rassuré, on peut prévoir quelques tests qui utilisent le vrai objet.

    • Parfois, on peut utiliser la classe de test elle-même comme objet de mock, ça s’appelle un test shunt, et ça a l’avantage d’être plus évident et lisible.

      • Exemple où le test a un compteur, et suit l’interface d’un objet qu’on donne déjà au system under test, avec une méthode qui incrémente le compteur quand elle est appelée.

        class Test {
          private count: number;
          constructor() {
            this.count = 0;
          }
         
          testNotification() {
            this.count = 0;
            const result = new TestResult();
            result.addlistener(this);
            new WasRun("testMethod").run(result);
            expect(this.count).toBe(1);
          }
         
          startTest() {
            this.count++;
          }
        }
      • Le test shunt marche bien avec le log de string quand on a des notifications sous forme d’appel de méthode qu’on veut tester dans l’ordre (cf. ce qui a été fait dans l’exemple xUnit).

    • On a parfois besoin de faire un crash test dummy à la place d’un mock normal, pour simuler un cas d’erreur qui arrive rarement : on part d’un objet complexe (par héritage ou par une autre méthode) et on change une méthode spécifique par quelque chose de fake qui peut par exemple lever une exception.

  • Une des techniques de Kent pour redémarrer rapidement quand il travaille sur un projet seul c’est de laisser le dernier test au rouge.

    • Par contre, quand il travaille au sein d’une équipe, il conseille de toujours laisser la suite de tests au vert, et d’intégrer son travail le plus souvent possible. Dans le cas où en intégrant son travail on casse d’autres tests, il vaut sans doute mieux supprimer ce qu'on vient de faire et le refaire.

28 - Green Bar Patterns

  • Quand on a un test rouge, il faut le faire passer au vert le plus vite possible.
    • Fake it ‘til you make it : on utilise des valeurs en dur pour faire passer le test, puis on va les remplacer progressivement par des variables pour généraliser le code.
      • Le coût additionnel de passer par des étapes en plus en vaut la peine parce qu’en échange on a un test vert très rapidement. Ça a un effet psychologique sur la confiance, et ça permet de rester concentré sur le cas qu’on traite.
    • Triangulate : on écrit une première assertion avec des valeurs en dur, puis une deuxième. L’implémentation en dur n’est plus vraiment possible, donc on est forcé de faire une implémentation qui marche pour les deux cas.
      • Kent préfère utiliser fake it et obvious implementation, parce que triangulate est plus lourd à utiliser.
    • Obvious implementation : on écrit directement l’implémentation parce qu’elle nous paraît évidente.
      • Il n’y a aucun souci à le faire si c’est vraiment évident, par contre si on tombe sur un test rouge, il faut accepter de revenir un cran en arrière et repasser par du fake it par exemple.
  • Quand on a une opération sur une collection, on peut d’abord implémenter la version sur un élément : on écrit un test avec un élément qu’on fait passer au vert, puis on introduit un paramètre qui est une collection,on implémente la version avec la collection, et enfin on supprime le paramètre initial qui n’est plus utilisé.
    • L’idée c’est d’isoler les changements. On a un changement qui permet de faire passer un test simple, puis un changement pour complexifier le test et le code en construisant sur ce qui marchait déjà.

29 - xUnit Patterns

  • Pour que la suite de test soit automatisée, la totalité des tests doivent mener à un résultat binaire : soit ils passent, soit non.
  • Il vaut mieux considérer le code comme une boîte noire, en utilisant son interface publique. Ça permet notamment d’avoir l’opportunité d’améliorer le design.
  • On se retrouve souvent avec le code de setup qui est répété entre plusieurs tests, il s’agit d’un code qu’on pourrait mettre en commun sous forme de fixture.
    • L’avantage de le faire c’est que ça élimine la duplication, et le désavantage c’est que ça oblige à regarder à deux endroits pour lire un test.
    • Dans le cas des frameworks xUnit, chaque fixture est présente dans une classe à elle, et donc si on a besoin de plusieurs fixtures on crée plusieurs classes de test.
  • Dans le cas où on utilise un framework comme xUnit qui garantit qu’une méthode tearDown sera exécutée dans tous les cas, on peut utiliser setUp et tearDown pour initialiser et nettoyer des ressources externes nécessaires au test.
  • Si le code d’un test devient long et difficile à lire, c’est le signe qu’il faut revenir à un niveau de granularité plus petit.
  • Kent est intéressé par une idée déjà proposée par plusieurs personnes : utiliser des commentaires, éventuellement imbriqués, pour décrire et organiser les tests.
    • NDLR : c’est ce qui a été formalisé avec le describe() de jest par exemple.
  • Pour tester les exceptions, on peut simplement exécuter le code dans un bloc try/catch, récupérer l’exception attendue, et faire échouer le test si on ne l’a pas eue.

30 - Design Patterns

  • La raison pour laquelle les patterns fonctionnent dans des contextes très différents c’est que la plupart des problèmes qu’on a sont causés par nos outils, et non pas par le domaine qu’on adresse.
  • Dans le cadre du TDD, les design patterns sont appliqués soit au moment de l'écriture des tests, soit pendant le refactoring. Il n’y a pas de phase propre au design.
  • Liste de quelques patterns pour le paradigme objet utilisés dans les exemples de ce livre :
    • Command : il s’agit de créer un objet pour déclencher l’invocation d’un calcul.
      • L’objet est envoyé comme un message, mais il a une méthode run(), et permet de déclencher le calcul de la bonne manière.
    • Value Object : on crée un objet dont la valeur ne change jamais, et donc le code peut compter sur elle.
      • Chaque opération sur l’objet renvoie une nouvelle instance avec les nouvelles valeurs.
      • L’objet doit implémenter l’opérateur d’égalité qui compare les valeurs internes, et en général un opérateur de hash.
      • Kent utilise ce pattern dès qu’il a l’impression que ça pourrait faire un peu sens.
    • Null Object : dans le cas où on est dans un cas spécial, par exemple si on n’a pas de résultat, au lieu de renvoyer null on envoie un objet de résultat habituel qui se comporte de manière à ne rien faire.
      • L’objet de résultat peut par exemple avoir une fonction pour écrire dans un fichier, qui pour ce cas-ci peut être appelée normalement mais ne fait rien.
    • Template Method : une classe mère contient une séquence de méthodes, et les classes filles peuvent choisir l’implémentation de ces méthodes.
      • La classe TestCase en est un exemple : elle choisit de la manière dont les méthodes runTest, setUp et tearDown seront exécutées, et les classes de test en héritent et implémentent ces méthodes.
      • Il faut faire attention à ne pas abstraire trop tôt, au risque de refaire l’abstraction en remettant plus de choses invariantes dans la classe mère.
    • Pluggable Object : dans le cas où on se retrouve avec une condition qui se répète à plusieurs endroits pour effectuer des actions, on va créer plusieurs objets qui obéissent à la même interface, qu’on va instancier une fois en fonction de cette condition. Les autres fois il suffira d’appeler les méthodes sur l’objet sans condition, ce sera déjà le bon objet.
    • Pluggable Selector : on a une structure conditionnelle pour appeler des variations d’une méthode. On ne veut pas choisir le polymorphisme parce qu’il y a trop peu de choses à mettre en commun, mais on veut éliminer le switch : il suffit que notre méthode appelle la bonne méthode de la classe en indiquant son nom dynamiquement par une variable.
      • Attention à ne pas en abuser, il s’agit de l’utiliser seulement si la situation est simple et qu'il n'y a qu’une méthode à appeler.
    • Factory Method : on instancie un objet en appelant une méthode plutôt qu’en appelant directement son constructeur.
      • Ca permet une certaine flexibilité, par exemple sur le type d’objet retourné.
      • Le désavantage c’est que ça ressemble à un simple appel de méthode et pas à une instanciation d’objet.
    • Imposter : quand on a déjà un objet qui encapsule des choses, et qu’on veut en faire une variation qui ajouterait des conditions dans plusieurs méthodes, on va créer un autre objet suivant la même interface.
      • Null Object est un Imposter : il s’utilise de la même manière que l’objet résultat de base.
      • Composite est un Imposter : l’objet de collection est traité de la même manière qu’un seul objet.
      • On peut avoir une idée d’Imposter pendant l’écriture d’un test, ou pendant un refactoring.
    • Composite : on traite un objet représentant une collection de choses avec la même interface que l’objet qui représente cette chose.
      • Ça permet de simplifier le code, mais d’un autre côté ça associe souvent des notions qui n’ont pas de sens dans le monde réel : des dossiers qui contiennent des dossiers, des dessins qui contiennent des dessins etc. Il faut voir si la simplification en vaut le coup.
    • Collecting Parameter : on ajoute un objet en paramètre de méthodes de plusieurs autres objets pour récupérer des résultats calculés dans ces méthodes.
      • On en a souvent besoin quand on utilise Composite.
    • Singleton : Kent conseille de ne pas fournir de variables globales, et de réfléchir plutôt au design de notre code.

31 - Refactoring

  • Dans le cadre du TDD, le refactoring consiste à modifier le code sans que les tests existants ne cassent.
  • L’une des manières de refactorer pas à pas est de faire en sorte que deux choses deviennent identiques pour pouvoir en supprimer une. Par exemple, pour supprimer les classes filles, on peut faire en sorte que leurs méthodes deviennent identiques pour les remonter dans la classe mère unes à unes.
  • Quand on veut faire un changement, une des techniques qui permet de le faciliter est d’isoler la partie qu’on veut changer.
    • On peut par exemple utiliser les techniques Extract Method, Extract Object et Method Object.
    • Quand on a extrait la partie qu’on voulait changer et qu’on a fait le changement, on peut ré-inliner la méthode, mais on peut aussi la laisser : il faut mettre en balance le coût de la méthode supplémentaire par rapport à la structuration que ça peut apporter.
  • Dans le cas où on change le format de la donnée, on peut procéder par petites étapes : créer une variable d’instance pour le nouveau format, l’assigner partout où on assigne l’ancienne, l’utiliser à la place de l’ancienne, et finir par supprimer l’ancienne. Et finir par modifier l’interface externe pour refléter le nouveau format.
  • Extract Method est utilisé notamment pour mieux comprendre un code long, mais aussi pour mettre en commun des morceaux présents dans plusieurs endroits.
  • Inline Method permet à l’inverse de réobtenir un code plus gros, pour par exemple réabstraire mieux par la suite avec d’autres extractions.
  • Extract Interface permet de créer une 2ème implémentation pour une classe existante : on crée une interface qui reprend les méthodes publiques de la classe, et les deux classes vont l’implémenter.
  • Move Method permet de déplacer une méthode d’un objet à un autre, pour mieux aligner les responsabilités.
    • On copie la méthode à l'endroit cible, on ajoute au besoin l’instance de l’objet original ou des variables de l’objet original si besoin. Dans le cas où des variables de l’objet original étaient modifiées, on abandonne ce refactoring. Et enfin on appelle la nouvelle méthode depuis l’ancienne.
    • Cette technique est souvent utilisée quand on remarque que plusieurs messages sont donnés à un autre objet, elle permet de diminuer ce nombre à un.
  • Method Object permet de créer un objet à partir d’une méthode longue et compliquée qui a besoin de plusieurs paramètres et utilise plusieurs variables locales.
    • Procédure :
      • On crée l’objet en prenant les paramètres de la méthode initiale au constructeur.
      • On crée des variables d’instance pour les variables locales de la méthode initiale.
      • On crée une méthode run() sur le nouvel objet, et on l’appelle depuis l’ancienne méthode dont le corps est absorbé dans le nouvel objet.
    • L’une des utilisations peut être quand on veut ajouter une nouvelle logique. On extrait l’ancienne dans un objet qu’on teste à part, puis on ajoute la nouvelle testée aussi, et on ajoute la nouvelle facilement à côté de l’ancienne.
    • L’autre cas peut être quand on n’arrive pas à diviser une grande méthode parce que l’extraction mène à trimballer trop de variables d’une méthode à l’autre. Avec Extract Object les variables locales sont des variables d’instance.
  • Add Parameter permet d'ajouter un paramètre à une méthode. On ajoute le paramètre, puis on laisse le compilateur nous dire ce qu’il faut changer.
    • Parfois on veut remplacer un paramètre, dans ce cas on ajoute le nouveau et on l’utilise, puis on supprimera l’ancien qui n’est plus utilisé.
  • Method Parameter to Constructor Parameter permet de déplacer un paramètre de plusieurs méthodes au constructeur : on ajoute d’abord le paramètre au constructeur et on l’assigne à une variable d’instance, on utilise la variable dans les méthodes, puis on supprime le paramètre des méthodes.
    • Ça peut permettre de ne passer le paramètre qu’une fois quand il est utilisé dans plusieurs méthodes.

32 - Mastering TDD

  • A propos de la taille des steps :
    • Un test peut couvrir l’ajout d’une seule ligne de logique et quelques refactorings, tout comme il peut couvrir l’ajout de centaines de lignes de logique et d’heures de refactorings.
    • Ceci dit, les développeurs qui font du TDD ont tendance à privilégier les petites étapes.
  • Les outils de refactoring automatisés permettent un saut qualitatif en permettant des refactorings beaucoup plus agressifs.
  • A propos de la quantité de tests :
    • Il faut suffisamment de tests pour arriver à ne plus avoir peur.
    • Kent réfléchit en termes de Mean Time Between Failures (MTBF), c'est-à-dire qu’il écrit des tests pour des cas qui ont une chance raisonnable de se produire pendant le temps que le code est censé durer.
    • Sauf bonne raison, on ne teste en général pas le code écrit par d’autres.
    • On supprime un test si :
      • On ne perd pas de confiance en l’enlevant.
      • On ne perd pas l’expression de l’un de nos scénarios par le test.
  • Parmi les caractéristiques des tests qui révèlent un problème de design :
    • Avoir un setup trop complexe ou difficile à mettre en commun.
    • Des tests trop longs à jouer, notamment si la suite prend plus de 10 mn.
    • Des tests fragiles.
  • Le TDD amène à faire le design pour le logiciel tel qu’il fonctionne aujourd’hui, et permet de le modifier à mesure que les besoins arrivent.
  • Kent a une approche aux patterns qui lui permet de les appliquer sans réfléchir pour gagner du temps et ne pas avoir à réfléchir, et garder son énergie pour les cas qui le nécessitent.
  • TDD peut être utilisé sur de grandes codebases, Kent a expérimenté jusqu’à plusieurs dizaines de personnes codant plusieurs années.
  • Le TDD au niveau applicatif écrit par le produit (ATDD) peut permettre d’être plus précis sur les fonctionnalités, mais le TDD est une pratique complémentaire, parce qu’elle amène le feedback sur une échelle de temps plus petite.
  • Quand on veut introduire du TDD sur une codebase classique, on ne peut pas tester et refactorer le tout d’abord parce que ce serait trop long, il faut plutôt :
    • Choisir une partie sur laquelle travailler, et laisser le reste.
    • Commencer à faire du refactoring en travaillant en pair, et avec quelques tests system level.
    • Ajouter des tests à mesure qu’on avance.

Appendix

Appendix II - Fibonacci

  • Il s’agit d’un exemple très court, montrant comment Kent driverait un simple fibonacci à partir des tests.
  • Vu qu’il s’agit d’une fonction unique qui prend un paramètre unique et retourne une valeur unique, Kent choisit de partir sur un seul test.
    test("fibonacci", () => {
      expect(fib(0)).toBe(0);
    });
  • On peut faire passer le test très vite.
    function fib(n: number) {
      return 0;
    }
  • On ajoute ensuite une assertion avec la valeur 1.
    test("fibonacci", () => {
      expect(fib(0)).toBe(0);
      expect(fib(1)).toBe(1);
    });
  • On peut là encore faire passer le test rapidement en traitant le 0 comme étant un cas spécial.
    function fib(n: number) {
      if (n === 0) return 0;
      return 1;
    }
  • Vu que les assertions du test se répètent et vont continuer à se répéter, on peut refactorer le test.
    test([
      [0, 0],
      [1, 1],
    ])("fibonacci", ([input, expected]) => {
      expect(fib(input)).toBe(expected);
    });
  • On peut alors ajouter la valeur d’input suivante.
    test([
      [0, 0],
      [1, 1],
      [2, 1],
    ])("fibonacci", ([input, expected]) => {
      expect(fib(input)).toBe(expected);
    });
  • Le test passe avec le code actuel, donc on ajoute la valeur d’après.
    test([
      [0, 0],
      [1, 1],
      [2, 1],
      [3, 2],
    ])("fibonacci", ([input, expected]) => {
      expect(fib(input)).toBe(expected);
    });
  • Le test échoue pour cette valeur. On peut encore une fois traiter les premiers cas comme des valeurs spéciales.
    function fib(n: number) {
      if(n === 0) return 0;
      if(n <= 2) return 1;
      return 2;
    }
  • On vient de faire passer le test, on peut réfléchir un peu, et essayer de généraliser le code : en fait le dernier 2 retourné est un 1 + 1.
    function fib(n: number) {
      if(n === 0) return 0;
      if(n <= 2) return 1;
      return 1 + 1;
    }
  • Le premier 1 est fib(n - 1), et le deuxième fib(n - 2) :
    function fib(n: number) {
      if(n === 0) return 0;
      if(n <= 2) return 1;
      return fib(n - 1) + fib(n - 2);
    }
  • On peut alors limiter la deuxième condition au cas spécial 1.
    function fib(n: number) {
      if (n === 0) return 0;
      if (n === 1) return 1;
      return fib(n - 1) + fib(n - 2);
    }
  • A chaque petite modification, on a joué l’ensemble des tests, et on a pu avoir l’esprit tranquille, pour se concentrer sur le moment crucial où il fallait généraliser pour faire émerger l’algorithme.