Cucumber

Cucumber est un framework pour l’implémentation de scénarios de type BDD (Behavior-Driven-Development). Dans ce chapitre, nous verrons en quoi consiste une approche BDD ainsi qu’une introduction à l’automatisation de scénarios avec Cucumber en Java. Enfin nous verrons comment valider une application Web en associant Cucumber et Selenium.

Behavior-Driven-Development (BDD)

Le BDD (Behavior-Driven-Development) est une approche proposée par Dan North dans son article Introducing BDD. Le fondement du BDD est moins de raisonner en terme de tests que de comportements d’un système.

Un test fait le plus souvent référence à une interaction entre un utilisateur et un système. Le résultat de cette interaction doit être conforme à une exigence ou une expression de besoins. Le test est un formalisme pour valider le travail de développement.

L’approche BDD oblige à revoir la notion de test et d’expression de besoins. Par exemple, dans une approche agile (qui est à la base de l’approche BDD), une nouvelle fonctionnalité à ajouter dans un système est associée à un ensemble de critères d’acceptation avant que l’équipe ne la développe. Dan North propose de représenter autant que possible ces critères d’acceptation par des scénarios décrivant les comportements attendus du système. Pour faciliter la communication, il propose de suivre un formalisme simple pour écrire ces scénarios : Given, When, Then :

Given some initial context,
When an event occurs,
Then ensure some outcomes.

Soit en français :

Etant donné un contexte initial,
Quand un événement survient,
Alors on s'assure de certains résultats

Note

Le formalisme du BDD reprend le principe du AAA des tests. L’étape Given correspond à la phase Arrange, l’étape When correspond à la phase Act et l’étape When correspond à la phase Assert.

En utilisant ce formalisme, les scénarios deviennent facilement communicables entre les développeurs, les experts fonctionnels et toutes les parties-prenantes dans la création de la solution. Les scénarios deviennent un support à la discussion et à la compréhension des fonctionnalités. Idéalement avec le BDD, la spécification du système est l’ensemble des scénarios définis.

De plus, en utilisant ce formalisme, il est possible de concevoir un outil capable d’analyser les scénarios et de les exécuter. On parle alors de spécification exécutable. Ainsi, les scénarios, bien qu’automatisés, demeurent écrits en langage naturel et sont facilement accessibles à l’ensemble des intervenants du projet.

Il ne faut pas voir le BDD comme une approche technique mais bien plutôt comme une manière d’organiser le développement logiciel différemment en repensant, d’une part, le lien entre expression de besoins et validation et, d’autre part, la communication entre les développeurs et les experts fonctionnels.

Plusieurs framework ont été proposés pour aider à réaliser ce type d’approche : FitNesse, JBehave (proposé par Dan North lui-même) et Cucumber. Cucumber est probablement le plus utilisé actuellement. Il a l’avantage de ne pas être lié à un seul langage de programmation car il a été porté pour plusieurs langages.

Écrire des scénarios avec Cucumber

Cucumber permet de rédiger des scénarios en suivant un formalisme appelé le langage Gherkin. Les scénarios sont regroupés par fonctionnalité (feature). Ils sont écrits dans un simple fichier texte. Par défaut, Cucumber s’attend à ce que le fichier porte l’extension .feature.

Imaginons que nous voulions tester le jeu du pendu (the hangman en anglais). Nous pourrions écrire les scénarios suivant dans le fichier PlayHagman.feature :

Le fichier PlayHangman.feature
Feature: Suggesting a letter

Scenario: Knowing the occurence of one letter in the word

  Given the word to be guessed is cucumber
  When the player suggests the letter u
  Then the letter is found 2 times

Scenario: Knowing the position of all occurences in the word

  Given the word to be guessed is cucumber
  When the player suggests the letter u
  Then the word is -u-u----

Le fichier commence par le mot clé Feature suivi de deux points et de la description de la fonctionnalité. Puis viennent les scénarios. Chacun d’entre-eux commence par Scenario suivi de deux points et de son nom. Chaque scénario est constitué d’étapes (steps) commençant par Given, When``ou ``Then. Il est possible de séquencer plusieurs étapes en utilisant And. Nous pourrions donc fusionner les deux scénarios précédents en un seul :

Le fichier PlayHangman.feature
Feature: Suggesting a letter

Scenario: Knowing the occurence and position of one letter in the word

  Given the word to be guessed is cucumber
  When the player suggests the letter u
  Then the letter is found 2 times
  And the word is -u-u----

La caractère # peut être utilisé pour signifier un commentaire. Il est également possible de l’utiliser suivi d’un mot-clé pour donner une méta-information. Par exemple, Gherkin est disponible dans de nombreuses langues. Pour préciser la langue du fichier, on utilise #language :

Le fichier JeuDuPendu.feature en français
#language: fr

Fonctionnalité: Proposer une lettre

Scénario: Connaître l'occurrence d'une lettre dans un mot

  Etant donné que le mot à trouver est cucumber
  Quand le joueur propose la lettre u
  Alors la lettre est présente 2 fois

Scénario: Connaître la position de toutes les occurrences dans un mot

  Etant donné que le mot à trouver est cucumber
  Quand le joueur propose la lettre u
  Alors le mot est -u-u----

La méta-information #language: fr en tête du fichier permet d’écrire les tests en français.

Chaque fonctionnalité est décrite par des scénarios dans son propre fichier portant l’extension .feature.

Note

Gherkin supporte les variantes de la forme Etant donné que, Etant donné qu', Etant donné… Vous pouvez également utiliser Mais (But) plutôt que Alors quand cela est syntaxiquement plus correct.

Pour une présentation complète du langage Gherkin, consultez la documentation officielle :

Automatiser les scénarios en Java

Pour poursuivre notre exemple du jeu du pendu, nous partirons du principe que les développeurs ont créé la classe JeuDuPendu que nous allons valider grâce à nos scénarios :

Le fichier JeuDuPendu.java
 1
 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
package dev.gayerie.jeu;

import java.util.Arrays;

public class JeuDuPendu {

    private char[] motSecret;
    private char[] lettresDecouvertes;

    /**
     * Crée une instance du jeu en donnant le mot à trouver.
     *
     * @param motSecret Le mot à découvrir
     */
    public JeuDuPendu(String motSecret) {
        this.motSecret = motSecret.toCharArray();
        this.lettresDecouvertes = new char[this.motSecret.length];
        Arrays.fill(this.lettresDecouvertes, '-');
    }

    /**
     * @param lettre La lettre que le joueur propose
     * @return le nombre d'occurrences de la lettre dans le mot
     */
    public int proposer(char lettre) {
        int nbOccurences = 0;
        for(int i = 0; i < motSecret.length; ++i) {
            if (motSecret[i] == lettre) {
                lettresDecouvertes[i] = lettre;
                nbOccurences++;
            }
        }
        return nbOccurences;
    }

    /**
     * @return Les lettres découvertes dans le mot.
     *         Les lettres inconnues sont remplacées par -
     *         Par exemple : bo-jo--
     */
    public String getLettresDecouvertes() {
        return String.valueOf(lettresDecouvertes);
    }
}

Projet de démo

Vous pouvez trouver un exemple plus complet en téléchargeant le projet cucumber-demo.zip qui contient tous les exemples de ce chapitre.

Pour automatiser les scénarios avec Cucumber, le plus simple est de les lancer avec Junit 4. Dans un projet Java géré avec Maven, on commence par ajouter les dépendances nécessaires :

Les dépendances Maven
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java</artifactId>
    <version>5.6.0</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit</artifactId>
    <version>5.6.0</version>
    <scope>test</scope>
</dependency>

Pour exécuter les scénarios, nous avons besoin :

  • Des scénarios écrits dans des fichiers texte avec l’exentension .feature,

  • D’une ou plusieurs classes contenant l’implémentation à exécuter pour chaque étape (step) d’un scénario,

  • D’une classe permettant de lancer les scénarios avec JUnit.

Les classes d’implémentation des étapes

Un scénario est découpé en étapes (steps). Chaque étape doit être associée à une méthode qui pourra être exécutée. Une telle méthode est publique et est annotée avec @Given, @When ou @Then. Même si ce n’est pas nécessaire, vous pouvez utiliser les annotations équivalentes dans la langue de vos scénarios. (par exemple, Cucumber fournit des annotations @Etantdonné, @Etantdonnéque, @Quand, @Alors).

Pour l’exemple du jeu du pendu, voici la classe d’implémentation des étapes :

La classe d’implémentation des étapes
 1
 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
package dev.gayerie.jeu;

import static org.junit.Assert.*;

import io.cucumber.java.fr.Alors;
import io.cucumber.java.fr.Etantdonnéque;
import io.cucumber.java.fr.Quand;

public class JeuDuPenduSteps {

    private JeuDuPendu jeuDuPendu;
    private int nbOccurencesDerniereLettreProposee;

    @Etantdonnéque("le mot à trouver est {word}")
    public void leMotATrouver(String mot) {
        jeuDuPendu = new JeuDuPendu(mot);
    }

    @Quand("le joueur propose la lettre {word}")
    public void proposerLettre(String lettre) {
        nbOccurencesDerniereLettreProposee = jeuDuPendu.proposer(lettre.charAt(0));
    }

    @Alors("la lettre est présente {int} fois")
    public void laLettreEstPresente(int nbFois) {
        assertEquals(nbFois, nbOccurencesDerniereLettreProposee);
    }

    @Alors("le mot est {}")
    public void leMotEst(String mot) {
        assertEquals(mot, jeuDuPendu.getLettresDecouvertes());
    }
}

Notez que ces méthodes utilisent les annotations pour préciser le texte de l’étape qu’elles représentent. Il est possible de paramétrer les étapes avec une notation de la forme {} donnant le type du paramètre.

Notation

Description

{int}

Le paramètre est un entier

{float}

Le paramètre est un nombre à virgule

{word}

Le paramètre est un mot sans espace

{string}

Le paramètre est une chaîne de caractères délimitée par des apostrophes ou des guillemets

{}

Le paramètre est une chaîne de caractères et correspond à n’importe quelle valeur

Par exemple, à la ligne 14 de la classe, on associe la méthode une étape de la forme le mot à trouver est {word}. Cucumber se chargera d’extraire le mot apparaissant dans le scénario pour le passer en paramètre de la méthode.

Note

Si vous préférez, vous pouvez associer les méthodes aux étapes en utilisant des expressions régulières. Les paramètres à extraire correspondent aux groupes définis dans votre expression.

Une classe définissant des méthodes d’étape agit le plus souvent comme une machine à état. Elle maintient en interne des attributs qui sont modifiés au fur et à mesure de l’appel aux méthodes. Remarquez que les méthodes annotées avec @Alors utilisent des assertions JUnit pour valider l’état courant.

Note

Pour allez plus loin, vous pouvez consulter la documentation officielle :

La classe de lancement JUnit

La classe de lancement JUnit est très simple à écrire car elle ne contient aucun test particulier. Grâce à l’annotation @RunWith, elle déclare que le moteur JUnit doit utiliser Cucumber pour se lancer. On peut également utiliser l’annotation @CucumberOptions pour paramétrer l’exécution des scénarios.

La classe de lancement avec JUnit
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package dev.gayerie.jeu;

import org.junit.runner.RunWith;

import io.cucumber.junit.Cucumber;
import io.cucumber.junit.CucumberOptions;

@RunWith(Cucumber.class)
@CucumberOptions(strict = true, monochrome = true)
public class JeuDuPenduTest {

}

Pour localiser les fichiers .feature et les classes fournissant l’implémentation des étapes, la règle par défaut est d’utiliser le même package que le package de la classe de lancement.

Pour l’exemple ci-dessus, toutes les classes dans le package dev.gayerie.jeu et définissant des étapes seront utilisées. Dans le répertoire Maven src/test/resources, tous les fichiers avec l’extension .feature présents dans le package dev.gayerie.jeu seront lus et exécutés.

Pour exécuter les scénarios associés, il suffit de lancer cette classe de test dans votre IDE ou de lancer la commande Maven :

mvn test

Intégration avec Selenium

Il est relativement simple d’intégrer Selenium dans l’implémentation des étapes. Cela permet d’écrire des scénarios impliquant l’utilisation de navigateur Web.

À titre d’illustration, nous allons reprendre l’exemple du Page Object Model vu au chapitre sur Selenium.

Le fichier DuckDuckGo.feature
#language: fr

Fonctionnalité: Référencement sur le moteur de recherche DuckDuckGo

Scénario: Le site de cucumber apparaît dans la première page de résultats de DuckDuckGo

  Etant donné que l'utilisateur est sur la page d'accueil de DuckDuckGo
  Quand il saisit le mot-clé cucumber
  Et qu'il lance la recherche
  Alors la page de résultats contient un lien sur le site https://cucumber.io

Scénario: Le site de selenium apparaît dans la première page de résultats de DuckDuckGo

  Etant donné que l'utilisateur est sur la page d'accueil de DuckDuckGo
  Quand il saisit le mot-clé selenium
  Et qu'il lance la recherche
  Alors la page de résultats contient un lien sur le site https://www.selenium.dev
La classe d’implémentation des étapes
package dev.gayerie.duckduckgo;

import static org.junit.Assert.*;

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;

import io.cucumber.java.After;
import io.cucumber.java.Before;
import io.cucumber.java.fr.Alors;
import io.cucumber.java.fr.Etantdonnéque;
import io.cucumber.java.fr.Quand;

public class DuckDuckGoSteps {

    private WebDriver webDriver;
    private HomePage homePage;
    private ResultPage resultPage;

    @Before
    public void createWebDriver() {
        webDriver = new ChromeDriver();
    }

    @After
    public void quitWebDriver() {
        webDriver.quit();
    }

    @Etantdonnéque("l'utilisateur est sur la page d'accueil de DuckDuckGo")
    public void utilisateurSurLaPageDAccueil() {
        homePage = HomePage.openWith(webDriver);
    }

    @Quand("il saisit le mot-clé {word}")
    public void ilSaisit(String motCle) {
        assertNotNull("La page de recherche n'est pas disponible", homePage);
        homePage.enterKeywords(motCle);
    }

    @Quand("il lance la recherche")
    public void ilLanceLaRecherche() {
        assertNotNull("La page de recherche n'est pas disponible", homePage);
        resultPage = homePage.clickOnSearch();
        homePage = null;
    }

    @Alors("la page de résultats contient un lien sur le site {}")
    public void laPageDeResultatContient(String site) {
        assertNotNull("La page de résultats n'est pas disponible", resultPage);
        assertTrue("Pas de résultat pour " + site, resultPage.isLinkPresent(site));
    }
}

L’API de Cucumber fournit les annotations @Before et @After pour déclarer des méthodes publiques qui seront appelées avant chaque scénario (respectivement après chaque scénario). Ainsi, il est possible de créer et de libérer correctement une instance de WebDriver.

La classe de lancement des scénarios est simplement :

La classe de lancement des scénarios
package dev.gayerie.duckduckgo;

import org.junit.runner.RunWith;

import io.cucumber.junit.Cucumber;
import io.cucumber.junit.CucumberOptions;

@RunWith(Cucumber.class)
@CucumberOptions(strict = true, monochrome = true)
public class DuckDuckGoAcceptanceTest {

}

Important

Notez que les classes DuckDuckGoSteps et DuckDuckGoAcceptanceTest appartiennent au même package. De même, le fichier DuckDuckGo.feature doit être placé dans le répertoire src/test/resources dans le package dev.gayerie.duckduckgo.

Pour aller plus loin

Cucumber dispose de nombreuses fonctionnalités pour permettre de s’adapter à des solutions complexes.

Le plan de scénario

Afin d’éviter de répéter des scénarios qui ne varient que sur leurs paramètres en entrée et leurs paramètres en sortie, il est possible de créer un plan de scénario (scenario outline ou scenario template) et de passer les valeurs à utiliser sous la forme d’un tableau d’exemple :

Exemple de plan de scénario
Plan du scénario: Connaître la position d'une lettre

  Etant donné que le mot à trouver est cucumber
  Quand le joueur propose la lettre <lettre>
  Alors le mot est <mot>

Exemples:

  | lettre |    mot   |
  |      c | c-c----- |
  |      u | -u-u---- |
  |      m | ----m--- |
  |      w | -------- |

Le plan ci-dessus permet d’exécuter quatre scénarios différents en remplaçant à chaque fois <lettre> et <mot> par leur valeur.

Le contexte

Le contexte (background) correspond à un ensemble d’étapes à réaliser avant tous les scénarios d’une fonctionnalité. Cela permet d’éviter de copier/coller des étapes dans chaque test qui n’apportent pas une réelle plus-value au lecteur.

Exemple de contexte
Fonctionnalité: Gérer les commandes

Contexte:
  Etant donné que l'utilisateur est loggé comme administrateur
  Et qu'il existe une commande en attente avec le numéro "XD-1234"

Scénario: Consulter la liste des commandes
  Quand l'utilisateur consulte la liste des commandes
  Alors il voit la commande avec le numéro "XD-1234"

Scénario: Editer une commande en attente
  Quand l'utilisateur consulte la liste des commandes
  Et qu'il édite la commande avec le numéro "XD-1234"
  Alors il peut annuler la commande

Les tags

Il est possible d’ajouter des tags sur des scénarios afin de filtrer leur exécution. Par exemple, une partie des scénarios ne sont peut-être pas automatisables pour des raisons de coûts ou de difficultés techniques. Il est possible de les marquer par un tag @manuel.

Exemple d’utilisation de tag
@manuel
Scenario: Gérer l'annulation d'une commande

  Etant donné qu'une commande n'a pas encore été livrée
  Quand l'utilisateur consulte la commande
  Et qu'il la supprime
  Alors le service commande reçoit une notification d'annulation

Dans la classe de lancement des scénarios, on peut spécifier de n’exécuter que les scénarios qui ne disposent pas du tag @manuel :

La classe de lancement avec JUnit
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package dev.gayerie.jeu;

import org.junit.runner.RunWith;

import io.cucumber.junit.Cucumber;
import io.cucumber.junit.CucumberOptions;

@RunWith(Cucumber.class)
@CucumberOptions(strict = true, monochrome = true, tags = "not @manuel")
public class JeuDuPenduTest {

}

Pour plus d’information, reportez-vous à la documentation sur les tags :

L’injection de dépendances

Créer des scénarios à l’échelle d’une application signifie créer un large panel d’étapes avec leur méthode Java associée. Il est plus simple de répartir ces méthodes dans plusieurs classes pour des questions de lisibilité et de maintenance. Il est alors courant qu’un même scénario requière l’utilisation d’étapes qui sont implémentées par différentes classes. Les instances de ces classes devront la plupart du temps partager un état. Pensez, par exemple, à un scénario impliquant une interface Web, il est nécessaire de partager au minimum l’instance du WebDriver Selenium qui permet de piloter le navigateur Web.

Cucumber propose des solutions d’injection de dépendances se basant sur différents frameworks : Spring, Guice, Pico Container…

Pour en savoir plus, reportez-vous à la documentation officielle :