Testez le comportement de votre code et non son implémentation
Mon premier billet sur les tests présente le concept et comment, avec peu d’effort, vous pouvez améliorer la validation que vous faites déjà. Mais le code présenté a des défauts et celui sur lequel je souhaite insister en premier relève de la question de quoi tester. En particulier pour le langage Python qui a une manière particulière de gérer la visibilité des attributs.
Notez que si l’exemple présenté ici repose sur une particularité du langage Python, le concept s’applique à tous les langages.
Rappel du contexte
Pour rappel, l’exemple était une gestion de listes. Un des tests concerne la validation que l’ajout d’un élément ajoute bien cet élément. Pour ce faire, l’assertion vérifie l’état de l’attribut _units. Or, cet attribut est privé. Oui, en Python tout est public et on est tenté d’utiliser cette propriété dans un cas comme celui-ci.
Le test d’ajout d’élément était donc le suivant :
def test_roster_size(): my_roster = mod.Roster("Ultramarines") my_roster.add("Scouts") assert len(my_roster._units) == 1
Pourquoi un attribut est-il privé ?
Définir un attribut comme privé (je parle ici de Python, langage pour lequel la notion d’attribut concerne aussi bien les attributs que le méthodes qui sont des attributs callable) sert avant tout à mettre en œuvre le principe de l’encapsulation, c’est à dire de masquer l’implémentation. Masquer ne signifie pas dissimuler, surtout pour un langage interprété. Si on souhaite masquer l’implémentation, c’est pour pouvoir faire évoluer cette implémentation sans casser l’usage de notre code.
Ainsi, dans notre implémentation, _units est une liste, la liste des *unités*. Imaginons maintenant une évolution possible : certaines unités peuvent exister en plusieurs exemplaires (on peut avoir plusieurs tactical squads) mais pas toutes (un personnage est unique). Du point de vue signature de la méthode, on peut ajouter une notion d’unicité qui sera un attribut optionnel avec une valeur par défaut à False.
Au sein de notre code, la gestion de la liste peut maintenant se faire non plus par une liste mais deux, stockées dans un dictionnaire. La clef sera un booléen signifiant si l’élément est unique.
Le code de l’objet de gestion de la liste pourrait évoluer dans ce sens (seul le code d’intérêt est produit ici et il n’y a pas de validation de paramètre) :
class Roster: def __init__(self, name: str): self.name = name.upper() self._units = {True: [], False: []} def add(self, unit, is_unique=False): if is_unique and unit in self._units[True]: raise ValueError("Duplicate unique unit [{}]".format(unit.name)) self._units[is_unique].append(unit)
Si nous rejouons les tests, l’ajout des tâches est toujours d’actualité. Mais la validation, qui vérifie la taille de l’attribut _tasks considère maintenant l’état comme faux…
Vérifiez l’état, pas l’implémentation
Vous voyez que le non respect du principe de l’encapsulation a des effets tout aussi néfastes sur les tests que sur du code fonctionnel. Pour vérifier le bon fonctionnement de la méthode d’ajout, nous avons évidemment besoin d’observer quelque chose. Ce quelque chose peut avoir plusieurs formes : l’objet Roster peut être implanté comme une collection avec une méthode spéciale len, proposer une property units retournant la liste de toutes les unités ou une méthode dédiée.
Personnellement, j’opterai pour la première option, ce qui conduira une implémentation initiale suivante
def __len__(self): return len(self._units)
à devenir
def __len__(self): return len(sum(list(self._units.values()), []))
Et non, le sum de listes n’est pas à faire mais pour présenter simplement l’intention il fera l’affaire.
Mais le test aurai aussi été en échec…
Oui, c’est exacte. Mais il y a une différence fondamentale dans ce que cela signifie. Dans le cas original, le test est en échec car l’implémentation a changée. Il faut donc adapter le test à la nouvelle implantation. Le test est maintenant en échec parce que nous avons une régression dans notre code. En modifiant l’attribut, la méthode spéciale doit évoluer et le test remplit son rôle en mettant cela en évidence.
En choisissant correctement vos tests, vous vous protégez donc contre la régression. Pour être efficace, un test doit amener un système dans un certain état et valider que cet état correspond à un attendu à l’aide d’observables.
Attention sur le choix des observables. Je lis régulièrement qu’un code soit être testable. La perversité est d’adapter le code au test. Dans le cas présent, il ne faut pas ajouter d’accesseur parce que vous en ressentez le besoin pour les tests. Tout attribut et méthode doivent être définis pour un besoin fonctionnel. Ce que vous devez tester, c’est le bon fonctionnement de votre code pour les services qu’il doit vous rendre et donc pour l’objet, par les interfaces publiques.
Ceci étant précisé, la prochaine étape concernera l’organisation du code.
À propos de... Darko Stankovski
iT guy, photographe et papa 3.0, je vous fais partager mon expérience et découvertes dans ces domaines. Vous pouvez me suivre sur les liens ci-dessous.
1 réponse
[…] avons vu dans le billet précédent que nous devons tester des fonctionnalités, pas des implémentations. Ceci est également vrai […]