Mon apprentissage du TDD (retour d'expérience)

Test-Driven Development devrait être communément appelé Test-Driven Design afin de démystifier une ambiguïté souvent perçue. Cet article a pour but d’aider à l’apprentissage du TDD pour qu’il soit, pour vous, plus rapide que le mien !

J’ai trouvé très enrichissante et "amusante" cette période de 3 ans de développement à utiliser TDD, tant ma compréhension a évolué au fil du temps, et mon bonheur de développeur s’est accru par la même occasion.

Il y a 3 ans j’écrivais à quel point je prenais enfin plaisir à tester mon code. Je me permets donc de revenir avec bien plus de conviction à présent sur la réalisation.

   

Étape 1. Le lâcher prise : barrière psychologique

TDD ce n’est pas Test-First, et ce n’est pas une méthode de tests non plus. Pas du tout. Et Colin Damon en parle bien mieux ici. Pour quelqu’un comme moi qui avait travaillé 10 ans sans TDD auparavant, TDD c’est avant tout une méthode de développement permettant de faire émerger son design et donc son code grâce aux tests, grâce à la compréhension métier, et exigeant de lâcher prise sur sa “connaissance” de l’application. Il n’est pas question de “penser savoir” où poser son code. Ou de chercher pendant des dizaines de minutes voire des heures quels impacts vont être occasionnés par notre code. Encore moins de rédiger une conception technique détaillée avant de la faire approuver. Il est question de se lancer, de commencer à itérer, de se faire confiance, de faire confiance à notre base de code, en se laissant guider par une méthode de travail.

(D’ailleurs “faire confiance à ma base de code” est quelque chose qui me paraît impossible sans une méthode comme TDD, mais me direz-vous suis-je bien trop dogmatique ! Pas de souci, c’est un jugement purement subjectif, vous avez peut-être raison (j’ai dit “peut-être” hein…). L’important est en tout cas d’atteindre cette confiance, on y revient plus bas.)

   

Étape 2. Petit à petit, on est moins petit : la vraie valeur de TDD

“Baby-step”, c’est la clé pour s’amuser, pas au sens d’enfantillage, mais pour prendre plaisir à développer et comprendre que notre métier est vraiment beau quand on supprime toute forme de “hasard”. Il faut arriver à comprendre, suite à ce lâcher prise, que TDD représente la construction d’un mur dont chaque brique est une itération. Bon, imaginez des briques qui ne seraient pas que rectangulaires, car vous me direz que c’est trop répétitif sinon. Mais dans l’image, une brique c’est surtout une question fonctionnelle. Un cycle “Red, Green, Refactor” de TDD, c’est répondre à une question. Si vous devez développer une fonctionnalité permettant aux utilisateurs·rices d’effectuer des virements bancaires, ça peut être une question très unitaire comme “c’est quoi un virement bancaire ?”. Ou bien “ça peut être négatif un montant de virement ?”. Stop ! Oulala je suis déjà allé trop loin. On a dit “baby-step”, on prend la première question qui vient. “C’est quoi un virement bancaire ?” Alors on se lance là-dedans.

class MoneyTransferUnitTest {
  @Test
  void shouldNotTransferWithoutAmount() {
    assertThatThrownBy(() -> new MoneyTransfer(null))
      .isExactlyInstanceOf(IllegalArgumentException.class);
  }
}
 
class MoneyTransfer {
  public MoneyTransfer(BigDecimal amount) {
    if (amount == null) {
      throw new IllegalArgumentException();
    }
  }
}

Vous allez me dire que ce bout de code ne dit pas ce qu’est un virement. Certes, mais il commence à définir en tout cas ce que ce n’est pas. Et on itère ainsi. Il est important de rappeler les deux premières phases de TDD avec les subtilités associées :

1. Red : on écrit un test incarnant une question et sa réponse, une bribe de spécification. Cette question doit être minimaliste, c’est-à-dire apporter un seul apport de compréhension. On souhaite ajouter de la valeur progressivement, par étapes rapides. Oui l’apport peut paraître simple, mais pourquoi vouloir rendre les choses compliquées ? Avec les baby-steps, on découpe n’importe quelle montagne en petits cailloux, et à la fin, on l’aura cette montagne.
2. Green : on écrit le code minimaliste pour que ceci fonctionne (sans casser les autres tests évidemment). Edit, je ne préfère pas corriger mais ajouter une note qui peut avoir son importance dans la pratique : en relecture d’article un collègue m’a fait remarquer que j’avais enfreint la troisième loi de TDD. C’est juste ! Mon if (amount == null) ne sert à rien à ce moment, le code minimaliste aurait été de simplement lancer l’exception. J’ai anticipé mon prochain test sans même m’en rendre compte, mais au quotidien ça ne me pose pas de problème (enfin je crois…).

Par succession de petits tests, on donnera un véritable sens à l’expression “documentation vivante” : vos tests donneront absolument toutes les indications pour comprendre fonctionnellement comment l’application est censée se comporter.

   

Étape 3. Abuser de son IDE

J’ai volontairement loupé une précision dans le “Red” et le “Green” de l’étape précédente : attention à ne pas écrire plus de code qu’il n’en compile. C’est-à-dire qu’aussitôt qu’une faute de compilation apparaît, il faut la résoudre immédiatement. Ça peut paraître pénible car on a envie de finir notre ligne de code, mais ce sera tout l’inverse : notre IDE va nous proposer de résoudre chaque problème, et rares sont les cas où notre outil préféré n’arrivera pas à nous aider de manière très pertinente via les actions contextuelles (raccourci number 1 qu’il m'est impensable de ne pas utiliser toutes les 5 secondes : alt+entrée sur Intellij).

   

Étape 4. La liberté du refacto : la confiance en notre code

Par sa construction itérative guidée par les tests, TDD apporte par “effet de bord” un taux de couverture de code par les tests à 100%. Ce n’est pas ce que l’on recherche de prime abord, mais ce code coverage est là, et surtout il est pertinent. C’est ce dernier mot qui est important : cette pertinence permet réellement de se plonger dans la troisième phase de TDD.

3. Refactor : on rend notre code, et nos tests y compris, (choisissez vos termes selon vos préférences) [ “propre” | “lisible” | “parlant” | “maintenable” | “évolutif” | “autre choix” ]

C’est le moment de changer cette vilaine IllegalArgumentException en une exception parlante MissingMandatoryValueException ou InvalidAmountException. Un peu plus tard, de passer d’un constructeur à un builder. D’extraire cette représentation BigDecimal dans un value objet Amount. Finalement de traiter, à votre rythme, avec vos connaissances actuelles, tous les code smells que vous souhaiterez.

C’est un filet de sécurité omniprésent. Que du bonheur ! Il n’y a aucune raison de craindre des régressions.

   

Étape 5. TPP : ne pas refacto trop tôt

On est tellement serein qu’on voudrait sortir nos plus beaux design patterns, mais il y a quand même un risque : celui de s’embourber dans notre propre généricité. Transformation Priority Premise prévient que plus on rend générique notre code, plus on applique une transformation élevée qui d’une part risque de modifier son comportement de manière bien trop imprévisible, et qui d’autre part limitera notre capacité à refacto à l’avenir, voire nous enverra tout droit dans un cul de sac. Ça m'est personnellement arrivé sur le kata diamond où il m’a été préférable de repartir de zéro.

La suggestion serait de procéder à des refacto relativement légers (désolé, je n’ai pas de définition de “refacto léger”), puis avec nos nouveaux baby-steps de voir s’affiner progressivement notre compréhension du métier, et à ce moment de découvrir des refactos très satisfaisants à opérer. Le maître mot ici est donc “patience”. Tout vient à point à qui sait attendre.

   

Étape 6. Tests techniques versus tests métier

A l’époque, malgré la satisfaction procurée par TDD, je dois dire qu’il me restait un véritable point de frustration concernant les appels entre couches de l’architecture de notre application. Quelque chose n’était pas chouette avec les tests permettant de faire émerger un service, une constatation qu’ils étaient répétitifs et terriblement techniques, mais sans comprendre comment faire autrement. Effectivement, de manière très (très très, très ? très !) répétée nous avions ce genre de choses :

@Mock
private MoneyTransfers secondaryPort;
 
@InjectMocks
private MoneyTransfersApplicationService service;
 
 
@Test
void shouldNotTransferMoneyWithoutMoneyTransfer() {
  assertThatThrownBy(() -> service.transferMoney(null))
    .isExactlyInstanceOf(MissingMandatoryValueException.class)
    .hasMessageContaining("moneyTransfer");
}
 
@Test
void shouldTransferMoneyUsingPort() {
  MoneyTransfer moneyTransfer = MoneyTransferFixture.moneyTransfer();
 
  service.transferMoney(moneyTransfer);
 
  verify(secondaryPort).transferMoney(moneyTransfer);
}

Pour le service associé :

public void transferMoney(MoneyTransfer moneyTransfer) {
  Assert.notNull("moneyTransfer", moneyTransfer);
  return transfers.transferMoney(moneyTransfer);
}

Ces deux tests n’ont strictement aucune valeur métier. Ni aucune valeur de “documentation vivante” d’ailleurs. Ils ne servent à rien et à personne. Vérifier un passe-plat, c’est globalement pas terrible. Il m’a fallu bien un an et demi pour tomber sur la bonne discussion avec des collègues et participer au mob programming qui changera définitivement ma façon de travailler.

   

Étape 7. Double loop BDD TDD <3

Tout d’abord, oui c’est un abus de langage car BDD est un outil d’alignement de la compréhension métier. C’est aussi appelé “double loop TDD” tout simplement. Nicolas Le Borgne nous récapitule quelques infos ici. Valentina Cupać l’appelle “Use Case Driven Development”. Et on peut aussi appeler ceci ATDD pour “Acceptance TDD” de par sa philosophie.

Bref, 70 noms potentiellement tous plus ou moins faux, mais en fait c’est vraiment pas du tout ce qui est important. Ce qui est important est de faire émerger son architecture par un comportement applicatif. Cette méthode de travail est particulièrement appropriée pour démarrer une application entière, ou une fonctionnalité complète. On cherchera 2 temporalités :

  1. Une boucle TDD sur un test de composant, donc très haut dans la pyramide de test, c’est-à-dire avec un feedback très long (dans la dizaine de minutes). C’est cette boucle sur laquelle on fait l’abus de parler de BDD ou d’ATDD, car l’objectif est réellement de se positionner en regard extérieur :
    *  en tant qu’application : quel comportement je suis censé apporter (BDD)
    * pour nos utilisateur·rices finaux·ales : quelle valeur j’apporte, et donc tant que cette boucle n’est pas “Green”, je n’ai pas le droit d’arrêter de développer (ATDD)
  2. Des boucles TDD qui vont venir ici et là, par des tests unitaires, sociaux, d'intégration, avec des temps de feedback extrêmement rapides, pour faire émerger notre métier et vérifier quelques mappings.

Et donc exit les tests unitaires pour vérifier des passe-plats ! Puisque les appels entre les couches de notre architecture sont vérifiés par un test de composant ici. Le test de composant se positionne en appel d’une boîte noire et reste très purement métier / lisible du commun du mortel :

Feature: Money transfers
 
  Scenario: Transferred money can be retrieved
    When money is transferred from a sender to a recipient
    Then the recipient bank account has a lot of money

Ici écrit en Gherkin et implémenté en Cucumber, on aura la glue suivante :

@Autowired
private TestRestTemplate rest;
 
private static final String MONEY_TRANSFER =
  """
  {
    "recipientAccount": "2",
    "amount": 1000,
  }
  """;
 
@When("money is transferred from a sender to a recipient")
public void transferMoney() {
  rest.exchange("/api/accounts/1/transfers", HttpMethod.POST, new HttpEntity<>(MONEY_TRANSFER), Void.class);
}
 
@Then("the recipient bank account has a lot of money")
public void assertBankAccountIsCredited() {
  ResponseEntity<RestAccount> account = rest.getForEntity("/api/accounts/2", RestAccount.class);
     assertThat(account.getBody().getBalance()).isEqualTo(BigDecimal.valueOf(1000));
}

A ce moment tout ce qu’on sait, c’est :

  • que l’application doit pouvoir autoriser les virements entre deux comptes
  • ainsi que de restituer le solde d’un compte

En quelques secondes, nous allons pouvoir écrire notre API REST correspondante :

RestController
@RequestMapping("/api/accounts")
public class MoneyTransfersResource {
  private final MoneyTransfersApplicationService service;
 
  public MoneyTransfersResource(MoneyTransfersApplicationService service) {
    this.service = service;
  }
 
  @PostMapping("/{account-id}/transfers")
  @Operation(summary = "Transfer money from a sender to a recipient")
  @ApiResponse(responseCode = "200", description = "Money transferred")
  void transferMoney(@PathVariable("account-id") UUID sender, @Validated @RequestBody RestMoneyTransfer moneyTransfer) {
    service.transferMoney(moneyTransfer.toDomain(sender));
  }
 
  @GetMapping("/{account-id}")
  @Operation(summary = "Fetch account balance")
  @ApiResponse(responseCode = "200", description = "Account information")
  RestAccount accountInformation(@PathVariable("account-id") UUID account) {
    return RestAccount.from(service.get(AccountId.from(account)));
  }
}

Puis le service :

@Service
@Transactional(readOnly = true)
public class MoneyTransfersApplicationService {
  private final MoneyTransfers port;
 
  public MoneyTransfersApplicationService(MoneyTransfers port) {
    this.port = port;
  }
 
  @Transactional
  @PreAuthorize("can('tranfer', '#transfer')")
  public void transferMoney(MoneyTransfer transfer) {
    port.transferMoney(transfer);
  }
 
  @PreAuthorize("can('read', '#account')")
  public Account get(AccountId account) {
    return port.get(account);
  }
}

Le port secondaire, où ici je prends le parti d’y poser les deux méthodes dans la même interface, mais au final peu importe, cette boucle n’est pas restrictive sur les façons de faire, ni sur notre propre connaissance des différents sujets. On aurait pu faire émerger deux interfaces différentes, ou pas du tout pour une archi en couches : au final le comportement applicatif se doit d’être le même, et on se doit de pouvoir faire évoluer / de refactorer notre code en fonction de notre faculté à assimiler les bons patterns, à notre rythme :

public interface MoneyTransfers {
  void transferMoney(MoneyTransfer moneyTransfer);
  Account get(AccountId account);
}

Et de manière générale une bonne partie des classes nécessaires, que l’on utilise un type d’architecture hexagonale, en couches, en oignon, ou ce qu’on veut :

structure des packages - exemple d'architecture hexagonale

Sur le chemin, on comprendra qu’on se retrouve avec une “coquille presque-vide”. Mais une coquille quand même. Une coquille “propre”, où on a pu poser le type d’architecture que l’on souhaitait extrêmement vite (une dizaine de minutes), où l’API REST va appeler notre service métier puis notre port et adaptateur secondaire. On va pouvoir passer du temps à développer notre métier maintenant.

> Si je décide de faire émerger un Domain Service lorsque je ressens un soupçon de complexité des règles métier, mon test de composant ne sera absolument pas impacté.
> Si, pour me focaliser sur mon métier, mon stockage (adaptateur secondaire) en première itération est une ArrayList in-memory, ce sera parfait.
> Si plus tard je décide de basculer cet adaptateur secondaire en base de données, tous les tests seront encore pertinents. Il me suffira probablement d’écrire un test d’intégration pour mon Repository Spring Data.

On se retrouvera très logiquement avec :

  • quelques tests de composant nous permettant de faire émerger l’intégralité de notre architecture (hexagonale ou pas)
  • quelques tests d’intégration nous permettant de faire émerger notre stockage, avec les bons scripts Liquibase, les bons noms de colonne et de table…
  • et surtout beaucoup de tests unitaires et/ou sociaux nous permettant de faire émerger en détail nos objets métier, ainsi que nos mapping entre les couches applicatives (Account vers RestAccount par exemple).

   

Conclusion

Le chemin ne s’arrête pas là, mais mon retour d’expérience si. Probablement découvrirai-je autre chose sur TDD dans les années à venir, je serai parfaitement incapable de savoir s’il y a 10 marches ou 10000 à cet escalier de l’apprentissage, mais ces quelques marches déjà gravies me permettent d’être heureux et serein au quotidien, alors je ne vais pas me plaindre ! Au passage je remercie toutes les personnes qui m’ont donné de leur connaissance, c’est très largement grâce à cette communauté du partage que j’ai la chance de porter ces convictions aujourd’hui.

Pour finir, ne vous arrêtez pas sans avoir lu cet article d’Anthony Rey sur le Type Driven Development. Je ne l’ai pas posé explicitement dans les étapes car je le considère hors champ de cet article, mais c’est une fabuleuse synergie avec le Test Driven Development.