- utilisation des outils de test et debuggage
- écriture et test d'une Collection: partie 1 , puis partie 2
- utilisation des Traits dans les tests unitaires (ce billet)
On peut faire l'analogie avec les modules de Ruby (appelés aussi mixins). Par exemple, le module Observable implémente le design pattern du même nom. Si une classe inclut ce module, elle possède de fait les méthodes add_observer, notify_observers, ... Si cette même classe doit aussi être Singleton, il suffit de lui inclure le module en question.
Voici un exemple extrêmement scientifique:
require 'observer' require 'singleton' class Agathe include Observable # Agathe est unique include Singleton def reveille_toi # ça peut mettre un peu de temps sleep 10*rand changed true notify_observers end end class Laurent # Laurent est unique aussi ;) include Singleton def initialize Agathe.instance.add_observer self end def update puts "C'est pas trop tôt" end def reveille_agathe Agathe.instance.reveille_toi end end Laurent.instance.reveille_agathe
et le résultat:
$ ruby test_modules.rb C'est pas trop tôt
Pour plus de détails sur les Traits de Smalltalk, vous pouvez consulter le document Traits: Composable Units of Behaviour(Nathanel Schärli, Stéphane Ducasse, Oscar Nierstrasz, Andrew P.Black).
La manière d'utiliser les Traits pour les tests unitaires dans Pharo m'a interpellé.
On écrit de nombreux tests unitaires de base qui se ressemblent, surtout lorsqu'on manipule des listes, des collections. Si on reprends notre exemple d'une classe qui représente une collection de films (Movies), on écrit à peu près les tests suivants:
- pour une instance de Movies toute fraîche:
- size retourne 0
- isEmpty retourne true
- j'ajoute un film (via Movies#add)
- size retourne 1
- isEmpty retourne false
- j'ajoute trois films
- size retourne 3
- je retire un film
- ....
Dans Pharo, des Traits dédiés à faciliter l'écriture des tests ont été écrits. On peut le voir en parcourant le package Collection-Tests. Les Traits sont par convention préfixés de la lettre T.
1. Test de la classe Movies avec le Trait TSizeTest
Créons notre champ d'expérimentation en ajoutant la classe MoviesWithTraitsTest:
Object subclass: #MoviesWithTraitsTest instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'Movies'
Si vous avez bien suivi le premier billet sur les tests d'une Collection, nous testons la méthode Movies#size avec une collection vide, puis contenant un et plusieurs éléments. Et ça tombe bien, le Trait que TSizeTest existe.
Indiquons à notre classe de test d'utiliser ce Trait. Modifiez la définition de la classe comme suit:
Object subclass: #MoviesWithTraitsTest uses: TSizeTest instanceVariableNames: '' classVariableNames: '' poolDictionaries: '' category: 'Movies'
et auto-magiquement, les méthodes du Trait sont ajoutées dans MovieWithTraitsTest.
Etudions la méthode MovieWithTraitsTest#test0TSizeTest:
test0TSizeTest self shouldnt: [self empty] raise: Error. self shouldnt: [self sizeCollection] raise: Error. self assert: self empty isEmpty. self deny: self sizeCollection isEmpty.
Les deux première lignes vérifient que les méthodes MovieWithTraitsTest#empty et MovieWithTraitsTest#sizeCollection ne lancent pas d'exception.
D'une part, les Traits ne peuvent définir de variables. D'autre part l'utilisateur du Trait a le devoir d'implémenter certaines méthodes pour le bon fonctionnement ce ce Trait.
Nous pouvons le vérifier en ouvrant MovieWithTraitsTest#sizeCollection:
sizeCollection "Answers a collection not empty" ^ self explicitRequirement
Bon, allons examiner explicitRequirement pour en connaître les détails. Clic droit sur la méthode et sélectionnez implementors.
explicitRequirement self error: 'Explicitly required method'
qui lance explicitement une jolie erreur.
Nous devons donc implémenter MovieWithTraitsTest#empty et MovieWithTraitsTest#sizeCollection. Commençons par la première en retournant une instance de Movies vide:
empty ^ Movies new
et une instance de Movies avec quelques éléments pour la suivante:
sizeCollection "Answers a collection not empty" | movies | movies := Movies new. #('Amélie' 'Alien 4' 'Délicatessen') do: [:aTitle| movies add: (Movie newWithTitle: aTitle)]. ^ movies
et lançons les tests. Le debugger se manifeste:
MovieWithTraitsTest#test0TSizeTest vérifie le retour de la méthode Movies#isEmpty qui n'existe pas. Créons la méthode:
isEmpty ^ moviesCollection isEmpty
puis relançons les tests. C'est maintenant le test MovieWithTraitsTest#testSize qui s'arrête car Movies#do: aBlock n'est pas implémentée. Appuyons nous toujours sur moviesCollection:
do: aBlock moviesCollection do: aBlock
et puis tout va beaucoup mieux.
2. Notre propre Trait
Allons plus loin en implémentant notre propre Trait de test. Pour reprendre la démarche du billet précédent, nous allons créer des tests vérifiant que Movies#includes: aMovie retourne true pour les instances de Movie qu'il contient, false dans le cas contraire.
Le test à false est plus simple à réaliser. Voici la méthode MoviesWithTraitsTest#testIncludesElementNotInReturnsFalse:
testIncludesElementNotInReturnsFalse self deny: (self sizeCollection includes: self elementNotIn).elementNotIn représente une instance qui n'est pas censée se trouver dans sizeCollection. Comme les Traits ne possèdent pas de variables d'instances, nous passons par une méthode:
elementNotIn ^ Movie newWithTitle: 'City Of Lost Children'.Vérifiez le bon fonctionnement des tests, cela doit passer comme une lettre à la poste (quand il n'y a pas grève).
Passons au test opposé:
testIncludesElementInReturnsTrue self assert: (self sizeCollection includes: self elementIn).Cette fois-ci, MoviesWithTraitsTest#elementIn doit retourner une instance de Movie précédemment ajoutée à la collection retournée par sizeCollection. Nous devons passer par une variable d'instance.
elementIn ^ aVeryLongEngagementet déclarez aVeryLongEngagement comme variable d'instance.
Initialisez ensuite cette variable dans la méthode setUp:
setUp aVeryLongEngagement := Movie newWithTitle: 'A Very Long Engagement'
et nous pouvons ajouter l'instance dans MoviesWithTraitsTest#sizeCollection:
sizeCollection "Answers a collection not empty" | movies | movies := Movies new. #('Amélie' 'Alien 4' 'Délicatessen') do: [:aTitle| movies add: (Movie newWithTitle: aTitle)]. movies add: aVeryLongEngagement. ^ movies
Ceci fait, nos tests passent.
3. Création du Trait
Nous allons maintenant extraire nos deux méthodes de test vers un nouveau Trait. Faites un clic droit sur la méthode testIncludesElementInReturnsTrue et sélectionnez move to trait...
Pharo demande le Trait dans lequel déplacer la méthode. Nous créons un nouveau Trait, cliquez *New Trait*:
puis choisissez lui un joli nom, ici TCollectionIncludesTest:
et votre nouveau Trait est né.
Si vous revenez à la définition de la classe MoviesWithTraitsTest, vous remarquerez qu'elle utilise notre Trait:
Celui-ci s'appuie sur deux méthodes: sizeCollection et elementIn:
testIncludesElementInReturnsTrue self assert: (self sizeCollection includes: self elementIn).elles sont destinées à être implémentées par les classes qui utilisent le Trait. Autant le rendre explicite. Rajoutons les méthodes suivantes à TCollectionIncludesTest:
sizeCollection "Answers a collection not empty" ^ self explicitRequirement
elementIn "Answers an instance included in self sizeCollection" ^ self explicitRequirement
Ajoutons enfin MoviesWithTraitsTest#testIncludesElementNotInReturnsFalse au Trait:
et complétons par la méthode elementNotIn:
elementNotIn "Answers an instance not included in self sizeCollection" ^ self explicitRequirement
Voilà, le Trait peut être utilisé par d'autres classes.
4. Avantages et inconvénients.
L'utilisation des Traits apporte les avantages suivants:
- la cohérence: un Trait définit un comportement, et les tests utilisant des Traits testeront ces mêmes comportements. Cela garantit que les mêmes noms de méthodes sur des objets de type différent aboutiront à un comportement similaire. On peut vérifier les conventions.
- réutilisation: la mutualisation des méthodes de test évite les duplications de code pour tester des comportements similaires.
Ceci dit des effets de bord apparaissent. Si la granularité des Traits n'est pas assez fine, on se retrouve obligé à implémenter beaucoup de méthodes d'un coup. Par exemple, lorsque nous avons utilisé TSizeTest, nous avons dû implémenter Movies#do: et Movies#isEmpty, méthodes qui ne nous intéressaient pas forcément. Un Trait avec plus de tests nous aurait obligé à implémenter encore plus de méthodes. Cela peut casser la démarche de développement piloté par les tests.
Les Traits de test sont un atout, mais je pense important:
- de bien étudier un Trait avant de l'utiliser
- de garder des Traits avec un périmètre de test limité