Monday, July 13, 2009

Pharo et tests unitaires (4): 1 Trait acheté, 4 tests offerts !!

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
    • size retourne 3
  • 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é

No comments:

Post a Comment