Régime

 

  1:  [Test]
  2:  public void GetTousLivres_Retourne_Tous_Livres()
  3:  {
  4:      EntrepotLivres entrepotLivres = new EntrepotLivres();
  5:      Auteur evans = new Auteur("Evans", "Eric");
  6:      CategorieLivre categorie = new CategorieLivre("Développement");
  7:      Adresse adresse = new Adresse("55 rue des Pommiers");
  8:      Librairie librairie = FabriqueLibrairie.Creer("Librairie la Pomme d'Or", adresse);
  9:      IList<Librairie> librairies = new List<Librairie>() { librairie };
 10:      Livre domainDrivenDesign = FabriqueLivre.Creer("Domain Driven Design", evans,
 11:          categorie, librairies);
 12:      Livre autreLivre = FabriqueLivre.Creer("autre livre", evans, categorie, librairies);
 13:      entrepotLivres.Ajouter(domainDrivenDesign);
 14:      entrepotLivres.Ajouter(autreLivre);
 15:   
 16:      IList<Livre> livresRetournes = entrepotLivres.GetTousLivres();
 17:   
 18:      Assert.AreEqual(2, livresRetournes.Count);
 19:      Assert.Contains(domainDrivenDesign, livresRetournes);
 20:      Assert.Contains(autreLivre, livresRetournes);
 21:  }

 

Mais que fait donc ce test unitaire ?

C'est la question que je me suis posée récemment en relisant un test du même genre.

Le titre peut nous donner une indication, mais le corps de ce test est lui-même peu lisible. Et même sans rentrer dans le détail du code, il y a fort à parier que la maintenabilité et la performance ne seront pas au rendez-vous avec ce gros bloc d'initialisation (le Arrange de Arrange Act Assert) qui plombe le test.

Le problème derrière tout ça, c'est que pour tester un comportement basique d'un objet (ici un Repository de livres), on est obligé de le remplir en initialisant toute une grappe d'autres objets (des livres, des auteurs, catégories...) assez complexes à construire. DDD ne nous facilite pas vraiment la tâche puisque si l'on veut assurer les invariants et garantir l'état valide du domaine, le seul point d'accès pour créer un objet complexe est normalement la Fabrique. Celle-ci va initialiser l'objet dans un état valide et demander beaucoup de paramètres, dont potentiellement d'autres objets qui doivent être construits eux aussi et... vous l'avez compris, le test devient rapidement surchargé.

 

Respectons l'esprit de TDD

 

Dans son livre XUnit Test Patterns et sur le site xunitpatterns.com, Gerard Meszaros met un nom sur ce genre de code smell : Obscure Test. Il propose une approche de l'écriture de tests fidèle aux principes de TDD pour éviter cet écueil :

« Ecrire les tests d'une manière "outside-in" peut nous éviter de produire des test obscurs qu'il faudrait ensuite refactorer. Dans cette approche, on commence par tracer les contours d'un Test à 4 Phases en utilisant des appels à des Méthodes Utilitaires de Tests non existantes.
Une fois qu'on est satisfait des tests, on peut commencer à écrire les méthodes utilitaires dont on a besoin pour les exécuter. En écrivant les tests d'abord, on obtient une meilleure compréhension de ce que les méthodes utilitaires doivent nous apporter pour rendre l'écriture des tests aussi simple que possible. »

En d'autres termes, on va créer le test le plus simple et le plus lisible possible dès le départ en s'aidant de méthodes utilitaires très expressives. Adieu la grappe d'objets, sa complexité sera cachée dans nos méthodes :

 

  1:  [Test]
  2:  public void GetTousLivres_Retourne_Tous_Livres()
  3:  {
  4:      Livre domainDrivenDesign = CreerLivre("Domain Driven Design");
  5:      Livre autreLivre = CreerLivre("Autre livre");
  6:      EntrepotLivres entrepotLivres = CreerEntrepotAvecDeuxLivres(domainDrivenDesign,
  7:          autreLivre);
  8:   
  9:      List<Livre> livresRetournes = entrepotLivres.GetTousLivres();
 10:   
 11:      Assert.AreEqual(2, livresRetournes.Count);
 12:      Assert.Contains(domainDrivenDesign, livresRetournes);
 13:      Assert.Contains(autreLivre, livresRetournes);
 14:  }

 

Et voici l'implémentation des méthodes utilitaires :

 

   1:  protected Livre CreerLivre(string titre)
   2:  {
   3:      Auteur auteur= new Auteur("Nom", "Prénom");
   4:      CategorieLivre categorie = new CategorieLivre("Une catégorie");
   5:      Adresse adresse = new Adresse("Une adresse");
   6:      Librairie librairie = FabriqueLibrairie.Creer("Une librairie", adresse);
   7:      IList<Librairie> librairies = new List<Librairie>() { librairie };
   8:      Livre livre = FabriqueLivre.Creer(titre, auteur, categorie, librairies);
   9:      return livre;
  10:  }
  11:   
  12:  protected EntrepotLivres CreerEntrepotAvecDeuxLivres(Livre livre1, Livre livre2)
  13:  {
  14:      EntrepotLivres entrepot = new EntrepotLivres();
  15:      entrepot.Ajouter(livre1);
  16:      entrepot.Ajouter(livre2);
  17:      return entrepot;
  18:  }

 

Coupons les cordons

 

Notre test est déjà beaucoup plus lisible, mais une autre question se profile à l'horizon. Est-il vraiment unitaire ? Peut-on dire qu'il est atomique alors qu'il s'appuie intensivement sur le bon fonctionnement d'objets externes, à savoir des Fabriques ?

Meszaros préconise de s'affranchir des dépendances aux objets annexes en recourant à des valeurs par défaut, ou en utilisant des Dummy Objects, des objets creux qui ne contiennent rien ou juste l'information nécessaire au test. Concrètement, on extrait une interface du vrai objet en question pour pouvoir créer des dummies sans contrainte de remplissage. Dans notre exemple c'est le Livre qui sera "dummifié" puisque l'Entrepot à tester contient directement des Livres :

 

   1:  public interface ILivre
   2:  {
   3:      ...
   4:  }
   5:   
   6:  public class DummyLivre : ILivre
   7:  {
   8:      //On construit un objet minimal
   9:      public DummyLivre(string titre)
  10:      {
  11:          this.titre = titre;
  12:      }
  13:  }
  14:   
  15:  //Nouvelle méthode du helper
  16:  protected DummyLivre CreerLivre(string titre)
  17:  {
  18:      return new DummyLivre(titre);
  19:  }

 

Cette solution ne me satisfait pas pleinement parce qu'il faut créer un interface ILivre et la faire implémenter à Livre, ce qui n'est pas forcément élégant au regard de la nécessité d'expressivité des classes du domaine dans DDD. A la place, on peut recourir à un framework d'isolation qui va instancier un proxy de notre Livre pour l'utiliser dans les tests sans besoin de recourir à une interface. Un exemple avec Moq :

 

   1:  protected Livre CreerLivre(string titre)
   2:  {
   3:      var mockLivre = new Mock<Livre>();
   4:      mockLivre.Setup(livre => livre.Titre).Returns(titre);
   5:      return mockLivre.Object;
   6:  }

 

NB : cela nécessite une petite modification de la classe Livre. Il faut rendre la property Titre virtual afin que Moq puisse la surcharger.

 

Stratégies d'extraction

 

Tout cela est très bien, mais nous avons toujours des méthodes utilitaires à l'intérieur de notre classe de test, ce qui n'est pas l'idéal pour la lisibilité et la réutilisabilité. Une solution pourrait être de rendre ces méthodes statiques et de les externaliser dans un Helper à part (on parle aussi de pattern ObjectMother) :

 

  1:  public static class LivreTestHelper
  2:  {
  3:      public static Livre CreerLivre(string titre) { ... }
  4:      public static EntrepotLivres CreerEntrepotAvecDeuxLivres(Livre livre1,
  5:          Livre livre2) { ... }
  6:  }

 

Voici maintenant à quoi ressemble le test :

 

  1:  [Test]
  2:  public void GetTousLivres_Retourne_Tous_Livres()
  3:  {
  4:      Livre domainDrivenDesign = LivreTestHelper.CreerLivre("Domain Driven Design");
  5:      Livre autreLivre = LivreTestHelper.CreerLivre("Autre livre");
  6:      EntrepotLivres entrepotLivres = LivreTestHelper.CreerEntrepotAvecDeuxLivres(
  7:          domainDrivenDesign, autreLivre);
  8:   
  9:      IList<Livre> livresRetournes = entrepotLivres.GetTousLivres();
 10:   
 11:      Assert.AreEqual(2, livresRetournes.Count);
 12:      Assert.Contains(domainDrivenDesign, livresRetournes);
 13:      Assert.Contains(autreLivre, livresRetournes);
 14:  }

 

Imaginons maintenant qu'on ait d'autres tests à écrire sur l'EntrepotLivres. Chacun va avoir besoin de son contexte d'initialisation particulier (livres avec une certaine catégorie, un certain auteur, un nombre de livres fixé...) Chaque test va ajouter ses méthodes au TestHelper et il y a fort à parier qu'on va vite se retrouver avec un helper obèse et beaucoup de duplication de code, chaque test ayant besoin d'un environnement légèrement différent des autres mais avec des choses communes.

C'est là que nous pouvons appeler le pattern Test Data Builder à la rescousse. Il va nous permettre de rendre modulaire la construction de notre contexte de test, sans trop nuire à la lisibilité.

Voici à quoi pourrait ressembler un test avec des DataBuilder :

 

   1:  [Test]
   2:  public void GetLivresParNomAuteur_Retourne_Bons_Livres()
   3:  {
   4:      Livre domainDrivenDesign = new LivreBuilder()
   5:          .AvecTitre("Domain Driven Design")
   6:          .AvecAuteur("Eric",  "Evans")
   7:          .Creer();
   8:   
   9:      Livre autreLivre = new LivreBuilder()
  10:          .AvecTitre("Autre titre")
  11:          .AvecAuteur("Autre", "Auteur")
  12:          .Creer();
  13:   
  14:      EntrepotLivres entrepotLivres = new EntrepotLivresBuilder()
  15:          .AvecLivre(domainDrivenDesign)
  16:          .AvecLivre(autreLivre)
  17:          .Creer();
  18:   
  19:      List<Livre> livresRetournes = entrepotLivres.GetLivresParNomAuteur("Evans");
  20:   
  21:      Assert.AreEqual(1, livresRetournes.Count);
  22:      Assert.Contains(domainDrivenDesign, livresRetournes);
  23:      Assert.IsFalse(livresRetournes.Contains(autreLivre));
  24:  }

 

Je ne sais pas ce que vous en pensez, mais je trouve ça plutôt agréable à l'oeil. Sous le capot, cela donne ça :

 

   1:  public class LivreBuilder
   2:  {
   3:      Mock<Livre> mockLivre = new Mock<Livre>();
   4:   
   5:     public LivreBuilder AvecTitre(string titre)
   6:      {
   7:          mockLivre.Setup(livre => livre.Titre).Returns(titre);
   8:          return this;
   9:      }
  10:   
  11:      public LivreBuilder AvecAuteur(string prenomAuteur, string nomAuteur)
  12:      {
  13:          var mockAuteur = new Mock<Auteur>();
  14:          mockAuteur.Setup(au => au.Prenom).Returns(prenomAuteur);
  15:          mockAuteur.Setup(au => au.Nom).Returns(nomAuteur);
  16:          mockLivre.Setup(livre => livre.Auteur).Returns(mockAuteur.Object);
  17:          return this;
  18:      }
  19:   
  20:      public Livre Creer()
  21:      {
  22:          return mockLivre.Object;
  23:      }
  24:  }
  25:   
  26:  public class EntrepotLivresBuilder
  27:  {
  28:      EntrepotLivres entrepotLivres = new EntrepotLivres();
  29:   
  30:      public EntrepotLivresBuilder AvecLivre(Livre livre)
  31:      {
  32:          entrepotLivres.Ajouter(livre);
  33:          return this;
  34:      }
  35:   
  36:      public EntrepotLivres Creer()
  37:      {
  38:          return entrepotLivres;
  39:      }
  40:  }

 

Un mot sur SetUp

 

On pourrait aussi être tenté d'instancier une partie du contexte des tests dans la méthode de SetUp de la classe de test (attribut [SetUp] avec NUnit).

Ce n'est pas forcément l'idéal en termes de lisibilité, et en tout cas on ne devrait construire dans cette méthode que les données qui sont le plus petit dénominateur commun entre tous les tests. En général, je préfère réserver le SetUp (et le TearDown) pour des opérations de plomberie générale des tests qui ne touchent pas au données, comme le démarrage et le rollback d'une transaction.

Dans ce sens, je suis assez d'accord avec Roy Osherove, auteur de The Art Of Unit Testing, qui écrit (p. 191) :

« Ma préférence personnelle est que chaque test crée ses propres mocks et stubs en appelant des méthodes de helpers dans le test lui-même, de façon à ce que le lecteur du test sache exactement ce qui se passe, sans avoir besoin de sauter du test au setup pour comprendre le tableau d'ensemble. »

Recette

Une alternative originale

 

Le Danois Mark Seemann propose une approche légèrement différente, qui fait partie d'un cortège de design patterns et conventions qu'il a appelé ZeroFriction TDD. Bien que je ne sois pas totalement persuadé par tous les patterns présentés (certains sont très orientés programmation défensive), un d'entre eux mérite qu'on s'y attarde : Fixture Object.

Seemann part du constat qu'en utilisant des helpers de tests statiques, on a forcément un contexte de test stateless, ce qui nous prive de certaines commodités. L'idée est de constituer un objet Fixture contenant l'état actuel des données de test et aussi la référence au système à tester (SUT). Ainsi le corps du test garde sa lisibilité et sa maintenabilité :

 

   1:  [Test]
   2:  public void GetTousLivres_Retourne_Tous_Livres()
   3:  {
   4:      EntrepotLivresFixture fixture = new EntrepotLivresFixture();
   5:   
   6:      fixture.AjouterPlusieursLivres();
   7:   
   8:      // System Under Test
   9:      EntrepotLivres sut = fixture.Sut;
  10:      List<Livre> resultats = sut.GetTousLivres();
  11:   
  12:      Assert.AreEqual(fixture.Livres, resultats);
  13:  }

 

On remarque que deux conventions de Zero Friction TDD censées reposer l'oeil et le cerveau du lecteur du test sont utilisées : Naming SUT Test Variables, qui préconise de nommer toujours de la même manière la variable de l'objet testé, et Naming Direct Output Variables, qui conseille la même chose pour le résultat qui va être asserté.

Voici l'implémentation du Fixture Object :

 

   1:  internal class EntrepotLivresFixture
   2:  {
   3:      public EntrepotLivresFixture()
   4:      {
   5:          Sut = new EntrepotLivres();
   6:          this.Livres = new List<Livre>();
   7:      }
   8:   
   9:      internal EntrepotLivres Sut { get; private set; }
  10:      internal IList<Livre> Livres { get; private set; }
  11:      internal int Plusieurs { get { return 3; } }
  12:      internal Livre PremierLivre { get { return Livres[0]; } }
  13:      internal Livre DeuxiemeLivre { get { return Livres[1]; } }
  14:   
  15:      internal void AjouterPlusieursLivres()
  16:      {
  17:          for (int i = 0; i < Plusieurs; i++)
  18:          {
  19:              Livre livre = new Mock<Livre>().Object;
  20:              Livres.Add(livre);
  21:              Sut.Ajouter(livre);
  22:          }
  23:      }
  24:  }

 

 

Voilà, ceci n'est qu'un échantillon des différentes stratégies de factorisation de tests imaginables. Il y a probablement des milliers de solutions possibles.

Et vous, quelle est votre régime minceur préféré pour vos tests ?