Blog Arolla

Test doubles

Hello, je voulais vous parler aujourd'hui d'un sujet vieux comme Hérode mais qui revient souvent lors des discussions sur les tests unitaires. Si vous faites beaucoup de tests unitaires, vous aviez forcement utilisé des mocks. Si vous vous posez la question qu'est-ce que le mot « mock », vous êtes au bon endroit. Je vais (du moins essayer de) vous expliquer ce que c'est. Un mock appelé aussi bouchon ou substitut permet de remplacer les dépendances d'un composant ou d'une unité pour pouvoir le/la tester de façon indépendante et isolée.
Avant d'attaquer le vif du sujet, il faut savoir une chose. Le mot « mock » est utilisé par abus de langage pour décrire la notion de « Test Double » (terme anglophone).
Et là j'entends les petites voix : « Comment, encore un nouveau terme ? »  Mais non rassurez-vous. L'expression existe depuis longtemps. Mais comme le « mock » est le plus utilisé des test doubles, il a naturellement pris le dessus par rapport au terme originel. C'est ce qu'on appelle un autonomase. Et ce n'est pas la première fois. D'ailleurs dans la vie de tous les jours, nous avons pleins d'exemples de ce genre. Prenons le cas des « caddies » (CaddieStore), en France on utilise ce terme pour définir les chariots utilisés dans les supermarchés alors qu'il s'agit d'une marque. Pareil pour le « Frigo » ou « Frigidaire » (marque appartenant au groupe Electrolux Home Products) pour décrire le réfrigérateur.

Revenons à notre sujet et reprenons le terme d'origine pour mieux expliquer le sujet.

Type de test doubles

On utilise les test doubles pour isoler le code que nous testons ou souhaitons analyser. Ce sont des objets qui miment les objets réels. J'aime bien ce schéma de xunitpatterns qui énumère les différents types de test doubles :
Dans les sections suivantes, je détaillerai chaque test double. Pour chacun, je partirai d’un besoin pour écrire le test sans utiliser d’outils ou de librairie et en deuxième temps, je présenterai le code du test en utilisant une librairie. De nos jours il y a tellement de librairies de mock qu'on n'a plus besoin de créer les implémentations des test doubles nous-même. Ces librairies le font très bien à notre place par contre il faut faire attention au coût d’apprentissage. Elles ne sont pas toutes simples à apprendre et à utiliser.
Je vais utiliser NSubstitute pour les exemples de test doubles. NSubstitute comme les autres librairies de mock génère dynamiquement l’implémentation correspondante au comportement souhaité. Je préviens quand même que le code écrit n’est pas tout à fait clean mais pour faciliter l’explication des mocks, je me suis permise de prendre quelques libertés. Je vous demande donc de m’excuser pour le coup 😉

Dummy

Sans librairie

Prenons l'exemple de la division d'un numérateur sur un dénominateur. Si le dénominateur est égal à zéro alors une erreur doit être lancée sinon on retourne le résultat de la division du numérateur par le dénominateur. Un des tests correspondra donc à la première partie de la règle de gestion (si le dénominateur est égal à zéro alors une exception doit être lancée).
[TestMethod]
public void Should_throw_an_exception_when_Divide_by_zero()
{
   Calculator calculator = new Calculator();
   int numerator = 3;
   int denominator = 0;
   Assert.ThrowsException<InvalidOperationException>(() =>
   {
      calculator.Divide(numerator, denominator);
   });
}
Dans ce cas, quelle que soit la valeur du numérateur (avoir « int numerator = 1; » ou « int numerator = 5 ; » ou même « int numerator = new Random().Next(); » à la place de « int numerator = 3; »), le comportement de notre test ne changera pas. On appelle alors cette variable dummy ou fantôme. Elle est obligatoire pour l'appel mais la variation de sa valeur n'a aucune incidence sur le comportement du test. Dans notre exemple, il s’agit d’un entier mais ça peut très bien être un objet plus complexe. Le principe reste le même dans les deux cas.

Avec librairie

J’aurai bien aimé avoir une méthode de type Arg.Any() dans NSubstitute qui génère une valeur random et qui permet de visualiser dans le test ce comportement qui consiste à dire quel que soit la valeur du numérateur, le test passe. Malheureusement, les librairies et frameworks de test que j’ai rencontré ne contiennent pas cette feature. Donc notre code ne changera pas à moins d’utiliser d’autres techniques comme la PBT (Property Based Testing). C’est un autre sujet, j’en parle dans mon article « Différents tests pour un dev » 😉.

Stub

Sans librairie

Imaginons que notre méthode de division a évolué au fil du temps et appelle un service d'authentification pour vérifier si on a le droit de lancer l'opération. Donc on voudra tester le comportement du calculator quand on est authentifié et quand on ne l'est pas. Mais alors comment faire ? Il nous faut dans ce/ cas un objet qui correspond à l’authentification réussie et un deuxième pour l’authentification échouée.

Cas d’authentification réussie

Pour ce cas, nous avons besoin d'une implémentation qui retourne true tout le temps. Pour pouvoir le faire il faut que notre calculator ait une dépendance à un contrat, une interface. Notre test sera donc comme suit.
[TestMethod]
public void Should_divide_a_numerator_by_a_denominator_when_authorization_is_accepted()
{
   IAuthorizer authorizer = new AllowAccessAuthorizer();
   Calculator calculator = new Calculator(authorizer);
   int numerator = 4;
   int denominator = 2;
 
   var result = calculator.Divide(numerator, denominator);
   Assert.AreEqual(2, result);
}
Notre implémentation d’authentification acceptée est la suivante.
public class AllowAccessAuthorizer : IAuthorizer
{
   public bool Authorize()
   {
      return true;
   }
}

Cas d’authentification échouée

Pour ce cas, nous avons besoin d'une implémentation qui retourne false tout le temps. Notre test sera donc comme suit.
[TestMethod]
public void Should_throw_an_exception_when_unauthorized_Division()
{
   IAuthorizer authorizer = new DenyAccessAuthorizer();
   Calculator calculator = new Calculator(authorizer);
   int numerator = 4;
   int denominator = 2;
   Assert.ThrowsException<UnauthorizedAccessException>(() =>
   {
      calculator.Divide(numerator, denominator);
   });
}
Notre implémentation d'authentification échouée est la suivante.
public class DenyAccessAuthorizer : IAuthorizer
{
   public bool Authorize()
   {
      return false;
   }
}
On appelle les implémentations AllowAccessAuthorizer et DenyAccessAuthorizer des stubs. Ce sont des objets qui ne contiennent pas de logique metier et produisent des réponses toutes prêtes pour un test.

Avec librairie

On n'a plus besoin de créer une implémentation par test. Dans le cas où l'authentification réussit, il suffit de définir le résultat de la méthode.
[TestMethod]
public void Should_Divide_a_numerator_by_a_denominator_when_authorization_is_accepted()
{
   IAuthorizer authorizer = Substitute.For<IAuthorizer>();
   authorizer.Authorize().Returns(true);
   Calculator calculator = new Calculator(authorizer);
   int numerator = 4;
   int denominator = 2;

   var result = calculator.Divide(numerator, denominator);
   Assert.AreEqual(2, result);
}
Pour le cas d'authentification échouée, on a juste à définir false comme valeur de retour et voici le test.
[TestMethod]
public void Should_throw_an_exception_when_unauthorized_Division()
{
   IAuthorizer authorizer = Substitute.For<IAuthorizer>();
   authorizer.Authorize().Returns(false);
   Calculator calculator = new Calculator(authorizer);
   int numerator = 3;
   int denominator = 0;
   Assert.ThrowsException<UnauthorizedAccessException>(() =>
   {
      calculator.Divide(numerator, denominator);
   });
}

Spy

Sans librairie

Changeons de contexte et imaginons que notre application comprend un service pour appliquer une réduction sur un article et notifie des utilisateurs de la réduction appliquée. On veut donc vérifier qu'on a autant d'appels au service de notification que d'utilisateurs à notifier. Pour connaitre le nombre d'appels nous aurons besoin des services d'un CounterNotifier qui va compter le nombre d'appels de la méthode Notify().
[TestMethod]
public void Should_Notify_twice_when_having_two_users_to_notify()
{
   List<User> users = new List<User>
   {
      new User(),
      new User()
   };
   var notifier = new CounterNotifier(); ;
   DiscountApplier discountApplier = new DiscountApplier(notifier, users);
   var item = new Item();

   discountApplier.Apply(item, 20);
 
   Assert.AreEqual(users.Count, notifier.CallCount);
}
public class CounterNotifier : INotifier
{
   public int CallCount { get; private set; }
 
   public void Notify(User userToNotify)
   {
      CallCount++;
   }
}
Effectivement on parle bien d’un espion dans ce cas. Comme son nom l'indique, il va espionner tous les appels qui passent et donner le nombre correspondant. Nous pouvons l’utiliser aussi pour espionner les valeurs des attributs d’une classe après un appel d’une méthode.

Avec librairie

Pour le spy, nous avons besoin de compter le nombre d'appels. On va donc vérifier que notre substitut a reçu le nombre d'appels attendu comme suit.
[TestMethod]
public void Should_Notify_twice_when_having_two_users_to_notify()
{
   List<User> users = new List<User>
   {
      new User(),
      new User()
   };
   var notifier = Substitute.For<INotifier>();
   DiscountApplier discountApplier = new DiscountApplier(notifier, users);
   var item = new Item();
 
   discountApplier.Apply(item, 20);
 
   notifier.Received(users.Count);
}
On peut aussi être plus précis en spécifiant quelle méthode est concernée par le nombre d'appels.
[TestMethod]
public void Should_Notify_twice_when_having_two_users_to_notify()
{
   List<User> users = new List<User>
   {
      new User(),
      new User()
   };
   var notifier = Substitute.For<INotifier>();
   DiscountApplier discountApplier = new DiscountApplier(notifier, users);
   var item = new Item();
 
   discountApplier.Apply(item, 20);
 
   notifier.Received(users.Count).Notify(Arg.Any<User>());
}

Mock

Sans librairie

Restons dans l’exemple de notification des réductions. Imaginons qu’on veuille vérifier que les utilisateurs notifiés sont bien ceux passés au service de réduction. Pour cela, on va avoir besoin d’une classe qui doit garder en mémoire les utilisateurs notifiés pour vérifier dans notre test que ce sont bien les mêmes passés.
Si on reprend donc l'exemple des réductions, notre test sera le suivant :
[TestMethod]
public void Should_notify_user_when_apply_discount()
{
   List<User> users = new List<User> { new User() };
   var notifier = new VerifyNotifier(); ;
   DiscountApplier discountApplier = new DiscountApplier(notifier, users);
   var item = new Item();

   discountApplier.Apply(item, 20);

   Assert.IsTrue(notifier.IsCalledForAll(users));
} 
public class VerifyNotifier : INotifier
{
   private bool isCalled;
   private readonly List<User> notifiedUsers = new List<User>();

   public bool IsCalledForAll(List<User> users)
   {
      return isCalled && notifiedUsers.TrueForAll(user => users.Contains(user));
   }

   public void Notify(User userToNotify)
   {
      notifiedUsers.Add(userToNotify);
      isCalled = true;
   }
}
Un mock est donc un objet préprogrammé avec le comportement attendu. Il a détrôné le terme « test double » car il vérifie que les attentes de la spécification sont bien réalisées en termes de comportement. Contrairement aux autres test doubles qui ont tendance à vérifier des états, celui-ci vérifie le comportement.

Avec librairie

Pour vérifier si la méthode est utilisée, il suffit de vérifier que la méthode est appelée pour chacun des utilisateurs à notifier.
[TestMethod]
public void Should_notify_user_when_apply_discount()
{
   List<User> users = new List<User> { new User() };
   var notifier = Substitute.For<INotifier>();
   DiscountApplier discountApplier = new DiscountApplier(notifier, users);
   var item = new Item();
 
   discountApplier.Apply(item, 20);
 
   users.ForEach(
      user => notifier.Received().Notify(user));
}
Vous avez remarqué que c'est la même méthode utilisée pour le spy. Effectivement en utilisant les frameworks de test doubles, souvent, on ne fait pas la distinction entre les différents types de test doubles. Comme présenté dans les exemples précédents, on mélange facilement les types.

Fake

Sans librairie

Le fake est le seul test double qu'on ne va pas utiliser pour les tests unitaires mais plutôt pour des tests d'intégration ou pour les environnements autre que la production. Il s'agit d'une implémentation qui ne doit jamais s'exécuter en production. Reprenons notre exemple de notification. Imaginons que les notifications seront sous forme de mails en utilisant du SMTP ou autre.
public interface INotifier
{
   void Notify(User userToNotify);
}
public class Notifier: INotifier
{
   public void Notify(User userToNotify)
   {
      // Send an email to the user
   }
}
Par contre pour un besoin de test ou dans un environnement autre que la production, nous n'allons pas polluer les messageries des utilisateurs. Nous allons donc implémenter un fake qui écrit dans un fichier par exemple.
public class FileNotifier : INotifier
{
   public void Notify(User userToNotify)
   {
      // Fake implementation
      // Write in a file
   }
}

Avec librairie

Je ne vais pas vous présenter un exemple pour le fake car je suppose que vous l'avez bien retenu. C'est le seul test double qui correspond à une vraie implémentation mais qui ne doit jamais s'exécuter en production. Donc on ne va pas utiliser un framework de mock pour ce type de test double, mais plutôt une implémentation spécifique pour l’environnement de recette ou d’UAT (User Acceptance Test) par exemple.
Même si les librairies de test doubles existent sur le marché depuis longtemps, dans certains cas, on peut se retrouver à ne pas les utiliser.

Comment choisir entre librairie ou implémentation manuelle

Choix d’équipe

En effet, ça peut être un choix d’équipe de ne pas utiliser les librairies de test doubles. Comme je l’ai précisé dans le paragraphe d’avant, les librairies demandent un effort d’apprentissage plus ou moins considérable. Ça dépendra du développeur, de la librairie elle-même et des cas d’utilisation des test doubles. Donc avant de choisir une librairie ou une autre, je vous conseille de faire des tests avec les membres de l’équipe avec différents cas d’utilisation afin de définir les niveaux de complexité d’utilisation de telle ou telle librairie. N’ayez pas peur de vous tourner vers une autre librairie que vous ne connaissez pas. Ne restez pas prisonnier de la librairie que vous utilisez depuis une dizaine d’année. Il y a des librairies sur le marché de plus en plus intuitives et faciles à utiliser.

Design complexe

Dans certains cas, un test peut être long à initialiser. Les causes sont multiples mais je les résume en un design complexe. Une initialisation de test sur une vingtaine de lignes ou plus, une initialisation d’un substitut sur une dizaine de lignes, ça vous rappelle des souvenirs ? D’ailleurs ça nous pousse même à créer une méthode d’initialisation à lancer avant chaque test ou avant tous les tests d’une même classe. Et puis comme on a besoin d’un comportement spécifique pour des cas en particulier, on va redéfinir l’initialisation pour les tests en question. On se retrouve en fin de compte avec non seulement un code non clean, mais aussi un code de test hyper-complexe. Après c’est l’effet, boule de neige ! On ne veut plus maintenir les tests car difficiles à modifier pour arriver à ne plus jamais en faire.
On parle de smells dans ces cas. Quand on met du temps à initialiser un test ou un substitut, c’est un indice de la complexité du code. Dans ce cas, généralement on ne va pas utiliser une librairie de test doubles mais plutôt, une implémentation dédiée pour le test en question.
Mais il faut faire attention, car dans ces cas on peut vite se retrouver avec une multitude d’implémentations pour quelques tests. A un moment, il faudra peut-être songer à faire du refactoring pour simplifier la complexité du code. Je vous laisse le soin de lire « Quand refactorer et pourquoi » pour plus d’information sur le refactoring 😉.

Statique

Quand on fait appel à une méthode statique d’une dépendance dans notre code, c’est difficile de la substituer. Mais pas d’inquiétude, en créant une implémentation dédiée au test, on peut substituer cette dépendance. Voyons comment faire. Imaginons cette classe StaticDiscountApplier qui fait appel à StaticNotifier pour notifier un utilisateur.
public class StaticDiscountApplier
{
   private List<User> usersToNotify;

   public StaticDiscountApplier(List<User> users)
   {
      this.usersToNotify = users;
   }
   private void NotifyUsers(List<User> usersToNotify)
   {
      foreach (var user in usersToNotify)
      {
         StaticNotifier.Notify(user);
      }
   }
}

static class StaticNotifier
{
   public static void Notify(User userToNotify)
   {
      // Send an email to the user
   }
}
Il faut tout d’abord extraire l’appel à la méthode statique dans une méthode qui sera surchargée par la classe fille. Après suivant les langages il y a peut-être des spécificités, comme par exemple pour C#, il faut qu’elle soit protected virtual.
public class StaticDiscountApplier
{
   private List<User> usersToNotify;

   public StaticDiscountApplier(List<User> users)
   {
      this.usersToNotify = users;
   }
   private void NotifyUsers(List<User> usersToNotify)
   {
      foreach (var user in usersToNotify)
      {
         NotifyUser(user);
      }
   }
   protected virtual void NotifyUser(User user)
   {
      StaticNotifier.Notify(user);
   }
}
Ensuite il suffit de créer une classe dans le projet ou package de test qui hérite de la classe qu’on veut tester. Attention, afin de ne pas changer le comportement global de la classe, il ne faut surcharger que la méthode extraite qui fait appel à la méthode statique.
public class DiscountApplierSpy : StaticDiscountApplier
{
   public int NumberOfNotifiedUsers { get; private set; }

   public DiscountApplierMock(List<User> users)
      : base(users)
   {
      NumberOfNotifiedUsers = 0;
   }

   protected override void NotifyUser(User user)
   {
      NumberOfNotifiedUsers++;
   }
}
Enfin, il faut utiliser cette classe dans les tests et non pas la classe initiale du code de production.
De cette façon, vous conservez le comportement de votre classe d’origine (celle de production) mais la dépendance vers la méthode statique est substituée dans la classe fille.
A vous de jouer maintenant 😉.

Avant de finir

Avec les librairies de test double (ou librairies de mock comme on les appelle), on ne se pose plus la question quel type de test double utiliser. On peut très bien à la fois mélanger les types dans un seul test :
  • Utiliser un stub pour définir le retour d’un substitut,
  • Utiliser un dummy pour un paramètre,
  • Et vérifier qu’un autre substitut est appelé n fois en utilisant un spy.
Vous pouvez voir les exemples de code mentionnés dans cet article sur mon github iAmDorra/TestDouble
N’hésitez pas à cloner le repo et tester pour mieux comprendre le fonctionnement des différents test doubles, sinon je suis disponible pour en discuter de vive voix 😉

Conclusion

Maintenant j'espère que les test doubles n'ont plus de secret pour vous. Gardez en tête que les test doubles peuvent être utilisés dans des tests unitaires, d’intégration ou autre. Et puis quand on vous parle de mock demandez bien si on ne voulait pas dire test doubles 🙂
A bientôt pour un prochain article.
Plus de publications

Comments are closed.