[Tutoriel] Test Behat sur un projet Symfony

Table des matières
But de ce tutoriel
Après vous avoir présenté le design pattern PageObject dans un précédent billet de blog, nous allons voir ensemble comment le mettre en pratique sur un projet Web.
Ici nous vérifions que toutes les briques de l'application fonctionnent ensemble correctement. Nous allons utiliser le driver Symfony de Mink pour permettre la simulation des requêtes HTTP et la récupération des réponses.
J'ai utilisé ce projet pour écrire ce billet de blog.
Installation
Nous allons avoir besoin de plusieurs dépendances uniquement pour le développement.
La première est Behat lui-même. Ensuite Doctrine data fixtures permettra de réinitialiser la base de données entre chaque scenarii.
Puis viennent les extensions pour Behat telles que MinkExtension et SymfonyExtension. Mink BrowserKit Driver est requise par MinkExtension sans être requise dans son composer.json
.
$ composer require --dev behat/behat doctrine/data-fixtures friends-of-behat/mink-browserkit-driver friends-of-behat/mink-extension friends-of-behat/page-object-extension friends-of-behat/symfony-extension
Si vous êtes sur la branche master du projet, Flex vous demandera la confirmation de l'application de la recette du packet friends-of-behat/symfony-extension
. Il est préférable d'accepter.
Config
Le fichier behat.yml.dist
a été ajouté à la racine du projet.
Modifier son contenu pour qu'il ressemble à celui-ci :
default:
suites:
default:
contexts:
- App\Behat\DemoContext
extensions:
Behat\MinkExtension:
base_url: "https://localhost/"
default_session: symfony
sessions:
symfony:
symfony: ~
FriendsOfBehat\SymfonyExtension: null
Ici nous activons et configurons les extensions installées précédemment.
Pour permettre la suite des opérations, nous avons besoin de modifier le fichier config/services_test.yaml
.
Ce fichier contient la configuration des services spécifiques à l'environnement de test.
Nous y définissons des alias, dont celui du générateur de code de double authentification pour le permettre d'être prévisible.
services:
_defaults:
autowire: true
autoconfigure: true
Doctrine\Persistence\ObjectManager: '@doctrine.orm.default_entity_manager'
App\Security\CodeGeneratorInterface: '@App\Tests\Service\DefinedCodeGenerator'
App\Tests\Service\:
resource: '../tests/Service/*'
App\Tests\Functional\:
resource: '../tests/Functional/*'
exclude: '../tests/Functional/{Page}'
App\Tests\Functional\Page\:
resource: '../tests/Functional/Page/*'
parent: 'FriendsOfBehat\PageObjectExtension\Page\SymfonyPage'
autoconfigure: false
public: false
autowire: true
FriendsOfBehat\PageObjectExtension\Page\Page:
abstract: true
autoconfigure: false
public: false
autowire: true
arguments:
- '@behat.mink.default_session'
- '@behat.mink.parameters'
FriendsOfBehat\PageObjectExtension\Page\SymfonyPage:
abstract: true
autoconfigure: false
public: false
autowire: true
parent: 'FriendsOfBehat\PageObjectExtension\Page\Page'
arguments:
- '@router'
Comme vous pouvez le voir, deux services abstraits sont définis pour les PageObject. Le service SymfonyPage sera utilisé par la configuration de tous les services présents dans le dossier tests/Functional/Page
.
Notre projet est configuré.
Initialiser l'arborescence
Comme expliqué dans mon premier blog post’ sur le sujet, nous allons ajouter les dossiers qui contiendront les contextes et les PageObject.
tests
├── bootstrap.php
├── Functional
│ ├── Context
│ │ ├── Task
│ │ └── TodoList
│ ├── Hook
│ ├── Page
│ │ ├── Task
│ │ └── TodoList
│ └── Setup
└── Service
Dans le cas de mon projet, j'ai besoin de modifier le comportement du générateur de code de double authentification pour le rendre prévisible. C'est pour cela que le dossier Service
a été ajouté.
Implémenter le service de génération de code
Dans le cas des tests, l'utilisation du hasard n'est pas possible. C'est pourquoi nous allons réimplémenter une interface pour définir un service spécifique au fonctionnement des tests.
Ajouter le fichier tests/Service/DefinedCodeGenerator.php
avec son contenu :
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Security\CodeGeneratorInterface;
class DefinedCodeGenerator implements CodeGeneratorInterface
{
public function generate(): string
{
return '1234';
}
}
Ajouter les scenarii de test
Comme les tests utilisent le langage naturel, il est préférable de commencer par l'écriture des tests.
Ajouter le fichier features/Login.feature
avec le contenu suivant :
#language: fr
Fonctionnalité: Connexion à l'application
Contexte:
Etant donné que l'utilisateur "todo@me.fr" est enregistré avec le mot de passe "MonSuperPassWord"
Scénario: Connexion
Etant donné que je suis sur la page de connexion
Lorsque je me connecte en tant que "todo@me.fr" avec le mot de passe "MonSuperPassWord"
Alors je dois être sur la page de double authentification
Lorsque je saisis le code de double authentification
Alors je dois être sur la liste des todo listes
Supprimer le fichier demo.feature
.
Aucune des phrases utilisées dans le scénario n'est connue de Behat.
Contexte d'initialisation
La section Contexte:
de la fonctionnalité permet la réalisation d'action avant chaque scénario. Cela permet d'éviter les répétitions en début de scénario.
La seule phrase présente permet d'ajouter un utilisateur et son mot de passe dans la base de données.
Comme il s'agit de mise en place des données utiles aux tests, nous sommes donc dans un contexte Setup
et, comme cela concerne un utilisateur, nous allons donc ajouter le UserContext
.
Ajouter le fichier tests/Functional/Setup/UserContext.php
avec son contenu :
<?php declare(strict_types=1);
namespace App\Tests\Functional\Setup;
use App\Entity\User;
use Behat\Behat\Context\Context;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
class UserContext implements Context
{
/**
* @var ObjectManager
*/
private $objectManager;
/**
* @var EncoderFactoryInterface
*/
private $encoderFactory;
public function __construct(ObjectManager $objectManager, EncoderFactoryInterface $encoderFactory)
{
$this->objectManager = $objectManager;
$this->encoderFactory = $encoderFactory;
}
/**
* @Given /^l'utilisateur "([^"]+)" est enregsitré avec le mot de passe "([^"]+)"$/
*/
public function registerUser($email, $password)
{
$user = new User();
$user->setEmail($email);
$user->setPassword($this->encoderFactory->getEncoder($user)->encodePassword($password, null));
$this->objectManager->persist($user);
$this->objectManager->flush();
}
}
La phrase est maintenant définie grâce à une expression rationnelle ayant des parenthèses capturant là où nous souhaitons récupérer des informations.
Pour permettre l'utilisation de cette phrase dans les scénarii de test, il est nécessaire d'ajouter le namespace de cette classe dans la configuration de Behat :
default:
suites:
default:
contexts:
- App\Tests\Functional\Setup\UserContext
Maintenant, l'utilisateur sera ajouté avant l'exécution du scénario.
Contexte d'action
Pour les autres actions, nous allons définir un contexte par page. Cela permet de garder les choses rangées.
Les contextes seront enrichis au fur et à mesure que les tests seront écrits.
Action sur la page de connexion
Dans ce contexte, deux phrases seront définies : celle ouvrant la page de connexion et celle réalisant la connexion d'un utilisateur.
Voici le contenu de ce contexte :
// Fichier : tests/Functional/Context/LoginContext.php
<?php declare(strict_types=1);
namespace App\Tests\Functional\Context;
use App\Tests\Functional\Page\LoginPage;
use Behat\Behat\Context\Context;
class LoginContext implements Context
{
/**
* @var LoginPage
*/
private $loginPage;
public function __construct(LoginPage $loginPage)
{
$this->loginPage = $loginPage;
}
/**
* @Given je suis sur la page de connexion
*/
public function jeSuisSurLaPageDeConnexion()
{
$this->loginPage->open();
}
/**
* @When /^je me connecte en tant que "([^"]+)" avec le mot de passe "([^"]+)"$/
*/
public function connexion($email, $password)
{
$this->loginPage->login($email, $password);
}
}
Vous voyez ainsi que le contexte ne contient pas beaucoup de code. Il ressemble à un adaptateur.
Voici le service PageObjet qui est implémenté dans la classe LoginPage
:
// Fichier : tests/Functional/Page/LoginPage.php
<?php declare(strict_types=1);
namespace App\Tests\Functional\Page;
use FriendsOfBehat\PageObjectExtension\Page\SymfonyPage;
class LoginPage extends SymfonyPage
{
public function getRouteName(): string
{
return 'app_login';
}
public function login($user, $password)
{
$this->open();
$this->getDocument()->fillField('email', $user);
$this->getDocument()->fillField('password', $password);
$this->getDocument()->pressButton('Sign in');
}
}
Ici également, il n'y a pas beaucoup de code. La fonction open
est gérée par la classe SymfonyPage
.
La méthode login
ouvre la page de login, renseigne le formulaire de connexion puis le valide.
Pour activer ce contexte, ajoutons-le à la configuration de Behat.
# fichier : behat.yml.dist
default:
suites:
default:
contexts:
- App\Tests\Functional\Context\LoginContext
Action sur la page du second facteur
Maintenant que nous avons réalisé la prise en charge de la page de connexion, il est maintenant nécessaire de gérer la page de la double authentification.
Comme il s'agit d'une nouvelle page, nous allons donc ajouter un nouveau contexte et un nouveau service PageObjet étendant SymfonyPage
.
// Fichier : tests/Functional/Context/TwoFactorContext.php
<?php declare(strict_types=1);
namespace App\Tests\Functional\Context;
use App\Tests\Functional\Page\TwoFactorPage;
use Behat\Behat\Context\Context;
class TwoFactorContext implements Context
{
/**
* @var TwoFactorPage
*/
private $twoFactorPage;
public function __construct(TwoFactorPage $twoFactorPage)
{
$this->twoFactorPage = $twoFactorPage;
}
/**
* @When je saisis le code de double authentification
*/
public function saisieDoubleAuth()
{
$this->twoFactorPage->sendCode('1234');
}
/**
* @Then je dois être sur la page de double authentification
*/
public function verifPageTwoFactor()
{
$this->twoFactorPage->verify();
}
}
// Fichier : tests/Functional/Page/TwoFactorPage.php
<?php declare(strict_types=1);
namespace App\Tests\Functional\Page;
use FriendsOfBehat\PageObjectExtension\Page\SymfonyPage;
class TwoFactorPage extends SymfonyPage
{
public function getRouteName(): string
{
return 'app_two_factor';
}
public function sendCode($code)
{
$this->verify();
$this->getDocument()->fillField('password', $code);
$this->getDocument()->pressButton('Send code');
}
}
Également, ce contexte doit être ajouté dans la configuration de Behat pour être utilisable :
# fichier : behat.yml.dist
default:
suites:
default:
contexts:
- App\Tests\Functional\Context\LoginContext
- App\Tests\Functional\Context\TwoFactorContext
Action sur la liste des ToDo listes
Maintenant, il reste une phrase non définie dans le scénario. Elle sera définie dans le contexte lié aux listes de ToDo Liste.
Comme pour les deux contextes précédents, nous en ajouterons un troisième IndexContext
avec son PageObject IndexPage
. Pour ne pas les confondre avec les autres, ils sont placés dans un dossier dédié aux fonctionnalités liées aux ToDo Listes.
// Fichier : tests/Functional/Context/TodoList/IndexContext.php
<?php declare(strict_types=1);
namespace App\Tests\Functional\Context\TodoList;
use App\Tests\Functional\Page\TodoList\IndexPage;
use Behat\Behat\Context\Context;
class IndexContext implements Context
{
/**
* @var IndexPage
*/
private $indexPage;
public function __construct(IndexPage $indexPage)
{
$this->indexPage = $indexPage;
}
/**
* @Then je dois être sur la liste des todo listes
*/
public function jeDoisEtreSurLaListeDesTodoListes()
{
$this->indexPage->verify();
}
}
Même si l'objet page contient peu de code, il est là pour disposer des fonctions prédéfinies par la classe SymfonyPage
.
// Fichier : tests/Functional/Page/TodoList/IndexPage.php
<?php declare(strict_types=1);
namespace App\Tests\Functional\Page\TodoList;
use FriendsOfBehat\PageObjectExtension\Page\SymfonyPage;
class IndexPage extends SymfonyPage
{
public function getRouteName(): string
{
return 'todo_list_index';
}
}
Également, ce contexte doit être ajouté dans la configuration de Behat pour être utilisable :
# fichier : behat.yml.dist
default:
suites:
default:
contexts:
- App\Tests\Functional\Context\LoginContext
- App\Tests\Functional\Context\TwoFactorContext
- App\Tests\Functional\Context\TodoListIndexContext
Contexte avant chaque scénario
Nous n'avons pour l'instant qu'un seul test. Le besoin de réinitialiser la base de données ne se fera sentir que lors de la prochaine exécution.
En effet, l'utilisateur existant déjà en base de données, une erreur SQL sera émise lors de la tentative d'ajout de l'utilisateur en base.
Pour éviter cela, il est possible d'exécuter du code automatiquement avant chaque scénario.
Ajouter le fichier tests/Functional/Hook/DoctrineContext.php
avec son contenu :
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Hook;
use Behat\Behat\Context\Context;
use Doctrine\Common\DataFixtures\Purger\ORMPurger;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineContext implements Context
{
/** @var EntityManagerInterface */
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @BeforeScenario
*/
public function purgeDatabase()
{
$this->entityManager->getConnection()->getConfiguration()->setSQLLogger(null);
$purger = new ORMPurger($this->entityManager);
$purger->purge();
$this->entityManager->clear();
}
}
Pour l'activer, il est nécessaire d'ajouter ce contexte aux autres dans la configuration de Behat behat.yml.dist
.
default:
suites:
default:
contexts:
- App\Tests\Functional\Hook\DoctrineContext
L'annotation @BeforeScenario
indique à Behat qu'il faut exécuter cette fonction avant tous les scénarii.
Conclusion
Nous avons ajouté des contextes, configuré les services utiles pour les tests, configuré Behat, écrit le scénario de test et les définitions de chaque phrase. Il est maintenant possible d'exécuter les tests et de poursuivre l'écriture des scénarii.
Cette petite mise en pratique du design pattern PageObjet ne montre malheureusement pas tout son potentiel. En transposant ce procédé sur vos projets et vos besoins métier, vous pouvez entrevoir le potentiel d'une telle solution.
Je n'exclus pas la réalisation d'un ou plusieurs autres billets de blog sur le sujet pour montrer le potentiel de ce design pattern.
Dites-moi dans les commentaires si vous avez déjà utilisé ce pattern sur vos projets. Je suis à votre service si vous avez des questions.
Mise à jour du 26 janvier 2021: Les antislash des noms de classe ont été remis.