Alexandre Salomé
Alexandre est consultant Symfony2 chez SensioLabs.
Vous pouvez le retrouver sur twitter: @alexandresalome.
Behat est un outil de BDD qu'on ne présente plus : http://behat.org
Grâce à cet outil et à des extensions telles que Mink, il est possible d'écrire un scénario de test très simple, compréhensible par un humain :
Quand je vais sur "/contact"
Et que je remplis :
| Nom | Alexandre Salomé |
| Sujet | Prise de contact |
| Message | Message de contact |
Et je clique sur "Envoyer"
Alors je vois "Votre message a été envoyé"
On implémente ensuite ces phrases avec des méthodes PHP, pour exécuter le test :
/**
* @When /^je clique sur "(.*)"$/
*/
public function iClickOn($text)
{
$button = $this->browser->findClickable($text);
$button->click();
}
Autant le concept est assez simple et très efficace, autant le choix de ces phrases n'est pas simple.
Bien souvent, en PHP, on développe des sites Web. Et qui dit sites Web dit navigateur Internet.
Behat s'interface avec une librairie appelée Mink, permettant de manipuler un navigateur Web de manière naturelle :
Grâce à ces 5 phrases, vous pouvez déjà tester une très grande partie de l'Internet. Par exemple, si vous voulez vérifier que votre site n'affiche pas une page blanche :
Quand je vais sur "http://afsy.fr"
Alors je vois "Symfony"
Et si vous voulez faire un test pour vérifier que votre site apparaît bien les dix premiers résultats de Google :
Quand je vais sur "http://google.fr"
Et que je remplis :
| q | Afsy |
Et je clique sur "Recherche Google"
Alors je vois "Association Francophone des utilisateurs de Symfony"
Lorsqu'on utilise Behat, il est parfois difficile d'arbitrer si une nouvelle phrase doit être créée ou non.
Pour vous donner un ordre d'idée, il est possible de tester une application comme Gitonomy (un serveur Git) avec 38 phrases réparties comme ceci :
La suite de test de Gitonomy a exactement 534 étapes, ce qui veut dire qu'en moyenne, une phrase est utilisée environ 15 fois. Bien sûr ça n'est pas une règle absolue, mais globalement, les méthodes implémentées doivent être réutilisées de nombreuses fois.
Quand on travaille avec Behat, il est intéressant de séparer en plusieurs classes les phrases de contexte. Dans l'exemple ci-dessus, on voit bien une séparation en 4 domaines différents. Concrètement, dans Gitonomy, ce sont bien 4 classes PHP différentes pour chacun de ces domaines :
ApiContext
(voir le fichier)GitonomyNavigationContext
(voir le fichier)MailCatcherContext
(voir le fichier)WebDriverContext
(voir le fichier)Finalement, pour réunir ensemble ces contextes dans un seul contexte :
# fichier: features/bootstrap/FeatureContext.php
use Alex\MailCatcher\Behat\MailCatcherContext;
use Behat\Behat\Context\BehatContext;
use Gitonomy\QA\Context\ApiContext;
use Gitonomy\QA\Context\GitonomyNavigationContext;
use WebDriver\Behat\WebDriverContext;
class FeatureContext extends BehatContext
{
public function __construct(array $parameters)
{
$this->useContext('api', new ApiContext());
$this->useContext('gitonomy_navigation', new GitonomyNavigationContext());
$this->useContext('webdriver', new WebDriverContext());
$this->useContext('mailcatcher', new MailCatcherContext());
}
}
Chaque contexte est ici nommé, pour être retrouvé ultérieurement. Par exemple, si vous êtes dans le contexte ApiContext
et que vous souhaitez accéder à WebDriverContext
, vous pouvez faire :
class ApiContext extends BehatContext
{
private function doSomething()
{
$otherContext = $this->getMainContext()->getSubContext('webdriver');
// la méthode ci-dessous est définie dans WebDriverContext
$otherContext->iClickOn('someText');
}
}
Cette astuce vous permettra d'appeler des méthodes déjà implémentée dans d'autres contextes sans dupliquer le code.
Lorsqu'on souhaite sortir des cas d'école avec Behat, on a rapidement besoin d'injecter ses dépendances dans le contexte. Par exemple, si on souhaite accéder à un fichier fixtures.yml
situé à la racine du projet, il faut injecter le chemin complet vers le fichier dans le contexte.
Behat dispose en interne d'un conteneur de services. Malheureusement, les contextes de notre application ne sont pas des services du conteneur. La méthode utilisée pour injecter les dépendances dans nos contextes est celle des Initializers : ce sont des services qui vont pouvoir itérer sur tous les contexte. L'interface d'un Initializer est la suivante :
namespace Behat\Behat\Context\Initializer;
interface InitializerInterface
{
/**
* Checks if initializer supports provided context.
*
* @return Boolean
*/
public function supports(ContextInterface $context);
/**
* Initializes provided context.
*/
public function initialize(ContextInterface $context);
}
Pour pouvoir injecter nos dépendances dans nos contextes, nous allons tout d'abord modifier notre classe de contexte pour pouvoir y injecter notre valeur :
class FeatureContext extends BehatContext
{
private $fixturesPath;
public function setFixturesPath($fixturesPath)
{
$this->fixturesPath = $fixturesPath;
}
}
Il ne reste plus qu'à faire en sorte que cette méthode soit appelée avec la bonne valeur. Pour cela, on crée l'Initializer associé :
namespace Acme\Behat\AcmeExtension\Context;
use Behat\Behat\Context\Initializer\InitializerInterface;
use Behat\Behat\Context\ContextInterface;
class FixturesInitializer implements InitializerInterface
{
private $fixturesPath;
public function __construct($fixturesPath)
{
$this->fixturesPath = $fixturesPath;
}
public function supports(ContextInterface $context)
{
return $context instanceof \FeatureContext;
}
public function initialize(ContextInterface $context)
{
$context->setFixturesPath($this->fixturesPath);
}
}
Il ne nous reste plus qu'à le déclarer auprès de Behat. Pour cela, la création d'une extension est obligatoire.
Pour pouvoir utiliser le conteneur de services, on crée une extension. Cette extension prend la forme d'une classe PHP, qu'on définit comme ceci :
# src/Acme/Behat/AcmeExtension/Extension.php
namespace Acme\Behat\AcmeExtension;
use Behat\Behat\Extension\Extension as BaseExtension;
class Extension extends BaseExtension
{
function load(array $config, ContainerBuilder $container)
{
$container->setParameter('acme.fixtures_path', $config['fixtures']);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/config'));
$loader->load('services.xml');
}
function getConfig(ArrayNodeDefinition $builder)
{
$builder
->children()
->scalarNode('fixtures')->isRequired()->end()
->end()
;
}
}
Dans un dossier config
, situé avec le fichier Extension.php
, on crée un fichier services.xml
définissant un service :
<!-- src/Acme/Behat/AcmeExtension/config/services.xml -->
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="fixtures_initializer" class="Acme\Behat\AcmeExtension\Context\FixturesInitializer">
<argument>%acme.fixtures_path%</argument>
<tag name="behat.context.initializer" />
</service>
</services>
</container>
Il ne nous reste plus qu'à l'activer dans behat.yml
:
default:
extensions:
Acme\Behat\AcmeExtension\Extension:
fixtures: fixtures.yml
Maintenant que nous avons vu les principes d'extension de Behat, mettons le en pratique !
Dans cet exercice, nous souhaitons accéder à Doctrine, à Twig et à tous les autres services présents dans notre conteneur de service.
Étape n°1 : Créer le service KernelProxy
Nous créons dans un premier temps un service permettant d'accéder à notre application Symfony :
namespace Acme\Behat\KernelExtension;
class KernelProxy
{
protected $appDir;
protected $env;
protected $debug;
public function __construct($appDir, $env = 'prod', $debug = true)
{
$this->appDir = $appDir;
$this->env = $env;
$this->debug = $debug;
}
public function run(\Closure $callback)
{
require_once $this->appDir.'/AppKernel.php';
$exception = null;
$app = new \AppKernel($this->env, $this->debug);
$app->boot();
try {
$result = $callback($app->getContainer());
} catch (\Exception $e) {
$exception = $e;
}
$app->shutdown();
if ($exception) {
throw $exception;
}
return $result;
}
}
Étape n°2 : Injecter le KernelProxy dans nos contextes
On crée ensuite une interface pour les classes de contexte souhaitant accéder au proxy : KernelProxyAwareInterface
:
namespace Acme\Behat\KernelExtension;
interface KernelProxyAwareInterface
{
public function setKernelProxy(KernelProxy $kernelProxy);
}
Dans notre classe de contexte, on implémente cette interface :
use Acme\Behat\KernelExtension\KernelAwareInterface;
use Behat\Behat\Context\BehatContext;
class FeatureContext extends BehatContext implements KernelAwareInterface
{
private $kernelProxy;
public function setKernelProxy(KernelProxyInterface $kernelProxy)
{
$this->kernelProxy = $kernelProxy;
}
/**
* @Given /^user "(.*)" does not exist$/
*/
public function userDoesNotExist($username)
{
$this->kernelProxy->run(function ($container) use ($username) {
$em = $container->get('doctrine')->getManager();
$user = $em->find('User', $username);
if ($user) {
$em->remove($user);
$em->flush();
}
})
}
}
Maintenant, on crée le fichier de service pour déclarer le KernelProxy comme service :
<!-- src/Acme/Behat/KernelExtension/config/services.xml -->
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="acme.kernel_proxy" class="Acme\Behat\KernelExtension\KernelProxy">
<argument>%acme.app_dir%</argument>
</service>
<service id="acme.kernel_proxy_initializer" class="Acme\Behat\KernelExtension\Context\KernelProxyInitializer">
<argument type="service" id="acme.kernel_proxy" />
<tag name="behat.context.initializer" />
</service>
</services>
</container>
On crée notre extension Behat :
# src/Acme/Behat/KernelExtension/Extension.php
namespace Acme\Behat\KernelExtension;
use Behat\Behat\Extension\Extension as BaseExtension;
class Extension extends BaseExtension
{
function load(array $config, ContainerBuilder $container)
{
$container->setParameter('acme.app_dir', $config['app_dir']);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/config'));
$loader->load('services.xml');
}
function getConfig(ArrayNodeDefinition $builder)
{
$builder
->children()
->scalarNode('app_dir')->defaultValue('app')->end()
->end()
;
}
}
Finalement, dans notre fichier behat.yml
:
default:
extensions:
Acme\Behat\KernelExtension\Extension: ~
Depuis maintenant 2 ans, j'utilise Behat dans tous mes projets Web, car c'est à mes yeux une des meilleures manière de valider fonctionnellement une application.
J'ai pu durant ce temps développer des extensions Behat pour 2 aspects récurrents.
Permettez-moi de vous les présenter ci-dessous.
Comme beaucoup de monde, j'ai commencé à utiliser Mink pour manipuler un navigateur Web. Néanmoins, à force de l'utiliser, certains comportements ne me convenaient pas :
Bref pour ces raisons et d'autres, et aussi pour gagner en flexibilité, j'ai préféré réimplémenter cette partie de manière plus simple.
Ainsi, j'ai créé l'extension WebDriver pour Selenium, permettant de profiter d'une liaison plus forte avec WebDriver (car aucune abstraction de navigateur).
Plus de détails sur la page de l'extension WebDriver pour Behat.
Finalement, pour vérifier les envois d'e-mail, on utilise un outil appelé Mailcatcher. Cet outil permet de créer un serveur SMTP bouchon et d'accéder aux mails par API ou par interface Web.
J'ai développé une librairie pour l'occasion et fait une extension Behat permettant de tester les mails envoyés. En reprenant l'exemple du début, on peut ainsi écrire (en anglais) :
When I am on "/contact"
And I fill:
| Name | My name |
| Subject | Subject of the message |
| Message | Content of the message |
And I click on "Send"
Then I should see "Your message is sent"
When I open mail to "contact@afsy.fr"
Then I should see "Content of the message" in mail
Tous les détails sur cette page.
Maintenant, nous pouvons créer de nouvelles étapes et amener dans celles-ci toutes sortes de dépendances.