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) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
129.
130.
131.
132.
133.
134.
135.
136.
137.
138.
139.
140.
141.
142.
143.
144.
145.
146.
147.
148.
149.
150.
151.
152.
<project
xmlns
=
"http://maven.apache.org/POM/4.0.0"
xmlns
:
xsi
=
"http://www.w3.org/2001/XMLSchema-instance"
xsi
:
schemaLocation
=
"http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd"
>
<modelVersion>
4.0.0</modelVersion>
<groupId>
jbehave-get-started</groupId>
<artifactId>
bdd101</artifactId>
<version>
0.0.1-SNAPSHOT</version>
<!-- ************************************************ -->
<!-- *~~~~~~~~~~~~~~~~~PROPERTIES~~~~~~~~~~~~~~~~~~~* -->
<!-- ************************************************ -->
<properties>
<maven.compiler.source>
1.6</maven.compiler.source>
<maven.compiler.target>
1.6</maven.compiler.target>
<project.build.sourceEncoding>
UTF-8</project.build.sourceEncoding>
<!-- lib versions -->
<hamcrest.version>
1.2</hamcrest.version>
<spring.version>
3.1.1.RELEASE</spring.version>
<slf4j.version>
1.6.4</slf4j.version>
<jbehave.version>
3.6.6</jbehave.version>
</properties>
<!-- ************************************************ -->
<!-- *~~~~~~~~~~~~~~~~DEPENDENCIES~~~~~~~~~~~~~~~~~~* -->
<!-- ************************************************ -->
<dependencies>
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~Commons~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<dependency>
<groupId>
org.apache.commons</groupId>
<artifactId>
commons-lang3</artifactId>
<version>
3.1</version>
</dependency>
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~Spring~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<dependency>
<groupId>
org.springframework</groupId>
<artifactId>
spring-context</artifactId>
</dependency>
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~~Log~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<dependency>
<groupId>
org.slf4j</groupId>
<artifactId>
log4j-over-slf4j</artifactId>
<version>
${slf4j.version}</version>
</dependency>
<dependency>
<groupId>
ch.qos.logback</groupId>
<artifactId>
logback-classic</artifactId>
<version>
1.0.0</version>
</dependency>
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~JBehave~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<dependency>
<groupId>
org.jbehave</groupId>
<artifactId>
jbehave-core</artifactId>
<version>
${jbehave.version}</version>
</dependency>
<dependency>
<groupId>
org.jbehave</groupId>
<artifactId>
jbehave-spring</artifactId>
<version>
${jbehave.version}</version>
</dependency>
<dependency>
<groupId>
de.codecentric</groupId>
<artifactId>
jbehave-junit-runner</artifactId>
<version>
1.0.1-SNAPSHOT</version>
</dependency>
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~Test~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<dependency>
<groupId>
junit</groupId>
<artifactId>
junit-dep</artifactId>
</dependency>
<dependency>
<groupId>
org.hamcrest</groupId>
<artifactId>
hamcrest-library</artifactId>
</dependency>
<dependency>
<groupId>
org.hamcrest</groupId>
<artifactId>
hamcrest-core</artifactId>
</dependency>
</dependencies>
<!-- ************************************************ -->
<!-- *~~~~~~~~~~~DEPENDENCY MANAGEMENT~~~~~~~~~~~~~~* -->
<!-- ************************************************ -->
<dependencyManagement>
<dependencies>
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~Spring~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<dependency>
<groupId>
org.springframework</groupId>
<artifactId>
spring-context</artifactId>
<version>
${spring.version}</version>
</dependency>
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~Test~~~~~~~~~~~~~~~~ -->
<!-- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -->
<dependency>
<groupId>
junit</groupId>
<artifactId>
junit-dep</artifactId>
<version>
4.10</version>
</dependency>
<dependency>
<groupId>
org.hamcrest</groupId>
<artifactId>
hamcrest-library</artifactId>
<version>
${hamcrest.version}</version>
</dependency>
<dependency>
<groupId>
org.hamcrest</groupId>
<artifactId>
hamcrest-core</artifactId>
<version>
${hamcrest.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<!-- ************************************************ -->
<!-- *~~~~~~~~~~~~~~~~~REPOSITORIES~~~~~~~~~~~~~~~~~* -->
<!-- ************************************************ -->
<repositories>
<repository>
<id>
codehaus-releases</id>
<name>
Codehaus Nexus Repository Manager</name>
<url>
https://nexus.codehaus.org/content/repositories/releases/</url>
</repository>
<repository>
<id>
sonatype-snapshots</id>
<name>
Sonatype Snapshots</name>
<url>
https://oss.sonatype.org/content/repositories/snapshots/</url>
</repository>
</repositories>
</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 :
2.
3.
4.
5.
6.
public
static
AnnotationConfigApplicationContext createContextFromBasePackages
(
String... basePackages) {
AnnotationConfigApplicationContext applicationContext =
new
AnnotationConfigApplicationContext
(
);
applicationContext.scan
(
basePackages);
applicationContext.refresh
(
);
return
applicationContext;
}
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
...
import
bdd101.util.Springs;
import
bdd101.util.UTF8StoryLoader;
import
de.codecentric.jbehave.junit.monitoring.JUnitReportingRunner;
@RunWith
(
JUnitReportingRunner.class
)
public
class
AllStoriesTest extends
JUnitStories {
private
final
CrossReference xref =
new
CrossReference
(
);
public
AllStoriesTest
(
) {
configuredEmbedder
(
)//
.embedderControls
(
)//
.doGenerateViewAfterStories
(
true
)//
.doIgnoreFailureInStories
(
false
)//
.doIgnoreFailureInView
(
true
)//
.doVerboseFailures
(
true
)//
.useThreads
(
2
)//
.useStoryTimeoutInSecs
(
60
);
}
@Override
public
Configuration configuration
(
) {
Class<
? extends
Embeddable>
embeddableClass =
this
.getClass
(
);
URL codeLocation =
codeLocationFromClass
(
embeddableClass);
StoryReporterBuilder storyReporter =
//
new
StoryReporterBuilder
(
) //
.withCodeLocation
(
codeLocation) //
.withDefaultFormats
(
) //
.withFormats
(
CONSOLE, //
HTML_TEMPLATE) //
.withFailureTrace
(
true
) //
.withFailureTraceCompression
(
true
) //
.withCrossReference
(
xref)
;
return
new
MostUsefulConfiguration
(
) //
.useStoryLoader
(
new
UTF8StoryLoader
(
embeddableClass)) //
.useStoryReporterBuilder
(
storyReporter) //
.useStepMonitor
(
xref.getStepMonitor
(
))//
;
}
@Override
protected
List<
String>
storyPaths
(
) {
URL searchInURL =
codeLocationFromClass
(
this
.getClass
(
));
return
new
StoryFinder
(
).findPaths
(
searchInURL, "**/*.story"
, ""
);
}
@Override
public
InjectableStepsFactory stepsFactory
(
) {
return
new
SpringStepsFactory
(
configuration
(
),
Springs.createAnnotatedContextFromBasePackages
(
"bdd101"
));
}
}
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 :
2.
3.
4.
5.
Scenario
:
2
+
2
Given a variable x with
value 2
When I add 2
to x
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
(
stories/
calculator.story)
Scenario
:
2
+
2
Given a variable x with
value 2
(
PENDING)
When I add 2
to x (
PENDING)
Then x should equal to 4
(
PENDING)
@Given
(
"a variable x with value 2"
)
@Pending
public
void
givenAVariableXWithValue2
(
) {
// PENDING
}
@When
(
"I add 2 to x"
)
@Pending
public
void
whenIAdd2ToX
(
) {
// PENDING
}
@Then
(
"x should equal to 4"
)
@Pending
public
void
thenXShouldEqual4
(
) {
// PENDING
}
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 :
2.
3.
4.
5.
6.
7.
...
return
new
MostUsefulConfiguration
(
) //
.useStoryLoader
(
new
UTF8StoryLoader
(
embeddableClass)) //
.useStoryReporterBuilder
(
storyReporter) //
.usePendingStepStrategy
(
new
FailingUponPendingStep
(
))
.useStepMonitor
(
xref.getStepMonitor
(
))//
;
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
import
org.jbehave.core.annotations.Given;
import
org.jbehave.core.annotations.Named;
import
org.jbehave.core.annotations.Then;
import
org.jbehave.core.annotations.When;
import
bdd101.util.StepsDefinition;
@StepsDefinition
public
class
CalculatorSteps {
@Given
(
"a variable $variable with value $value"
)
public
void
defineNamedVariableWithValue
(
String variable, int
value) {
throw
new
UnsupportedOperationException
(
);
}
@When
(
"I add $value to $variable"
)
public
void
addValueToVariable
(
@Named
(
"variable"
) String variable,
@Named
(
"value"
)int
value) {
throw
new
UnsupportedOperationException
(
);
}
@Then
(
"$variable should equal to $expected"
)
public
void
assertVariableEqualTo
(
String variable, int
expectedValue) {
throw
new
UnsupportedOperationException
(
);
}
}
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) ;
2.
3.
4.
5.
6.
import
java.lang.annotation.Documented;
import
org.springframework.stereotype.Component;
@Documented
@Component
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
(
stories/
calculator.story)
Scenario
:
2
+
2
Given a variable x with
value 2
(
FAILED)
(
java.lang.UnsupportedOperationException)
When I add 2
to x (
NOT PERFORMED)
Then x should equal to 4
(
NOT PERFORMED)
java.lang.UnsupportedOperationException
at bdd101.calculator.CalculatorSteps.defineNamedVariableWithValue
(
CalculatorSteps.java:14
)
(
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
import
java.util.HashMap;
import
java.util.Map;
public
class
Calculator {
private
final
Map<
String, Integer>
context;
public
Calculator (
) {
context =
new
HashMap<
String, Integer>(
);
}
public
void
defineVariable
(
String variable, int
value) {
context.put
(
variable, value);
}
public
void
addToVariable
(
String variable, int
value) {
int
existing =
getVariableValueOrFail
(
variable);
context.put
(
variable, value +
existing);
}
public
int
getVariableValue
(
String variable) {
return
getVariableValueOrFail
(
variable);
}
protected
int
getVariableValueOrFail
(
String variable) {
Integer existing =
context.get
(
variable);
if
(
existing==
null
)
throw
new
IllegalStateException
(
"Variable <"
+
variable +
"> is not defined"
);
return
existing;
}
}
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!).
2.
3.
4.
5.
6.
7.
8.
9.
10.
public
class
CalculatorSteps {
private
Calculator calculator =
new
Calculator (
);
@Given
(
"a variable $variable with value $value"
)
public
void
defineNamedVariableWithValue
(
String variable, int
value) {
calculator.defineVariable
(
variable, value);
}
...
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
...
@When
(
"I add $value to $variable"
)
public
void
addValueToVariable
(
@Named
(
"variable"
) String variable,
@Named
(
"value"
)int
value) {
calculator.addToVariable
(
variable, value);
}
@Then
(
"$variable should equal to $expected"
)
public
void
assertVariableEqualTo
(
String variable, int
expectedValue) {
assertThat
(
calculator.getVariableValue
(
variable), equalTo
(
expectedValue));
}
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
Scenario
:
2
+
2
avec une variable y
Given a variable y with
value 2
When I add 2
to y
Then y should equal to 4
Scenario
:
37
+
5
avec une variable UnBienJoli_Nom
Given a variable UnBienJoli_Nom with
value 37
When I add 5
to UnBienJoli_Nom
Then UnBienJoli_Nom should equal to 42
Scenario
:
7
+
2
et 9
+
4
avec une variable y et une variable x
Given a variable y with
value 7
Given a variable x with
value 9
When I add 2
to y
When I add 4
to x
Then x should equal to 13
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.
2.
3.
4.
5.
6.
7.
Scenario
:
37
+
5
+
6
+
17
Given a variable x with
value 37
When I add 5
to y
And I add 6
to x
And I add 17
to x
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
Scenario
:
37
+
5
+
6
+
17
Given a variable x with
value 37
When I add 5
to y
And I add 6
to x
And I add 17
to x
Then x should equal to 65
(
FAILED)
(
java.lang.AssertionError:
Expected
:
<
65
>
got
:
<
60
>
)
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 :
2.
3.
4.
Scenario
:
Undefined variable displays error message
When I add 5
to y
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
public
class
CalculatorContext {
private
static
ThreadLocal<
CalculatorContext>
threadContext =
new
ThreadLocal<
CalculatorContext>(
);
public
static
CalculatorContext context
(
) {
return
threadContext.get
(
);
}
public
static
Calculator calculator
(
) {
return
context
(
).getCalculator
(
);
}
public
static
void
initialize
(
) {
// one does not rely on ThreadLocal#initialValue()
// so that one is sure only initialize create a new
// instance
threadContext.set
(
new
CalculatorContext
(
));
}
public
static
void
dispose (
) {
threadContext.remove
(
);
}
private
final
Calculator calculator;
private
Exception lastError;
public
CalculatorContext
(
) {
calculator =
new
Calculator
(
);
}
public
Calculator getCalculator
(
) {
return
calculator;
}
public
void
setLastError
(
Exception lastError) {
this
.lastError =
lastError;
}
public
Exception getLastError
(
) {
return
lastError;
}
}
Modifions enfin notre classe CalculatorSteps :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
package
bdd101.calculator;
import
static
bdd101.calculator.CalculatorContext.calculator;
import
static
org.hamcrest.CoreMatchers.equalTo;
import
static
org.hamcrest.MatcherAssert.assertThat;
import
org.jbehave.core.annotations.AfterScenario;
import
org.jbehave.core.annotations.BeforeScenario;
import
org.jbehave.core.annotations.Given;
import
org.jbehave.core.annotations.Named;
import
org.jbehave.core.annotations.Then;
import
org.jbehave.core.annotations.When;
import
bdd101.util.StepsDefinition;
@StepsDefinition
public
class
CalculatorSteps {
@BeforeScenario
public
void
inializeScenario
(
) {
CalculatorContext.initialize
(
);
}
@AfterScenario
public
void
disposeScenario
(
) {
CalculatorContext.dispose
(
);
}
@Given
(
"a variable $variable with value $value"
)
public
void
defineNamedVariableWithValue
(
String variable, int
value) {
calculator
(
).defineVariable
(
variable, value);
}
@When
(
"I add $value to $variable"
)
public
void
addValueToVariable
(
@Named
(
"variable"
) String variable,
@Named
(
"value"
)int
value) {
calculator
(
).addToVariable
(
variable, value);
}
}
Relançons les tests et cette fois nous obtenons bien l'exception souhaitée :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
...
Scenario
:
Undefined variable displays error message
When I add 5
to y (
FAILED)
(
java.lang.IllegalStateException: Variable <
y>
is not defined)
Then the calculator should display the message 'Variable y is not defined'
(
PENDING)
@Then
(
"the calculator should display the message 'Variable y is not defined'"
)
@Pending
public
void
thenTheCalculatorShouldDisplayTheMessageVariableYIsNotDefined
(
) {
// PENDING
}
Modifions légèrement notre classe de définitions d'étapes pour gérer l'exception :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
...
@When
(
"I add $value to $variable"
)
public
void
addValueToVariable
(
@Named
(
"variable"
) String variable,
@Named
(
"value"
)int
value) {
try
{
calculator
(
).addToVariable
(
variable, value);
}
catch
(
Exception e) {
context
(
).setLastError
(
e);
}
}
...
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 :
2.
3.
4.
5.
6.
7.
8.
...
@Then
(
"the calculator should display the message '$errorMessage'"
)
public
void
assertErrorMessageIsDisplayed
(
String errorMessage) {
Exception lastError =
context
(
).getLastError
(
);
assertThat
(
"Not in error situtation"
, lastError, notNullValue
(
));
assertThat
(
"Wrong error message"
, lastError.getMessage
(
), equalTo
(
errorMessage));
}
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
Scenario
:
2
+
2
Given a variable x with
value 2
When I add 2
to x
Then x should equal to 4
And the calculator should not be in error
Scenario
:
2
+
2
avec une variable y
Given a variable y with
value 2
When I add 2
to y
Then y should equal to 4
And the calculator should not be in error
...
La méthode correspondant à l'étape :
2.
3.
4.
5.
6.
...
@Then
(
"the calculator should not be in error"
)
public
void
assertNoErrorMessageIsDisplayed
(
) {
Exception lastError =
context
(
).getLastError
(
);
assertThat
(
lastError, nullValue
(
));
}
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▲
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.