Mocks aren't stubs - Développement piloté par les tests et doublures d’objets
Quel style choisir ?
Il n'y a pas de réponse absolue à cette question. Il s'agit d'un choix personnel. Il est néanmoins nécessaire d'être conscient des impacts et des effets de bord de chacune des deux méthodes sur le développement, particulièrement lorsqu'il s'agit de travailler en équipe.
De manière factuelle, on sait tout d'abord que les deux styles ont une approche différente concernant l'utilisation des doublures d'objets. Les tests classiques n'utilisent les doublures que pour les collaborateurs aux effets de bords délicats. Les tests orientés mocks utilisent les mocks, tout le temps, pour tous les objets hormis le System Under Testing. C'est à prendre en considération : écrire des tests est une tâche récurrente, l'expérience doit être la plus confortable possible. C'est un premier critère de choix.
Ensuite, tout dépend de la vision que l'on a du développement. Est-ce que l'on cherche intuitivement à vérifier l'état des objets après exécution, ou bien à surveiller leur comportement ? Dans le premier cas il vaudra mieux se tourner vers les tests classiques, dans le second cas, choisir les tests orientés mocks.
Il est néannmoins difficile de répondre à cette question intuitivement. Dans un contexte de TDD, écrire un test ce n'est pas vérifier que l'application fonctionne, c'est concevoir, construire et affiner une vision du code et de son fonctionnement. Il y'a donc plus d'impacts qu'on ne le pense à choisir un style ou un autre.
Impact sur l'ordre de développement
L'objectif du TDD est d'éviter le "code spaghetti". Si l'on choisit d'utiliser un TDD orienté mock, le développement s'approchera plus du "code en lasagnes" voir du "code en raviolis". Blague à part, les mocks permettent d'avancer couche par couche dans la production de code. On commence par implémenter le premier objet de l'appllication qui sera le SUT du premier test. Ses collaborateurs seront tous "mockés", leurs comportements spécifiés dans les Expectations. Il s'agit donc de se concentrer sur les interactions entre le SUT et ses collaborateurs. Une fois le premier module terminé, on remplace l'un des mocks par une implémentation, ce qui fournira un nouveau SUT pour un nouveau test. Et ainsi de suite, de couche en couche. Prenons l'exemple d'un logiciel de traitement d'image. On commencerait par l'IHM, en mockant la gestion des images. Puis le développement de cette gestion suivra en mockant le module de traitement de l'image et ainsi de suite :

Le TDD classique a une approche radicalement différente, qui se concentre sur le développement du domaine. On choisit un objet, à n'importe quel niveau applicatif, on le développe en TDD en implémentant ses collaborateurs, avec des stubs pour les parties les plus complexes. Comme le développement démarre à partir de n'importe quelle couche, il est possible de poursuivre dans n'importe quelle direction. En reprenant l'exemple du traitement d'image. Le développement démarre cette fois-ci de la gestion de l'image, puis il se poursuit soit sur l'IHM soit sur le processus de traitement :

On parle d'approche outside-in pour le TDD orienté mock, et de middle-out pour le TDD classique.
Les données de test
Tester suppose tester sur des données de test. Les contraintes sont différentes pour les deux styles. Le TDD orienté mock oblige de créer des mocks à chaque test, c'est-à-dire instancier le mock et définir les attentes. Il est difficile de réutiliser cette partie du code.
Le TDD classique présente un avantage, les données de tests étant des instances d'implémentations réelles. Il est possible d'écrire des Factories de données de test, réutilisables dans tous les tests de l'application. Néanmoins, cela provoque un coût de développement et de maintenance supplémentaire, ce qui est un inconvénient.
L'impact sur le couplage
L'isolation des tests
Les tests n'ont pas la même granularité en fonction du style employé. En utilisant les mocks, la seule implémentation réelle du test est le SUT : ce sera le seul objet vraiment testé. Cela implique que les collaborateurs mockés devront disposer de leurs propres tests.
Ceci-dit, il n'est pas impossible d'avoir une granularité relativement fine avec du TDD classique. Simplement, réaliser un test qui teste beaucoup trop de choses à la fois posera problème au moment de déboguer. Néanmoins, comme les implémentations réelles sont utilisées dans tous les tests, il suffit d'un bug dans une classe, pour que ce soit plusieurs classes de test qui échouent.
Dans tous les cas, il est fondamental de couvrir son application avec des tests fonctionnels, qui vérifieront la conformité de l'application de bout en bout. L'application doit être vérifiée à tous les niveaux de détail.
Le couplage avec l'implémentation des mocks
Le TDD n'est pas une technique qui se maîtrise en un jour, car elle demande de la rigueur. Malheureusement, il en est de même pour l'utilisation des mocks. La maîtrise des outils de mocking est critique, étant donné le couplage qu'il existe entre l'implémentation des SUT et les tests avec mocks. S'il y'a un changement de comportement dans une méthode, c'est au moins un test qui échouera. Au moins un, car il peut il y'en avoir plusieurs si les contraintes sur les méthodes mockées sont trop fortes. Il faut suivre le principe DRY : Don't Repeat Yourself. Un cas de test ne doit être testé qu'une seule fois pour éviter que le refactoring du code ne devienne cauchemardesque.
Ce problème est illustré dans les exemples de l'entrepôt et de la commande :
- Dans un premier test, l'attente est la suivante :
oneOf(warehouseMock).hasInventory(with(equal(GUINESS)), with(equal(50)))
. Les arguments sont déterministes, il faut que ce soit "GUINESS" et "50" pour que le mocking de la méthode hasInventory soit valide. - Dans un autre test, l'attente est :
oneOf(warehouseMock).hasInventory(with(any(String.class)), with(any(int.class)))
. Peu importe les arguments, tant que les types sont bons. Cette partie du code a déjà été vérifiée dans le test précédent, mais l'exécution de la méthode hasInventory est obligatoire. Il faut donc la préciser dans les Expectations. Il est ensuite possible de changer la valeur de retour pour tester un autre comportement.
L'impact sur la conception
Comme dit précédemment, la conception de l'application sera impactée par le style de TDD pour lequel l'on aura opté. Ces conséquences sont résumées dans le tableau ci-dessous :
TDD classique | TDD orienté mock |
---|---|
Privilégie les méthodes à retour de valeur, pour y effectuer des assertions | Privilégie les méthodes agissant sur des objets, pour surveiller leur comportement |
- | Favorise le tell don't ask principle, le mocking de méthode incitant a créer des wrappers avec une bonne sémantique |
Il est parfois nécessaire d'ajouter des méthodes - des accesseurs sur des champs privés par exemples -, pour tester une classe, ce qui est une mauvaise pratique | Pas besoin d'ajouter de méthodes, puisque ce sont déjà les appels de méthodes qui sont vérifiés via le mocking |
- | Favorise l'utilisation de role interfaces (= une interface par rôle fonctionnel = meilleur découplage) à la place des header interfaces (= une interface avec toutes les méthodes qu'un type d'objet devra implémenter), puisqu'il est inutile de créer un mock disposant de méthodes qui ne seront pas testées. |