Tutoriel sur l'utilisation de JBehave pour la mise en oeuvre du développement dirigé par le comportement (Behavior Driven Development)

| Arnauld Loyer (Arolla) | Arolla |

Cet article se propose de présenter comment utiliser la bibliothèque JBehave pour mettre en œuvre le développement dirigé par le comportement (BDD en anglais pour Behavior Driven Development). Une introduction au développement dirigé par le comportement peut être trouvée ici.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum Commentez .

- Titre : Tutoriel sur l'utilisation de JBehave pour la mise en oeuvre du développement dirigé par le comportement (Behavior Driven Development)

- Auteurs : Arnauld Loyer (Arolla), Arolla

- Parution : 16 septembre 2013

- Mise à jour : 16 septembre 2013

- Licence : Copyright ® 2013 Arolla. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Mise en place de notre environnement

- II. Notre premier scénario

- III. Conclusion

- IV. Références et Liens

- IV-A. Articles

- IV-B. Outils

- V. Remerciements

I - Mise en place de notre environnement

Commençons par créer un nouveau projet Maven et ajoutons les dépendances nécessaires dans notre descripteur de projet (pom.xml) :

1. <project xmlns="http://maven.apache.org/POM/4.0.0"

2. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

3. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0

5. http://maven.apache.org/xsd/maven-4.0.0.xsd">

7. <modelVersion>4.0.0</modelVersion>

8. <groupId>jbehave-get-started</groupId>

9. <artifactId>bdd101</artifactId>

10. <version>0.0.1-SNAPSHOT</version>

12. <!-- ************************************************ -->

13. <!-- *~~~~~~~~~~~~~~~~~PROPERTIES~~~~~~~~~~~~~~~~~~~* -->

14. <!-- ************************************************ -->

15. <properties>

16. <maven.compiler.source>1.6</maven.compiler.source>

17. <maven.compiler.target>1.6</maven.compiler.target>

18. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

20. <!-- lib versions -->

21. <hamcrest.version>1.2</hamcrest.version>

22. <spring.version>3.1.1.RELEASE</spring.version>

23. <slf4j.version>1.6.4</slf4j.version>

24. <jbehave.version>3.6.6</jbehave.version>

25. </properties>

27. <!-- ************************************************ -->

28. <!-- *~~~~~~~~~~~~~~~~DEPENDENCIES~~~~~~~~~~~~~~~~~~* -->

29. <!-- ************************************************ -->

30. <dependencies>

31. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

32. <!-- ~~~~~~~~~~~~~Commons~~~~~~~~~~~~~~~ -->

33. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

34. <dependency>

35. <groupId>org.apache.commons</groupId>

36. <artifactId>commons-lang3</artifactId>

37. <version>3.1</version>

38. </dependency>

40. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

41. <!-- ~~~~~~~~~~~~~~Spring~~~~~~~~~~~~~~~ -->

42. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

43. <dependency>

44. <groupId>org.springframework</groupId>

45. <artifactId>spring-context</artifactId>

46. </dependency>

48. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

49. <!-- ~~~~~~~~~~~~~~~~Log~~~~~~~~~~~~~~~~ -->

50. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

51. <dependency>

52. <groupId>org.slf4j</groupId>

53. <artifactId>log4j-over-slf4j</artifactId>

54. <version>${slf4j.version}</version>

55. </dependency>

57. <dependency>

58. <groupId>ch.qos.logback</groupId>

59. <artifactId>logback-classic</artifactId>

60. <version>1.0.0</version>

61. </dependency>

63. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

64. <!-- ~~~~~~~~~~~~~JBehave~~~~~~~~~~~~~~~ -->

65. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

66. <dependency>

67. <groupId>org.jbehave</groupId>

68. <artifactId>jbehave-core</artifactId>

69. <version>${jbehave.version}</version>

70. </dependency>

72. <dependency>

73. <groupId>org.jbehave</groupId>

74. <artifactId>jbehave-spring</artifactId>

75. <version>${jbehave.version}</version>

76. </dependency>

78. <dependency>

79. <groupId>de.codecentric</groupId>

80. <artifactId>jbehave-junit-runner</artifactId>

81. <version>1.0.1-SNAPSHOT</version>

82. </dependency>

84. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

85. <!-- ~~~~~~~~~~~~~~~Test~~~~~~~~~~~~~~~~ -->

86. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

87. <dependency>

88. <groupId>junit</groupId>

89. <artifactId>junit-dep</artifactId>

90. </dependency>

91. <dependency>

92. <groupId>org.hamcrest</groupId>

93. <artifactId>hamcrest-library</artifactId>

94. </dependency>

95. <dependency>

96. <groupId>org.hamcrest</groupId>

97. <artifactId>hamcrest-core</artifactId>

98. </dependency>

99. </dependencies>

101. <!-- ************************************************ -->

102. <!-- *~~~~~~~~~~~DEPENDENCY MANAGEMENT~~~~~~~~~~~~~~* -->

103. <!-- ************************************************ -->

104. <dependencyManagement>

105. <dependencies>

106. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

107. <!-- ~~~~~~~~~~~~~~Spring~~~~~~~~~~~~~~~ -->

108. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

109. <dependency>

110. <groupId>org.springframework</groupId>

111. <artifactId>spring-context</artifactId>

112. <version>${spring.version}</version>

113. </dependency>

115. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

116. <!-- ~~~~~~~~~~~~~~~Test~~~~~~~~~~~~~~~~ -->

117. <!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->

118. <dependency>

119. <groupId>junit</groupId>

120. <artifactId>junit-dep</artifactId>

121. <version>4.10</version>

122. </dependency>

123. <dependency>

124. <groupId>org.hamcrest</groupId>

125. <artifactId>hamcrest-library</artifactId>

126. <version>${hamcrest.version}</version>

127. </dependency>

128. <dependency>

129. <groupId>org.hamcrest</groupId>

130. <artifactId>hamcrest-core</artifactId>

131. <version>${hamcrest.version}</version>

132. </dependency>

133. </dependencies>

134. </dependencyManagement>

136. <!-- ************************************************ -->

137. <!-- *~~~~~~~~~~~~~~~~~REPOSITORIES~~~~~~~~~~~~~~~~~* -->

138. <!-- ************************************************ -->

139. <repositories>

140. <repository>

141. <id>codehaus-releases</id>

142. <name>Codehaus Nexus Repository Manager</name>

143. <url>https://nexus.codehaus.org/content/repositories/releases/</url>

144. </repository>

145. <repository>

146. <id>sonatype-snapshots</id>

147. <name>Sonatype Snapshots</name>

148. <url>https://oss.sonatype.org/content/repositories/snapshots/</url>

149. </repository>

150. </repositories>

152. </project>

On notera les dépendances à JBehave, quelques utilitaires pour simplifier l'écriture de nos testset Spring pour l'injection de dépendance.

On retiendra aussi la dépendance à jbehave-junit-runner qui permet une intégration encore plus riche avec Junit en utilisant un lanceur spécial : de.codecentric.jbehave.junit.monitoring.JUnitReportingRunner. Ce lanceur permet de visualiser chaque étape de chaque scénario comme un test spécifique, il est ainsi beaucoup plus facile d'identifier à quelle étape notre scénario a échoué. De plus, cela s'intègre parfaitement avec la vue Eclipse, JUnit permettant un retour immédiat lorsque les tests sont exécutés directement depuis l'IDE. La page du projet correspondant peut être trouvée ici : Code Centric ~ jbehave-junit-runner.

Comme nous nous baserons uniquement sur les annotations Spring pour l'injection de dépendances et la définition de nos étapes, nous nous passerons de fichier de configuration Spring. Le contexte sera directement initialisé par la méthode suivante :

1. public static AnnotationConfigApplicationContext createContextFromBasePackages(String... basePackages) {

2. AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();

3. applicationContext.scan(basePackages);

4. applicationContext.refresh();

5. return applicationContext;

6. }

Voilà pour l'infrastructure, il nous reste à définir la classe qui lancera nos scénarios. Nous nous baserons pour cela sur le framework JUnit pour lequel JBehave fournit les adaptateurs nécessaires.

Au risque de faire un peu peur au début, nous opterons tout de suite pour une description assez riche de notre environnement de tests. JBehave fournit de multiples façons diverses et variées pour configurer l'environnement d'exécution des scénarios, nous choisissons ici la moins « magique », mais la plus verbeuse et surtout celle qui permet un contrôle total de chaque composant.

1. ...

3. import bdd101.util.Springs;

4. import bdd101.util.UTF8StoryLoader;

5. import de.codecentric.jbehave.junit.monitoring.JUnitReportingRunner;

7. @RunWith(JUnitReportingRunner.class)

8. public class AllStoriesTest extends JUnitStories {

10. private final CrossReference xref = new CrossReference();

12. public AllStoriesTest() {

13. configuredEmbedder()//

14. .embedderControls()//

15. .doGenerateViewAfterStories(true)//

16. .doIgnoreFailureInStories(false)//

17. .doIgnoreFailureInView(true)//

18. .doVerboseFailures(true)//

19. .useThreads(2)//

20. .useStoryTimeoutInSecs(60);

21. }

23. @Override

24. public Configuration configuration() {

25. Class<? extends Embeddable> embeddableClass = this.getClass();

26. URL codeLocation = codeLocationFromClass(embeddableClass);

27. StoryReporterBuilder storyReporter = //

28. new StoryReporterBuilder() //

29. .withCodeLocation(codeLocation) //

30. .withDefaultFormats() //

31. .withFormats(CONSOLE, //

32. HTML_TEMPLATE) //

33. .withFailureTrace(true) //

34. .withFailureTraceCompression(true) //

35. .withCrossReference(xref)

36. ;

37. return new MostUsefulConfiguration() //

38. .useStoryLoader(new UTF8StoryLoader(embeddableClass)) //

39. .useStoryReporterBuilder(storyReporter) //

40. .useStepMonitor(xref.getStepMonitor())//

41. ;

42. }

44. @Override

45. protected List<String> storyPaths() {

46. URL searchInURL = codeLocationFromClass(this.getClass());

47. return new StoryFinder().findPaths(searchInURL, "**/*.story", "");

48. }

50. @Override

51. public InjectableStepsFactory stepsFactory() {

52. return new SpringStepsFactory(configuration(),

53. Springs.createAnnotatedContextFromBasePackages("bdd101"));

54. }

55. }

Quelques explications :

- l'annotation @RunWith(JUnitReportingRunner.class) indique à JUnit le lanceur qui doit être utilisé pour exécuter notre test ;

- le nom de notre classe finit par Test afin de suivre les conventions usuelles et étend la classe JBehave : JUnitStories afin de faciliter l'intégration JUnit / JBehave ;

- notre constructeur définit l'Embedder JBehave (c'est-à-dire l'environnement global d'exécution des tests JBehave) qui sera utilisé. Nous verrons les options activées au fur et à mesure de notre article. Ce qu'il faut retenir, c'est que ces paramètres permettent de contrôler l'exécution des tests (useStoryTimeoutInSecs, useThreads) et la perception globale des tests (doVerboseFailures) : un test en échec arrête-t-il l'exécution (doIgnoreFailureInStories) ou est-ce lors de la génération du rapport consolidé (doGenerateViewAfterStories) que l'on considérera que l'exécution est en échec (doIgnoreFailureInView) ? ;

- chaque test JBehave étant lancé de manière indépendante, à la fin de chaque test, JBehave consolide les résultats dans un unique rapport ;

- vient ensuite la seconde partie de la configuration de notre environnement d'exécution. On retiendra pour le moment deux paramètres importants :

- les types de rapport qui seront générés, avec notamment la sortie CONSOLE qui facilitera la phase de développement dans notre IDE, et la sortie HTML_TEMPLATE que nous verrons plus tard et qui permet d'avoir un joli rapport HTML,

- l'utilisation d'une classe spéciale UTF8StoryLoader qui nous permettra de nous affranchir des problématiques d'encodage qui peuvent apparaître dans le cas de développements multiplateformes. On impose ici l'utilisation systématique de l'UTF8, ce qui correspond au choix que nous avons fait dans notre fichier pom.xml de Maven ;

- on trouve ensuite la méthode permettant de récupérer la liste des fichiers *.story à exécuter. Il y a (au moins) deux pièges dans cette déclaration :

- le premier (qui est aussi directement lié à l'utilisation de notre classe UTF8StoryLoader) est que les fichiers seront chargés comme ressources Java, il convient donc d'indiquer des chemins relatifs à notre classpath. Ce qui nous amène au second piège,

- la méthode utilisée ici se base sur l'emplacement de notre classe de test, il est donc important de placer nos fichiers *.story dans les ressources Maven correspondantes : src/test/resources (copiées dans target/test-classes) si notre lanceur est dans un package de src/test/java, ou src/main/resources (copiées dans target/classes) si notre lanceur est dans un package de src/main/java.

II - Notre premier scénario

Commençons simplement par le développement d'une petite calculatrice.

Écrivons notre premier scénario src/test/resources/stories/calculator.story :

1. Scenario: 2+2

3. Given a variable x with value 2

4. When I add 2 to x

5. Then x should equal to 4

On peut constater que toutes nos étapes sont soulignées en rouge pour indiquer que notre éditeur n'est pas parvenu à les associer au code java correspondant.

Exécutons notre lanceur de scénario : Run as / JUnit Test sur la classe AllStoriesTest.

La console Eclipse (rappel : la sortie console est activée grâce à l'option CONSOLE) affiche alors la sortie suivante :

1. (stories/calculator.story)

2. Scenario: 2+2

3. Given a variable x with value 2 (PENDING)

4. When I add 2 to x (PENDING)

5. Then x should equal to 4 (PENDING)

7. @Given("a variable x with value 2")

8. @Pending

9. public void givenAVariableXWithValue2() {

10. // PENDING

11. }

13. @When("I add 2 to x")

14. @Pending

15. public void whenIAdd2ToX() {

16. // PENDING

17. }

19. @Then("x should equal to 4")

20. @Pending

21. public void thenXShouldEqual4() {

22. // PENDING

23. }

Faisons un petit point du résultat obtenu et qui peut être confus au premier abord :

- notre test JUnit est vert ! Ce qui est déroutant ! ;

- grâce au lanceur JUnit (de.codecentric.jbehave.junit.monitoring.JUnitReportingRunner), la vue JUnit nous affiche l'intégralité des étapes qui ont été jouées : BeforeStories, notre scénario et les étapes AfterStories ;

- toutes les étapes de notre scénario sont marquées PENDING et ont été ignorées lors de l'exécution du test ;

- PENDING signifie que les étapes présentes dans notre fichier story n'ont pas leurs correspondants dans le code Java, où les méthodes qui doivent être invoquées sont annotées avec le texte du step correspondant. C'est ce qui est d'ailleurs proposé par JBehave en suggestion d'implémentation dans la console. Pour mettre en échec les étapes PENDING et donc pour que notre test ne soit plus vert, il suffit de changer la stratégie par défaut dans la classe AllStoriesTest par FailingUponPendingStep :

1. ...

2. return new MostUsefulConfiguration() //

3. .useStoryLoader(new UTF8StoryLoader(embeddableClass)) //

4. .useStoryReporterBuilder(storyReporter) //

5. .usePendingStepStrategy(new FailingUponPendingStep())

6. .useStepMonitor(xref.getStepMonitor())//

7. ;

Créons donc une classe bdd101.calculator.CalculatorSteps qui contiendra nos premières définitions d'étapes (Steps) basées en partie sur les propositions faites par JBehave dans la console :

1. import org.jbehave.core.annotations.Given;

2. import org.jbehave.core.annotations.Named;

3. import org.jbehave.core.annotations.Then;

4. import org.jbehave.core.annotations.When;

6. import bdd101.util.StepsDefinition;

8. @StepsDefinition

9. public class CalculatorSteps {

11. @Given("a variable $variable with value $value")

12. public void defineNamedVariableWithValue(String variable, int value) {

13. throw new UnsupportedOperationException();

14. }

16. @When("I add $value to $variable")

17. public void addValueToVariable(@Named("variable") String variable,

18. @Named("value")int value) {

19. throw new UnsupportedOperationException();

20. }

22. @Then("$variable should equal to $expected")

23. public void assertVariableEqualTo(String variable, int expectedValue) {

24. throw new UnsupportedOperationException();

25. }

26. }

Relisons cette classe ligne par ligne :

- @StepsDefinition est une annotation personnelle qui permet à la fois de marquer cette classe comme contenant des définitions d'étapes (ce qui est purement informatif) et qui permet à Spring de la détecter au moment où il va parcourir les classes pour la construction de son contexte ; pour plus d'informations, voir la documentation de Spring sur l'utilisation des annotations (Spring - Using filters to customize scanning) ;

1. import java.lang.annotation.Documented;

2. import org.springframework.stereotype.Component;

4. @Documented

5. @Component

6. public @interface StepsDefinition {}

- les étapes sont définies grâce à des annotations spécifiques : @Given, @When et @Then ;

- la valeur de chaque annotation correspond à la phrase dans le scénario. Nous avons gardé la configuration par défaut qui spécifie que dans ces phrases, les mots commençant par $ désignent les variables. Ainsi, la première annotation permet de supporter les phrases suivantes :

- Given a variable x with value 2,

- Given a variable y with value 17,

- …

- les variables sont passées en paramètre dans le même ordre qu'elles apparaissent dans la phrase. Si cet ordre n'est pas satisfaisant, il est possible d'annoter chaque paramètre, @Named, pour indiquer la variable qu'il référence (Lignes 17 et 18) ;

- la conversion d'une variable dans le type du paramètre se fait automatiquement à l'aide des converteurs prédéfinis. Il est possible d'ajouter de nouveaux converteurs ;

- toutes nos étapes génèrent une exception dans notre implémentation initiale.

On peut constater qu'une fois ces étapes enregistrées, notre éditeur de scénarios nous indique que toutes nos étapes sont bien définies. Les variables apparaissent avec une couleur différente mettant en évidence leur emplacement.

Exécutons à nouveau notre test :

1. (stories/calculator.story)

2. Scenario: 2+2

3. Given a variable x with value 2 (FAILED)

4. (java.lang.UnsupportedOperationException)

5. When I add 2 to x (NOT PERFORMED)

6. Then x should equal to 4 (NOT PERFORMED)

8. java.lang.UnsupportedOperationException

9. at bdd101.calculator.CalculatorSteps.defineNamedVariableWithValue(CalculatorSteps.java:14)

10. (reflection-invoke)

On constate désormais que notre test est en échec, que seule la première étape a été exécutée, mais qu'elle a échoué FAILED en générant une exception, ce qui correspond bien à notre implémentation. La suite du scénario n'a pas été exécutée : NOT PERFORMED.

Passons rapidement sur le développement de notre calculatrice (par une approche de type TDD par exemple) pour arriver à une implémentation fonctionnelle (au sens « qui fonctionne »…). Nous obtenons alors la classe Calculator suivante :

1. import java.util.HashMap;

2. import java.util.Map;

4. public class Calculator {

5. private final Map<String, Integer> context;

7. public Calculator () {

8. context = new HashMap<String, Integer>();

9. }

11. public void defineVariable(String variable, int value) {

12. context.put(variable, value);

13. }

15. public void addToVariable(String variable, int value) {

16. int existing = getVariableValueOrFail(variable);

17. context.put(variable, value + existing);

18. }

20. public int getVariableValue(String variable) {

21. return getVariableValueOrFail(variable);

22. }

24. protected int getVariableValueOrFail(String variable) {

25. Integer existing = context.get(variable);

26. if(existing==null)

27. throw new IllegalStateException(

28. "Variable <" + variable + "> is not defined");

29. return existing;

30. }

31. }

Il est désormais nécessaire de faire le lien entre notre calculateur (Calculator) et la définition de nos étapes (CalculatorSteps).

Dans notre éditeur de scénarios, il est possible d'accéder directement à la méthode correspondante soit par Ctrl+Clic sur l'étape concernée soit en ayant le curseur sur la ligne correspondante et en appuyant sur Ctrl+G (GO!).

1. public class CalculatorSteps {

2. private Calculator calculator = new Calculator ();

4. @Given("a variable $variable with value $value")

5. public void defineNamedVariableWithValue(String variable, int value) {

6. calculator.defineVariable(variable, value);

7. }

9. ...

10. }

En relançant notre test, nous obtenons cette fois :

Bon ! À ce stade, vous devriez avoir un bon aperçu du fonctionnement, faisons un petit saut dans le temps pour arriver à l'implémentation finale de nos étapes :

1. ...

3. @When("I add $value to $variable")

4. public void addValueToVariable(@Named("variable") String variable,

5. @Named("value")int value) {

6. calculator.addToVariable(variable, value);

7. }

9. @Then("$variable should equal to $expected")

10. public void assertVariableEqualTo(String variable, int expectedValue) {

11. assertThat(calculator.getVariableValue(variable), equalTo(expectedValue));

12. }

Avant de faire un petit point avec notre client, enrichissons un peu notre histoire en lui ajoutant de nouveaux scénarios.

On commencera par un petit copier/coller (et oui, on a le droit !) pour vérifier que l'on peut utiliser d'autres noms de variables et d'autres valeurs que 2. Et même que l'on peut mixer l'utilisation de plusieurs variables.

1. Scenario: 2+2 avec une variable y

3. Given a variable y with value 2

4. When I add 2 to y

5. Then y should equal to 4

7. Scenario: 37+5 avec une variable UnBienJoli_Nom

9. Given a variable UnBienJoli_Nom with value 37

10. When I add 5 to UnBienJoli_Nom

11. Then UnBienJoli_Nom should equal to 42

13. Scenario: 7+2 et 9+4 avec une variable y et une variable x

15. Given a variable y with value 7

16. Given a variable x with value 9

17. When I add 2 to y

18. When I add 4 to x

19. Then x should equal to 13

20. Then y should equal to 9

Hummm et si on utilise une variable qui n'existe pas ? Eh bien la réponse est à voir avec le client ! Faisons un petit point avec notre client. Il nous dit que ça serait bien si on pouvait faire plusieurs additions sur la même variable.

1. Scenario: 37+5+6+17

3. Given a variable x with value 37

4. When I add 5 to y

5. And I add 6 to x

6. And I add 17 to x

7. Then x should equal to 65

Dans notre éditeur de scénarios, il est possible d'obtenir une complétion automatique parmi les étapes disponibles par Ctrl+Espace. Il est aussi possible de faire une recherche parmi toutes les étapes disponibles en pressant Ctrl+J.

Relançons notre test :

1. Scenario: 37+5+6+17

2. Given a variable x with value 37

3. When I add 5 to y

4. And I add 6 to x

5. And I add 17 to x

6. Then x should equal to 65 (FAILED)

7. (java.lang.AssertionError:

8. Expected: <65>

9. got: <60>

10. )

Humpf ! Ça, c'était pas prévu ! Que s'est-il passé ? En regardant de plus près, on peut voir que l'on s'est trompé ligne 4, on ajoute 5 à la variable y au lieu de la variable x… Ce qui soulève deux problèmes : comment se fait-il que la variable y existe et pourquoi n'a-t-on pas eu d'erreur ? Ce qui nous permet au passage de voir avec notre client comment il souhaite prendre en compte l'utilisation de variables non définies. Ensemble, nous définissons alors un nouveau scénario :

1. Scenario: Undefined variable displays error message

3. When I add 5 to y

4. Then the calculator should display the message 'Variable <y> is not defined'

Maintenant, intéressons-nous à notre erreur précédente : comment se fait-il que nous n'ayons pas eu d'erreur (IllegalStateException) ? Eh bien, tout simplement parce que l'un des scénarios précédents a défini cette variable, et que les classes définissant les étapes ne sont pas réinstanciées à chaque test : nous utilisons donc la même instance de Calculator pour tous les scénarios.

Les classes définissant les étapes ne sont instanciées qu'une seule fois pour tous les fichiers *.story et pour tous les scénarios d'un fichier story. Et même de manière concurrente si l'on spécifie que les scénarios peuvent être exécutés à travers plusieurs Thread.

Cela nous amène à présenter quelques bonnes pratiques :

- ne pas stocker d'états dans les classes définissant les étapes ;

- utiliser les annotations @BeforeStories, @BeforeStory, @BeforeScenario pour réinitialiser les états entre chaque scénario. Dans le cas de tests unitaires, on pourra se contenter de réinitialiser uniquement le contexte du test avant chaque scénario @BeforeScenario. Tandis que dans le cas des tests d'intégration, on pourra par exemple démarrer le serveur Sélenium, ou le serveur d'applications, au tout début des tests dans une méthode annotée @BeforeStories, réinitialiser la base de données avant chaque histoire @BeforeStory et réinitialiser le contexte du test avant chaque scénario @BeforeScenario;

- utiliser les annotations @AfterStories, @AfterStory et @AfterScenario pour fermer et nettoyer les ressources correspondantes.

Afin de garder une infrastructure de test simple qui nous permettra de travailler en environnement concurrent, nous opterons pour l'utilisation de variables ThreadLocal pour maintenir l'état de chaque scénario. Ainsi, deux scénarios s'exécutant en parallèle (chacun dans leur thread) disposeront chacun de leur propre contexte.

1. public class CalculatorContext {

3. private static ThreadLocal<CalculatorContext> threadContext =

4. new ThreadLocal<CalculatorContext>();

6. public static CalculatorContext context() {

7. return threadContext.get();

8. }

10. public static Calculator calculator() {

11. return context().getCalculator();

12. }

14. public static void initialize() {

15. // one does not rely on ThreadLocal#initialValue()

16. // so that one is sure only initialize create a new

17. // instance

18. threadContext.set(new CalculatorContext());

19. }

20. public static void dispose () {

21. threadContext.remove();

22. }

24. private final Calculator calculator;

25. private Exception lastError;

27. public CalculatorContext() {

28. calculator = new Calculator();

29. }

31. public Calculator getCalculator() {

32. return calculator;

33. }

35. public void setLastError(Exception lastError) {

36. this.lastError = lastError;

37. }

39. public Exception getLastError() {

40. return lastError;

41. }

42. }

Modifions enfin notre classe CalculatorSteps :

1. package bdd101.calculator;

3. import static bdd101.calculator.CalculatorContext.calculator;

4. import static org.hamcrest.CoreMatchers.equalTo;

5. import static org.hamcrest.MatcherAssert.assertThat;

7. import org.jbehave.core.annotations.AfterScenario;

8. import org.jbehave.core.annotations.BeforeScenario;

9. import org.jbehave.core.annotations.Given;

10. import org.jbehave.core.annotations.Named;

11. import org.jbehave.core.annotations.Then;

12. import org.jbehave.core.annotations.When;

14. import bdd101.util.StepsDefinition;

16. @StepsDefinition

17. public class CalculatorSteps {

19. @BeforeScenario

20. public void inializeScenario() {

21. CalculatorContext.initialize();

22. }

24. @AfterScenario

25. public void disposeScenario() {

26. CalculatorContext.dispose();

27. }

29. @Given("a variable $variable with value $value")

30. public void defineNamedVariableWithValue(String variable, int value) {

31. calculator().defineVariable(variable, value);

32. }

34. @When("I add $value to $variable")

35. public void addValueToVariable(@Named("variable") String variable,

36. @Named("value")int value) {

37. calculator().addToVariable(variable, value);

38. }

39. }

Relançons les tests et cette fois nous obtenons bien l'exception souhaitée :

1. ...

3. Scenario: Undefined variable displays error message

4. When I add 5 to y (FAILED)

5. (java.lang.IllegalStateException: Variable <y> is not defined)

6. Then the calculator should display the message 'Variable y is not defined' (PENDING)

7. @Then("the calculator should display the message 'Variable y is not defined'")

8. @Pending

9. public void thenTheCalculatorShouldDisplayTheMessageVariableYIsNotDefined() {

10. // PENDING

11. }

Modifions légèrement notre classe de définitions d'étapes pour gérer l'exception :

1. ...

2. @When("I add $value to $variable")

3. public void addValueToVariable(@Named("variable") String variable,

4. @Named("value")int value) {

5. try {

6. calculator().addToVariable(variable, value);

7. } catch (Exception e) {

8. context().setLastError(e);

9. }

10. }

11. ...

L'erreur pouvant être de nature « métier » (le code est ici simplifié), ce n'est généralement pas à une étape de type Given ou When de la traiter. Les assertions devraient autant que possible se situer dans les méthodes Then.

Enfin, ajoutons l'étape de vérification :

1. ...

3. @Then("the calculator should display the message '$errorMessage'")

4. public void assertErrorMessageIsDisplayed(String errorMessage) {

5. Exception lastError = context().getLastError();

6. assertThat("Not in error situtation", lastError, notNullValue());

7. assertThat("Wrong error message", lastError.getMessage(), equalTo(errorMessage));

8. }

Afin de s'assurer que tous nos scénarios précédents restent cohérents, nous ajoutons aussi l'étape suivante the calculator should not be in error à la fin de chaque scénario.

1. Scenario: 2+2

3. Given a variable x with value 2

4. When I add 2 to x

5. Then x should equal to 4

6. And the calculator should not be in error

8. Scenario: 2+2 avec une variable y

10. Given a variable y with value 2

11. When I add 2 to y

12. Then y should equal to 4

13. And the calculator should not be in error

15. ...

La méthode correspondant à l'étape :

1. ...

2. @Then("the calculator should not be in error")

3. public void assertNoErrorMessageIsDisplayed() {

4. Exception lastError = context().getLastError();

5. assertThat(lastError, nullValue());

6. }

III - Conclusion

Un schéma vaut mieux qu'un long discours :

En attendant le « Specs Creator », le BDD est une bonne alternative !

IV - Références et Liens

Le code complet est disponible ici : jbehave-get-started.

IV-A - Articles

- Dan North - Introduction to BDD (traduction française par Philippe Poumaroux)

- Dan North - BDD is like TDD if…

- Herding Code #42 ~ Scott Bellware on BDD

- Liz Keogh - Translating TDD to BDD

- Gojko Adzic - Specification by Example: How successful teams deliver the right software

IV-B - Outils

- JBehave

- Code Centric ~ jbehave-junit-runner

- JBehave Eclipse plugin

V - Remerciements

Cet article a été publié avec l'aimable autorisation de la société Arolla et l'article d'origine (Ceylon : un java moderne et sans legacy) peut être vu sur le blog/site de Arolla.

Nous tenons à remercier Claude Leloup pour sa relecture orthographique attentive de cet article et Mickael Baron pour la mise au gabarit.