Image de couverture de l'article Le BDD (Behavior Driven Development) avec Behat et Symfony
Retour aux articles

L'agence

WanadevStudio

Le BDD (Behavior Driven Development) avec Behat et Symfony

Le sujet des tests fonctionnels est une (longue) histoire chez Wanadev. Notre projet Kazaplan grossissant de plus en plus, nous sommes obligés d'adopter une politique de tests plus en plus stricte au fur et à mesure de l'avancement du projet.

Différents types de tests

On peut distinguer plusieurs types de tests dans le backoffice du projet Kazaplan :

  • Les tests unitaires, écrits grâce à PHPUnit. Nous utilisons intensément ceux-ci, et notre politique de couverture est assez simple. Nous ne visons pas le coverage de 100% (qui n'est pas toujours, voire rarement, une bonne idée). Cependant, l'intégralité de nos services sont testés. Nous excluons les DTO, VO, entités et autres classes sans logique. Par contre, lorsque nous parlons de classes de services, tout est testé ;
  • Les tests de mutations, réalisés par Infection. Cet outil est une surcouche à PHPUnit. Infection va modifier et casser le code source pour vérifier que nos tests réagissent correctement. En quelque sorte, ces tests vont tester nos tests. Nous y reviendrons dans un prochain article ;
  • Les tests fonctionnels avec PHPUnit et les WebTestCase de Symfony. L'intégralité de nos tests fonctionnels utilisaient cet outil jusqu'à récemment. Nous avons changé cela au profit de Behat. On considère donc que toute cette partie des tests est legacy et que plus personne ne doit y toucher. Bon, en réalité, nous y touchons encore un petit peu lorsqu'il s'agit de les maintenir et dans le cas de nos (rares) hotfixes. Mais sinon, interdiction d'en ajouter de nouveaux ;
  • Les tests fonctionnels avec Behat. C'est cet outil qui va nous intéresser dans cet article. L'intégralité de nos tests fonctionnels sont réalisés avec cet outil. Dès que nous en avons la possibilité et l'occasion, nous migrons les tests fonctionnels existants réalisés avec PHPUnit en tests Behat. Nous ne pouvons pas prévoir de migrer tous les tests fonctionnels réalisés avec PHPUnit de suite dû à leur nombre trop important.

Behat : le compagnon parfait pour vos tests fonctionnels

Behat est un framework PHP permettant d'écrire des tests en langage dit naturel. Ce langage s'appelle le langage Gherkin. Il a été créé pour écrire des scénarios de test qui peuvent être lus et compris par le plus grand nombre, sans compétences techniques. Plus fort que cela, l'idée serait même que les Product Owners soient en mesure d'écrire eux-mêmes les tests. Soyons honnêtes, cela reste assez rare. Par contre, soyez assurés que si votre PO écrit des scénarios de test dans leurs demandes en reprenant la nomenclature du langage Gherkin, vous gagnerez en efficacité. En plus de cela, ils sont d'excellents critères d'acceptance. C'est simple : si les tests donnés par le PO sont au vert, alors vous êtes sûr que vous respectez la demande. Tout le monde est gagnant !

Entrons dans le vif du sujet : à quoi ressemble donc ce fameux langage naturel ? Prenons un exemple de l'un de nos tests tout droit venu du code source de Kazaplan :

@api @user
Feature: User creation

  Scenario: I can create a user
    Given I'm logged in as "kazaplan@kazaplan.com"
    When I send a "POST" request to "/api/users/" with body:
    """
    {
      "email": "john.doe@wanadev.fr",
      "username": "john.doe",
      "plainPassword": "VeryStr0ngP4$$wOrD"
    }
    """
    Then the response status code is 201
    And the response JSON node "uuid" is a valid UUID

Effectivement, tous les détails techniques n'ont pas été cachés. On retrouve un payload, une URL, un code de retour... Mais avouez que c'est quand même très lisible en un seul coup d'œil. Bien plus que si le test avait été réalisé en PHP, dans une classe et une fonction de test.

Ce genre de test pourrait avoir été spécifié par un Product Owner en écrivant quelque chose comme :

Étant donné que je suis un utilisateur administrateur
Quand je vais sur la page de création d'un utilisateur
Et que je remplis le champ "email" avec "john.doe@wanadev.fr"
Et que je remplis le champ "username" avec "john.doe"
Et que je remplis le champ "password" avec "VeryStr0ngP4$$wOrD"
Alors j'ai un message de succès
Et je reçois l'identifiant de l'utilisateur

On se rend compte que la traduction de l'un à l'autre est aisée. L'écriture des tests sous ce format porte un nom : le Behaviour Driven Development, ou BDD.

Le fonctionnement interne

Maintenant que nous voyons à quoi ressemble un scénario Behat, une question est toujours en suspens : comment Behat interprète les étapes de nos scénarios ? Est-ce qu'il y a de la magie quelque part ?

En réalité, et vous vous en doutez, rien de magique. Behat va "simplement" faire correspondre chaque étape avec une fonction PHP. Cela se déroule grâce à des Contexts. Les Contexts sont des classes PHP qui contiennent la définition des étapes que nous utilisons. Nous allons d'ailleurs définir quelques étapes réutilisables dans une classe ApiContext :

namespace App\Tests\Behat;

use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\PyStringNode;
use Behat\Hook\AfterScenario;
use Behat\Hook\BeforeScenario;
use Behat\Step\Given;
use Behat\Step\Then;
use Behat\Step\When;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;

class ApiContext extends WebTestCase implements Context
{
    private static KernelBrowser $client;

    #[BeforeScenario]
    public function setUpClient(): void
    {
        static::$client = static::createClient();
    }

    #[AfterScenario]
    public function tearDownClient(): void
    {
        parent::tearDown();
    }

    #[When('I send a :method request to :url')]
    public function iSendARequest(string $method, string $url): void
    {
        static::$client->request($method, $url);
        static::$response = static::$client->getResponse();
    }

    #[When('I send a :method request to :url with body:')]
    public function iSendARequestWithBody(string $method, string $url, PyStringNode $node): void
    {
        static::$client->request($method, $url, content: (string) $node);
        static::$response = static::$client->getResponse();
    }

    #[Then('the response status code is :code')]
    public function theResponseStatusCodeIs(int $code): void
    {
        $this->assertResponseStatusCodeSame($code);
    }

    // ...
}

Nous utilisons la puissance des WebTestCase de Symfony pour disposer d'un client HTTP pour faire les requêtes à notre API. Il est écrit plus tôt dans l'article que nous arrêtions d'utiliser les WebTestCase pour nos tests fonctionnels. Ce n'est pas tout à fait vrai, disons plutôt que nous ne les utilisons plus "directement", le but est de s'en abstraire le plus possible.

Notre contexte est assez simple en l'état :

  • Avant chaque scénario, on initialise un client HTTP et on démarre notre application Symfony ;
  • Après chaque scénario, on fait le ménage. Par exemple, on peut vider les données en cache dans Redis ou flush tous les messages asynchrones qui pourraient rester dans nos transports ;
  • On définit trois étapes (très courtes) pour envoyer une requête, une requête avec un body et la vérification du status code qu'on nous renvoie.

À première vue, tout cela peut paraître un peu verbeux. Mais en réalité, ces étapes ne sont à écrire qu'une seule fois. Très vite, on se rend compte qu'on n'écrit plus aucune étape, mais qu'on utilise systématiquement les mêmes. Surtout lorsque l'on teste une API où les endpoints se ressemblent. Combinez à cela la puissance des IDE pour autocompléter vos étapes dans vos fichiers de scénarios, et l'écriture des tests devient très rapide.

En bonus : faire du Test Driven Development (en clair : écrire les tests avant le code) est très facile avec Behat. Vous écrivez d'abord à quoi ressemble l'endpoint, le body qu'il attend, ce qu'on s'attend à recevoir, et tout cela avant même d'écrire le code. Mais nous parlerons des mérites et avantages du TDD une autre fois !

Fixtures et transactions

Données de tests

Qui dit tests fonctionnels dit jeux de tests (ou fixtures). Alors oui, ce n'est pas toujours la partie la plus plaisante que d'écrire des fausses données. C'est pour cela qu'il est très important de s'y prendre tôt et de le faire au fur et à mesure. Vous ne voulez tout de même pas lancer vos tests sur des données de production...

Il existe plusieurs bibliothèques reconnues pour écrire des données de tests. Voici quelques-unes des plus connues :

  • zenstruck/foundry, c'est celle que nous utilisons pour Kazaplan ;
  • nelmio/alice, très répandue dans l'industrie. Cette bibliothèque a la particularité de permettre l'écriture des fixtures en YAML, ce qui rend la chose moins verbeuse que de les écrire en PHP ;
  • doctrine/data-fixtures, très reconnue aussi du fait de son utilisation avec Symfony et le DoctrineFixturesBundle.

Transactions

Quelle que soit la bibliothèque utilisée pour vos jeux de tests, il va être très important de prendre en compte la séparation nette de chacun de vos scénarios. Pourquoi ? Si vous testez votre API avec des endpoints pour créer, récupérer et supprimer des ressources, vous allez modifier et casser vos jeux de tests tout au long de l'exécution. Que se passe-t-il si le test d'un endpoint pour récupérer une ressource est appelé après la suppression de cette dernière ? Le test ne fonctionnera tout simplement plus.

Cela signifie que vous ne serez plus en mesure de lancer seulement une partie de vos tests alors que leur nombre grandit, car l'ordre d'exécution devient primordial. Si votre suite de tests met plusieurs heures à tourner, ce n'est pas idéal.

Pire que ça : ajouter des fixtures va devenir long et fastidieux. En ajoutant de nouvelles données, les ID de vos entités pourraient se décaler et être modifiés au fil des appels à votre API. Cela pose alors problème si vos tests sont basés sur des identifiants codés en dur (par exemple, en faisant un appel à /api/users/123). Le résultat : devoir corriger des dizaines, voire des centaines de tests à chaque fois que vous ajoutez une seule petite entité dans vos jeux de tests. Vous n'avez pas envie de ça.

Il existe une solution très simple, mise en place depuis le début sur les tests Behat de Kazaplan : dmaicher/doctrine-test-bundle. Cette extension Behat va :

  • Démarrer une nouvelle transaction de base de données avant chaque scénario Behat ;
  • Rollback la transaction après chaque scénario.

Cela veut dire que chaque scénario rend la base de données dans l'état dans lequel il l'a trouvée. Nos tests sont donc tous indépendants et peuvent être exécutés dans n'importe quel ordre. Magique !

Pour conclure

Aucune hésitation quant à la recommandation d'utiliser Behat. Nos tests fonctionnels ont pris une nouvelle dimension depuis que nous l'utilisons. Écrire des tests fonctionnels n'est plus une corvée et devient très naturel. Les développeurs front-end sont en mesure de lire nos tests fonctionnels sans avoir besoin d'une seule connaissance en PHP. Cela constitue en quelque sorte une belle documentation avec des exemples de payload, de codes de retours, etc. En conjonction avec un Swagger, les développeurs front-end disposent d'énormément d'éléments pour utiliser notre API le plus facilement et rapidement possible.

Une dernière chose à garder en tête est la forte utilisation de Behat dans l'industrie. Au même titre que PHPUnit, Behat fait partie de ces outils qu'un développeur PHP doit connaître, au moins de nom et sur le principe.

Commentaires

Il n'y a actuellement aucun commentaire. Soyez le premier !