Dernière partie de la série:
Smalltalk ajoute à l'héritage un autre mécanisme de réutilisation de code, les
Traits. Pour (sur-)simplifier, un Trait fournit un ensemble de méthodes définissant un comportement. Plusieurs classes peuvent utiliser un même Trait et une classe peut être composée de plusieurs Traits. Cela nous offre un moyen d'éviter des duplications de code en mutualisant des fonctions ou comportements génériques entre les classes.
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
- je retire un film
C'est sympa les premières fois mais la pratique devient vite répétitive. Heureusement, certains frameworks permettent de réduire ce genre de tests à une simple ligne de code (avec
Shoulda dans le monde Rails par exemple).
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.
Cela nous amène à la définition de
Object#explicitRequirement:
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
^ aVeryLongEngagement
et 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é