Mocks aren't stubs - Développement piloté par les tests et doublures d’objets
Les différentes doublures d'objet
On parle de doublure en référence à celles du cinéma. Une doublure d'objet prendra la place d'une instance complètement implémentée, uniquement pour passer un test. Il existe quatre types de doublures :
- Le Dummy (fantôme en français) : c'est un objet qui n'est pas utilisé et qui sert surtout à remplir une liste de paramètres.
- Le Fake (objet factice en français) : il s'agit d'un objet avec une implémentation fonctionnelle mais non-acceptable pour être utilisée en production. Exemple : un générateur de mot de passe aléatoire qui se contenterait du hash du nom de l'utilisateur. C'est une faille de sécurité inacceptable mais l'implémentation peut suffir pour certains tests.
- Le Stub (bouchon en français) : ces objets sont créés spécialement pour les tests. Leurs méthodes ont des réponses prédéfinies écrites en dur, simplement pour rendre les tests exécutables. Les stubs peuvent égaement mémoriser de l'information pour y effectuer des tests d'état.
- Le Mock (objet fantaisie, en référence à la tortue éponyme ; ou simulacre) : en utilisant un framework, il n'y a pas de code à développer pour utilser un mock. C'est une simulation d'objet basée sur une interface, sur laquelle on spécifie les appels de méthodes, leur nombre d'appel attendu, leurs arguments attendus et le résultat retourné en cas d'appel valide.
Les deux doublures les plus utilisées sont les stubs et les mocks. Ils sont souvent confondus, plus en raison de leurs utiilisations que de leurs implémentations. Un stub est écrit pour "boucher les trous" et éviter de bloquer le développement. Un mock, étant donné la nécessité de surveiller l'objet et d'en prendre le contrôle pour retourner une valeur sur un appel spécifique, l'implémentation est plus complexe. Classe anonyme, proxy et réflexivité sont les outils généralement utilisés par les frameworks de mocking. Ce n'est pas le sujet ici, mais lire le code source des frameworks demeure tout à fait instructif.
Avant d'expliquer en quoi l'utilisation des stubs et des mocks est vraiment différente, une question subsiste : pourquoi utiliser une doublure ?
Pourquoi utiliser une doublure d'objet ?
Il était question de la testabilité dans l'introduction. Le premier usage que l'on peut faire d'une doublure, est de tester unitairement une fonctionnalité qui a une dépendance avec une entité externe à l'application, comme une base de donnée ou un mailer. On ne souhaite pas envoyer un email à chaque exécution de test. Il est alors nécessaire d'abstraire le mailer et d'utiliser une doublure lors des tests.
Replaçons nous dans l'exemple de l'entrepôt et de la commande. Nous avons besoin d'une nouvelle fonctionnalité : lorsqu'une commande est effectuée mais qu'il n'y a plus assez de stock pour celle-ci, un email doit être envoyé. Un nouveau test est alors écrit. Pour le test classique il est nécessaire d'écrire un stub que voici :
public class MailServiceStub implements MailServiceInterface {
private List messages = new ArrayList();
public void send(Message message) {
messages.add(message);
}
public int numberSent() {
return messages.size();
}
}
Envoyer un mail pour ce stub consiste à ajouter le message dans une collection de messages, ce qui incrémente le nombre de messages virtuellement envoyés. Il est alors possible d'écrire le test ci-dessous, sans crainte d'envoyer réellement un mail via l'application :
...
@Test
public void testOrderSendsMailIfUnfilled() {
Order order = new Order(GUINESS, 51);
MailServiceStub mailer = new MailServiceStub();
order.setMailer(mailer);
order.fill(warehouse);
assertEquals(1, mailer.numberSent());
}
...
Grâce à l'assertion assertEquals(1, mailer.numberSent())
, on s'assure qu'un mail a bien été envoyé après l'appel de la méthode fill() sur une commande demandant plus de produit qu'il y'en a dans l'entrepôt.
Le même test mais en utilisant un mock à la place d'un stub s'écrirait ainsi :
@Test
public void testOrderSendsMailIfUnfilled() {
Order order = new Order(GUINESS, 51);
final WarehouseInterface warehouseMock = context.mock(WarehouseInterface.class);
final MailServiceInterface mailerMock = context.mock(MailServiceInterface.class);
order.setMailer(mailerMock);
context.checking(new Expectations() {{
oneOf(warehouseMock).hasInventory(with(any(String.class)), with(any(int.class))); will(returnValue(false));
oneOf(mailerMock).send(with(any(Message.class)));
}});
order.fill(warehouseMock);
}
Comme expliqué précédemment, toute la logique des mocks se situent dans les Expectations. Il y'a ici deux appels principaux, mis en couleur dans le code. Ces lignes pourraient être traduit comme ci-desous :
- Ligne bleue : comme précédemment, un appel de hasInventory avec n'importe quelle entrée est attendu une fois. Si c'est bien le cas, la méthode retournera faux.
- Ligne verte : on attend sur le mock du mailer, l'appel de la méthode send(). C'est-à-dire que dans ce cas de test, il est explicitement attendu qu'un mailer implémentant MailServiceInterface envoie un email.
Nous constatons donc deux différences majeures avec le stub :
- Aucune implémentation de mailer n'est utilisée, il n'y a rien de codé si ce n'est une interface.
- C'est l'appel de méthode qui est vérifié pour s'assurer de l'envoi d'un email, et non le nombre de messages envoyés par le mailer ; la vérification de comportement et non d'état.
Ces doublures de test, dans un cas classique comme orienté mock, apporte plus de testabilité. Mais la meilleure façon de garantir qu'un test soit testables, c'est d'écrire le test avant la fonctionnalité. Il s'agit du fameux Test Driven Development ou TDD, évoqué en conclusion de la partie précédente.
Le développement piloté par les tests
Cette méthodologie mériterait un exposé très démonstratif, mais il est essentiel d'en parler ici. Le TDD consiste à écrire les tests d'une fonctionnalité, puis, de la coder. Certes, dit ainsi, cela semble aussi simple que superflu. C'est en réalité plus complexe.
Comment faire du TDD ?
Il s'agit d'un processus itératif :
- Écrire un test et l'exécuter pour qu'il échoue : cela permet de vérifier que le test n'est pas systématiquement valide, auquel cas il ne testerait rien.
- Écrire le code applicatif permettant de faire passer le test : il s'agit donc de corriger le test.
- Refactoriser le code et relancer le test : la correction du test doit être simpliste, pour converger vers un programme fonctionnel. Cela permet de s'assurer de la valeur du test. Il est ensuite possible de remanier le code pour qu'il soit le plus qualitatif possible, puis de relancer le test qui servira de garde fou. Cela permet de refactoriser sans casser le code.
- Refactoriser le test et le relancer : la qualité du code constituant les tests est toute aussi important que celle du code applicatif. Un test lisible et simple, c'est un test facile à maintenir et qui - potentiellement -, décrit une fonctionnalité bien codée et facile à comprendre.
- Recommencer l'étape une jusqu'à ce qu'il n'y ait plus de fonctionnalité à coder.
Pourquoi faire du TDD ?
- Une meilleure conception : la première question à se poser n'est plus "comment je vais coder ça ?" mais plutôt "comment j'aimerais m'en servir dans mon code ?". Cela pousse à écrire du code simple, et découplé. Un test de 50 lignes pour une seule fonctionnalité est un indicateur d'erreur de concept.
- Une couverture de test de 100% : comme le test est écrit avant code, l'intégralité du code est nécessairement testé. Si l'ensemble de l'application est couverte par des tests, elle en est donc d'autant plus maintenable. Les seuls bugs qui peuvent survenir sont ceux qui ont été oublié dans les cas de test, avec un jeu de données particulier.
Dans quel contexte coder en TDD ?
Sans rentrer dans les détails, le TDD s'intègre parfaitement dans un contexte de développement utilisant les méthodes agiles. Les bonnes pratiques sont : partir d'une User Story, écrire le test et le code en TDD - en Pair Programming ou non -, et opter pour une solution d'intégration continue. Il en résulte une application construite petit à petit, toujours concentrée sur la valeur fonctionnelle sans négliger la qualité technique, et testée à chaque modification envoyée sur le logiciel de suivi de version.
Deux styles de test pour deux styles de TDD
L'utilisation de tests classiques ou orientés mock avec le TDD révèle les différences entre les deux techniques.