Tests et qualité logicielle

Le behaviour-driven development avec Cucumber et Gherkin

Intermédiaire8 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Le BDD est une façon de s'accorder sur ce qu'il faut construire avant que quiconque n'écrive du code ; Cucumber est une manière de consigner cet accord pour qu'il reste vrai. Ce guide couvre la conversation de découverte, la syntaxe Gherkin en détail, le branchement des scénarios sur des step definitions Java, et les dérives qui transforment une suite de features en fardeau de maintenance.

Ce que vous allez apprendre

  • Animer une conversation de découverte qui produit des exemples concrets avant que le code n'existe
  • Écrire du Gherkin qui décrit le comportement métier plutôt que les étapes de l'interface utilisateur
  • Implémenter des step definitions en Java et partager l'état entre elles en toute sécurité
  • Utiliser Scenario Outline, Background, les tags et les hooks sans dupliquer de scénarios
  • Reconnaître les anti-patterns qui rendent une suite Cucumber lente, fragile et jamais lue

La plupart des défauts qui comptent ne sont pas des erreurs de code. Ce sont des malentendus : le développeur a construit ce que disait le ticket, le ticket disait ce que l'analyste avait compris, et l'analyste avait compris quelque chose de légèrement différent de ce que voulait le métier. Le behaviour-driven development est un ensemble de pratiques qui vise précisément cet écart, et sa technique centrale est d'une simplicité désarmante : avant d'écrire du code, réunir dans une même conversation des personnes aux points de vue différents et les obliger à s'accorder sur des exemples concrets.

Cucumber n'intervient qu'ensuite. Il reprend ces exemples, rédigés dans un langage naturel structuré appelé Gherkin, et les exécute contre le système pour que l'accord ne puisse pas se dégrader en silence. Cette étape d'automatisation est réellement utile, mais c'est aussi celle que l'on adopte le plus volontiers sans la conversation — et c'est ainsi que des équipes se retrouvent avec un millier de fichiers feature lents et fragiles que personne hors de l'équipe n'a jamais ouverts.

La découverte avant l'automatisation

La conversation a une forme. Prenez une user story, réunissez autour d'elle une personne du produit, un développeur et un testeur pendant une demi-heure, et déroulez-la par example mapping : la story sur une carte, chaque règle métier qu'elle implique sur une autre, des exemples concrets sous chaque règle, et les questions auxquelles personne dans la pièce ne sait répondre sur une carte d'une quatrième couleur.

Ce qui en ressort, ce sont rarement les exemples. Ce sont les questions. « Que se passe-t-il si le client est membre premium et que la commande est internationale ? » « Un remboursement partiel remet-il à zéro le délai de retour ? » Chaque question soulevée dans cette pièce est un défaut qui ne sera jamais écrit, et le nombre de cartes questions est un assez bon indicateur de la maturité de la story : aucune signifie généralement que les règles n'ont pas été examinées ; beaucoup signifie que la story doit repartir pour être découpée ou clarifiée.

Les exemples qui survivent deviennent les critères d'acceptation. Certains sont automatisés sous forme de scénarios ; beaucoup sont mieux couverts par un test unitaire ; quelques-uns décrivent des choses qui ne valent pas la peine d'être automatisées. Faire le tri relève du jugement, et se tromper dans le sens du « tout automatiser avec Cucumber » est la façon la plus courante dont cette pratique échoue.

Gherkin, en détail

Un fichier .feature est du texte brut avec un petit jeu de mots-clés. Chaque fichier contient une feature ; chaque scénario est un exemple du comportement.

Feature: Free shipping thresholds
  So that customers are encouraged to fill their basket,
  orders qualify for free shipping under published rules.

  Background:
    Given the free shipping threshold is 50 EUR

  Scenario: An order above the threshold ships free
    Given a basket containing:
      | product   | quantity | unit price |
      | Notebook  | 2        | 24.00      |
      | Pencil    | 1        | 3.50       |
    When the customer requests domestic delivery
    Then the shipping cost is 0.00 EUR

  Scenario: An order below the threshold pays standard shipping
    Given a basket containing:
      | product   | quantity | unit price |
      | Pencil    | 4        | 3.50       |
    When the customer requests domestic delivery
    Then the shipping cost is 4.90 EUR

  Scenario Outline: Premium members ship free regardless of basket value
    Given a <tier> customer with a basket worth <value> EUR
    When the customer requests domestic delivery
    Then the shipping cost is <cost> EUR

    Examples:
      | tier     | value | cost |
      | premium  | 10.00 | 0.00 |
      | premium  | 60.00 | 0.00 |
      | standard | 10.00 | 4.90 |
      | standard | 60.00 | 0.00 |

Chaque mot-clé a un rôle précis. Given établit le contexte — un état qui existe déjà, jamais une action de l'utilisateur. When est l'unique événement testé ; un scénario avec trois étapes When est en réalité trois scénarios, ou un événement mal choisi. Then vérifie un résultat observable, et observable par l'acteur du scénario : « alors la ligne de la table orders a le statut PAID » est une assertion de développeur déguisée en Gherkin. And et But prolongent le mot-clé précédent et n'existent que pour la lisibilité.

Background regroupe les étapes Given communes à tous les scénarios du fichier. On en abuse facilement : un background de huit étapes impose à chaque scénario un contexte dont la plupart n'ont pas besoin, et le lecteur doit tout garder en tête. Limitez-le à une ou deux étapes de contexte réellement universel.

Scenario Outline, avec une table Examples, exécute le même scénario une fois par ligne — le bon outil quand une règle a plusieurs cas illustratifs, et le mauvais pour énumérer vingt valeurs limites, qui relèvent d'un test unitaire paramétré.

Les data tables (le bloc délimité par des barres verticales sous une étape) et les doc strings (du texte entre triples guillemets) transmettent des arguments structurés à une étape. Les tags (@smoke, @slow, @wip) placés au-dessus d'une feature ou d'un scénario sélectionnent ce qui s'exécute ; le pipeline peut alors lancer @smoke à chaque commit et l'ensemble chaque nuit.

Brancher les scénarios sur Java

Cucumber associe chaque ligne d'étape à des méthodes annotées. La syntaxe actuelle repose sur les Cucumber Expressions, bien plus lisibles que les expressions régulières des projets plus anciens :

public class ShippingSteps {

    private final ShoppingContext context;      // shared state, injected

    public ShippingSteps(ShoppingContext context) {
        this.context = context;
    }

    @Given("the free shipping threshold is {bigdecimal} EUR")
    public void theThresholdIs(BigDecimal threshold) {
        context.pricing().setFreeShippingThreshold(Money.of(threshold, "EUR"));
    }

    @Given("a basket containing:")
    public void aBasketContaining(DataTable table) {
        for (Map<String, String> row : table.asMaps()) {
            context.basket().add(
                    row.get("product"),
                    Integer.parseInt(row.get("quantity")),
                    Money.of(row.get("unit price"), "EUR"));
        }
    }

    @Given("a {word} customer with a basket worth {bigdecimal} EUR")
    public void aCustomerWithABasketWorth(String tier, BigDecimal value) {
        context.setCustomer(new Customer(Tier.valueOf(tier.toUpperCase(Locale.ROOT))));
        context.basket().add("Assorted", 1, Money.of(value, "EUR"));
    }

    @When("the customer requests {word} delivery")
    public void theCustomerRequestsDelivery(String destination) {
        context.setQuote(context.shipping().quote(
                context.basket(), context.customer(), Destination.valueOf(destination.toUpperCase(Locale.ROOT))));
    }

    @Then("the shipping cost is {bigdecimal} EUR")
    public void theShippingCostIs(BigDecimal expected) {
        assertThat(context.quote().shippingCost()).isEqualTo(Money.of(expected, "EUR"));
    }
}

Les types de paramètres intégrés couvrent {int}, {word}, {string}, {float} et {bigdecimal} ; vous déclarez les vôtres avec @ParameterType pour que {money} ou {tier} arrive sous forme d'objet métier plutôt que de chaîne, ce qui élimine l'essentiel du bruit de parsing dans les méthodes d'étape.

Sous JUnit 5, le runner s'appuie sur le moteur de la plateforme, configuré soit via l'API de suite, soit via junit-platform.properties :

@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.example.shop.acceptance")
@ConfigurationParameter(key = FILTER_TAGS_PROPERTY_NAME, value = "not @wip")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty, html:target/cucumber-report.html")
public class RunAcceptanceTests {
}

Partager l'état sans champ statique

Les step definitions vivent dans des classes différentes, et l'étape When a besoin de ce que l'étape Given a créé. La mauvaise réponse — des champs statiques — casse l'exécution parallèle et laisse l'état fuiter d'un scénario à l'autre. La réponse de Cucumber est l'injection de dépendances : ajoutez l'un des modules cucumber-picocontainer, cucumber-spring ou cucumber-guice, et les classes de step definitions bénéficient de l'injection par constructeur avec un graphe d'objets neuf pour chaque scénario.

public class ShoppingContext {          // one instance per scenario
    private final Basket basket = new Basket();
    private final PricingConfig pricing = new PricingConfig();
    private final ShippingService shipping = new ShippingService(pricing);
    private Customer customer = Customer.anonymous();
    private ShippingQuote quote;
    // accessors omitted
}

Avec cucumber-spring, annoter une classe de glue avec @CucumberContextConfiguration et @SpringBootTest place tout le contexte applicatif derrière les étapes et fait de chaque scénario une véritable exécution de bout en bout ; les compromis sont les mêmes que ceux décrits dans le guide des tests Spring Boot. Les hooks (@Before, @After, et leurs variantes taguées comme @Before("@database")) gèrent la préparation et le nettoyage propres à chaque scénario, et @After reçoit l'objet Scenario, ce qui permet d'attacher une capture d'écran ou un corps de réponse au rapport quand un scénario échoue.

Le déclaratif l'emporte sur l'impératif

Le facteur qui détermine le plus sûrement la survie d'une suite Cucumber est le niveau d'abstraction des étapes. Comparez :

# Imperative — describes the interface, not the rule
Scenario: Discount
  Given I open "/login"
  And I type "[email protected]" into the field "#email"
  And I type "hunter2" into the field "#password"
  And I click the button "#signin"
  And I navigate to "/products/SKU-1"
  And I click "Add to basket"
  Then the element "#total" contains the text "45.00"
# Declarative — describes the rule, readable by anyone
Scenario: Members get 10% off their first order
  Given Ada is a signed-in member with no previous orders
  When she adds the 50 EUR notebook to her basket
  Then her order total is 45.00 EUR

La version impérative casse dès qu'un designer renomme une classe CSS, n'apprend rien au lecteur sur la règle métier et ne peut s'exécuter que dans un navigateur. La version déclarative survit aux refontes, énonce la règle qu'elle protège et peut être branchée sur un appel de service qui s'exécute en quelques millisecondes. Toute étape qui contient un sélecteur, un verbe HTTP, un nom de table ou un identifiant de champ est candidate à une réécriture un niveau plus haut.

Cela dit, le branchement doit rester honnête. Une étape intitulée « elle ajoute le carnet à son panier » doit emprunter le même chemin de code que l'application, et non glisser une ligne directement en base. Des scénarios qui simulent le travail qu'ils prétendent effectuer sont pires que pas de scénario du tout, car ils annoncent un succès pour un chemin qui n'a jamais été exercé.

La documentation vivante

C'est côté sortie que le BDD se distingue le plus des tests ordinaires. Le rapport HTML ou JSON de Cucumber, publié par le pipeline, affiche chaque scénario avec son statut ; comme il est généré à partir des fichiers mêmes sur lesquels l'équipe s'est accordée, il ne peut pas diverger du code comme le fait une page de wiki. Les fichiers feature vivent sous contrôle de version à côté du code qu'ils décrivent : un changement de comportement et le changement de sa documentation arrivent dans le même commit et la même revue.

Pour que cela ait la moindre valeur, quelqu'un hors de l'équipe de développement doit effectivement le lire. Si personne n'ouvre jamais les rapports, la couche Gherkin n'est que du surcoût, et la même couverture reviendrait moins cher sous forme de simples tests JUnit — une conclusion légitime, qu'il vaut mieux tirer tôt qu'après deux ans d'accumulation de fichiers feature.

Anti-patterns

Du Gherkin piloté par l'interface. Déjà évoqué plus haut ; c'est le plus courant et le plus dommageable.

L'explosion de scénarios. Vingt scénarios qui énumèrent les combinaisons de deux flags. Automatisez un exemple illustratif par règle et reléguez les combinaisons dans une table de décision au niveau unitaire, comme le décrit le guide des fondamentaux du test.

Les étapes à conjonctions. Given the customer is registered and has a valid card and lives in France cache trois faits dans une seule étape et n'est pas réutilisable. Découpez-la, ou introduisez un concept métier qui les regroupe légitimement.

Les étapes Then techniques. Vérifier des lignes en base, des lignes de log ou des chemins JSON ramène le scénario au niveau du développeur et le rend illisible pour le public auquel il est destiné.

L'état mutable partagé entre scénarios. Les scénarios doivent être indépendants et exécutables dans n'importe quel ordre, ou en parallèle. Un scénario qui dépend de données créées par le précédent échouera mystérieusement la première fois que quelqu'un filtrera par tag.

Des step definitions qui contiennent de la logique. Boucles, conditions et calculs dans une étape signifient que le test calcule le résultat attendu au lieu de l'énoncer. Laissez l'arithmétique dans la table d'exemples du fichier feature.

Du Gherkin écrit par les seuls développeurs. Ce n'est alors qu'un test JUnit verbeux avec une indirection de plus. Impliquez les lecteurs métier, ou supprimez la couche.

Points clés à retenir

  • La valeur du BDD se concentre dans la conversation de découverte ; l'example mapping fait émerger les questions qui deviendraient sinon des défauts.
  • Les mots-clés Gherkin ont des rôles précis : Given est le contexte, When un événement unique, Then un résultat que l'acteur peut observer.
  • Scenario Outline couvre une poignée de cas illustratifs ; les limites exhaustives relèvent de tests unitaires rapides, pas des fichiers feature.
  • Partagez l'état entre step definitions par injection par constructeur avec un objet de contexte propre à chaque scénario, jamais via des champs statiques.
  • Écrivez les scénarios autour des règles métier et branchez-les sous l'interface utilisateur ; un Gherkin impératif, clic par clic, est lent, fragile et illisible.
  • N'adoptez Cucumber que si des non-développeurs lisent ou écrivent les scénarios — sinon, du JUnit classique offre la même couverture avec moins de machinerie.

Sources et lectures complémentaires

Questions fréquentes

Le BDD, est-ce simplement du TDD avec d'autres mots-clés ?
Les deux partagent le même rythme « de l'extérieur vers l'intérieur », mais le centre de gravité du BDD est la conversation qui précède le code. Ses pratiques — example mapping, spécification par l'exemple, vocabulaire partagé — servent à faire émerger les malentendus entre produit, développement et test tant que modifier l'exigence coûte encore peu. Automatiser les exemples convenus avec Cucumber est la dernière étape, et la moins essentielle : l'essentiel de la valeur vient de la discussion, même si rien n'est automatisé.
Faut-il Cucumber, ou peut-on écrire les tests d'acceptation en JUnit classique ?
N'adoptez Cucumber que si des non-développeurs lisent ou écrivent réellement les scénarios. La couche Gherkin ajoute une indirection — glue code, expressions régulières, état partagé — et c'est une perte sèche quand les seuls lecteurs sont les développeurs qui ont écrit le code. De simples tests JUnit aux méthodes bien nommées, avec un vocabulaire métier construit sur AssertJ, offrent la même couverture avec moins de machinerie.
Les scénarios doivent-ils piloter l'interface utilisateur ?
En général, non. Des scénarios formulés en clics et en noms de champs cassent à chaque changement de mise en page et s'exécutent lentement dans un navigateur. Écrivez les scénarios autour des règles métier et branchez-les sur un service ou une API HTTP sous l'interface, puis couvrez l'interface elle-même avec quelques parcours end-to-end dédiés. Les fichiers feature restent lisibles et la suite assez rapide pour tourner à chaque commit.
Combien de scénarios une feature doit-elle contenir ?
Assez peu pour que quelqu'un les lise tous. Une feature qui dépasse une douzaine de scénarios mélange généralement des règles qui mériteraient des fichiers distincts, ou énumère des combinaisons qui relèvent d'un test unitaire paramétré. Réservez les scénarios aux exemples illustratifs qui tranchent un désaccord sur la règle, et couvrez les cas limites exhaustifs au niveau unitaire, où ils s'exécutent en quelques millisecondes.