Lorsque nous développons un site internet, il est plus que conseillé de faire de la BDD, à défaut des tests fonctionnels après avoir développé la fonctionnalité du site.
La raison en est simple: un site internet évolue si rapidement que vérifier toutes les régressions possibles est une perte de temps considérable, et surtout, les tests pouvant être lancés via un outil tel que jenkins, les régressions sont immédiatement détectées. De quoi « soulager » les inquiétudes du chef de projet et des dev sur l’assurance que rien n’a été cassé.
Tout commence par le composer.json
C’est parti, on doit rapatrier un certain nombre de librairies pour que tout fonctionne: BEHAT, pour écrire des scénarios, et MINK, pour pouvoir naviguer dans l’HTML et accéder à un certain nombre de méthodes déjà existantes.
Dans le require-dev, vous devriez donc avoir ceci:
"require-dev": { "doctrine/doctrine-fixtures-bundle": "~2.2", "sensio/generator-bundle": "~2.3", "phpunit/phpunit": "4.8.*", "behat/behat": "~3.0", "behat/symfony2-extension": "~2.0", "behat/mink": "1.6.*", "behat/mink-extension": "~2.0", "behat/mink-selenium2-driver": "*", },
Bien évidemment, phpunit n’est pas utile ici, mais c’est parce que je fais toujours des tests unitaires et fonctionnels dans mes projets.
Le driver selenium2 va être très important pour lancer le navigateur.
Rien à faire côté appKernel (ça aurait été différent si nous avions fait appel au mink-bundle qui peut s’utiliser sans Behat et qui fournit une interface pour naviguer dans HTML)
Création d’un fichier behat.yml dans le répertoire config à la racine du site
Il n’est pas obligatoire de le placer ici (il peut être mis directement à la racine du site sans être mis dans un fichier config), mais c’est une pratique que je trouve bonne, car on y met aussi des config de capistrano par exemple.
Voici comment il est composé:
default: autoload: src/ extensions: Behat\Symfony2Extension: ~ Behat\MinkExtension: base_url: http://mon-url.local sessions: my_session: selenium2: ~ suites: bo: contexts: - App\CoreBundle\Features\Context\CoreContext type: symfony_bundle bundle: 'AppBackBundle' bo_user: contexts: - App\CoreBundle\Features\Context\CoreContext type: symfony_bundle bundle: 'AppBackUserBundle' blog: contexts: - App\CoreBundle\Features\Context\CoreContext type: symfony_bundle bundle: 'AppBlogBundle' dev: extensions: Behat\MinkExtension: base_url: http://mon-url.dev preprod: extensions: Behat\MinkExtension: base_url: http://mon-url.preprod prod: extensions: Behat\MinkExtension: base_url: http://mon-url.fr
- On indique que les features appelées sont dans src
- On appelle les extensions Behat de Symfony2 et de Mink, en spécifiant l’url local et comme session selenium2
- On définit ce qu’on appelle des suites, c’est à dire les bundles qui vont être testés
- Dans ces suites, on peut définir un context qui va pouvoir être utilisé partout (plutôt que de dupliquer les fonctions qui seront utiles dans chaque context)
- On définit des profils d’environnement: default, dev, preprod, prod avec la bonne url à chaque fois. Vous allez rapidement comprendre pourquoi l’on fait cela.
Création des features et scénarios dans un bundle
Votre bundle est créé, mais vous voulez écrire des tests fonctionnels dedans. Pour cela, rien de plus simple. Rendez-vous dans votre console et tapez la ligne suivante:
bin/behat --init --suite=votreSuite --append-snippets
Ici, votreSuite peut être bo, ou bo_user, ou encore blog.
Exécuter cette ligne va afficher ceci:
+d /Users/jsaulnier/Sites/REST-BEHAT/src/App/BlogBundle/Features - place your *.feature files here +d /Users/jsaulnier/Sites/REST-BEHAT/src/App/BlogBundle/Features/Context - place your context classes here +f /Users/jsaulnier/Sites/REST-BEHAT/src/App/BlogBundle/Features/Context/FeatureContext.php - place your definitions, transformations and hooks here
Attention, si vous avez déclaré un context comme le CoreContext dans « contexts », le répertoire Features sera créé dans votre bundle mais pas le fichier FeatureContext.php, et vous devrez alors le créer à la main
Que le fichier FeatureContext.php ait été généré ou non, voici la tête qu’il doit avoir:
#src/App/BlogBundleFeatures/Context/FeatureContext.php <?php namespace App\BlogBundle\Features\Context; use Behat\Behat\Context\Context; use Behat\Behat\Context\SnippetAcceptingContext; use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; /** * Defines application features from the specific context. */ class FeatureContext implements Context, SnippetAcceptingContext { /** * Initializes context. * * Every scenario gets its own context instance. * You can also pass arbitrary arguments to the * context constructor through behat.yml. */ public function __construct() { } }
Il va falloir le modifier, car aujourd’hui, si vous tapez la commande
bin/behat --suite=votreSuite -dl
et que vous n’avez défini aucun context (comme le CoreContext que j’ai mis en place) eh bien vous n’aurez rien. En effet, il n’y a aucune définition possible. Et c’est là que Mink rentre en jeu.
Modification du fichier FeatureContext.php
<?php namespace App\BlogBundle\Features\Context; use Behat\Behat\Tester\Exception\PendingException; use Behat\Behat\Context\SnippetAcceptingContext; use Behat\MinkExtension\Context\MinkContext as BaseMinkContext; /** * Defines application features from the specific context. */ class FeatureContext extends BaseMinkContext implements SnippetAcceptingContext { /** * Initializes context. * * Every scenario gets its own context instance. * You can also pass arbitrary arguments to the * context constructor through behat.yml. */ public function __construct() { } }
Mais quel est donc ce fichier Behat\MinkExtension\Context\MinkContext? Eh bien il étend des classes qui, arrivé presque au plus haut niveau de l’arborescence, implémente Behat\Behat\Context\Context.
Mais surtout, il comporte un grand nombre de méthodes qui vont pouvoir nous servir sur nos scénarios! En effet, si vous retapez à présent votre ligne de commande
bin/behat --suite=votreSuite -dl
vous verrez cela:
blog | Given /^(?:|je )suis sur la page d'accueil$/ blog | When /^(?:|je )vais sur la page d'accueil$/ blog | Given /^(?:|je )suis sur "(?P<page>[^"]+)"$/ blog | When /^(?:|je )vais sur "(?P<page>[^"]+)"$/ blog | When /^(?:|je )recharge la page$/ blog | When /^(?:|je )recule d'une page$/ blog | When /^(?:|j')avance d'une page$/ blog | When /^(?:|je )presse "(?P<button>(?:[^"]|\\")*)"$/ blog | When /^(?:|je )suis "(?P<link>(?:[^"]|\\")*)"$/ blog | When /^(?:|je )remplis "(?P<field>(?:[^"]|\\")*)" avec "(?P<value>(?:[^"]|\\")*)"$/ blog | When /^(?:|I )fill in "(?P<field>(?:[^"]|\\")*)" with:$/ blog | When /^(?:|je )remplis "(?P<value>(?:[^"]|\\")*)" pour "(?P<field>(?:[^"]|\\")*)"$/ blog | When /^(?:|je )remplis le texte suivant:$/ blog | When /^(?:|je )sélectionne "(?P<option>(?:[^"]|\\")*)" depuis "(?P<select>(?:[^"]|\\")*)"$/ blog | When /^(?:|je )sélectionne une autre option "(?P<option>(?:[^"]|\\")*)" depuis "(?P<select>(?:[^"]|\\")*)"$/ blog | When /^(?:|je )coche "(?P<option>(?:[^"]|\\")*)"$/ blog | When /^(?:|je )décoche "(?P<option>(?:[^"]|\\")*)"$/ blog | When /^(?:|j')attache le fichier "(?P<path>[^"]*)" à "(?P<field>(?:[^"]|\\")*)"$/ blog | Then /^(?:|je )devrais être sur "(?P<page>[^"]+)"$/ blog | Then /^(?:|je )devrais être sur la page d'accueil$/ blog | Then /^l'(?i)url(?-i) devrait suivre le motif (?P<pattern>"(?:[^"]|\\")*")$/ blog | Then /^le code de status de la réponse devrait être (?P<code>\d+)$/ blog | Then /^le code de status de la réponse ne devrait pas être (?P<code>\d+)$/ blog | Then /^(?:|je )devrais voir "(?P<text>(?:[^"]|\\")*)"$/ blog | Then /^(?:|je )ne devrais pas voir "(?P<text>(?:[^"]|\\")*)"$/ blog | Then /^(?:|je )devrais voir un texte suivant le motif (?P<pattern>"(?:[^"]|\\")*")$/ blog | Then /^(?:|je )ne devrais pas voir de texte suivant le motif (?P<pattern>"(?:[^"]|\\")*")$/ blog | Then /^la réponse devrait contenir "(?P<text>(?:[^"]|\\")*)"$/ blog | Then /^la réponse ne devrait pas contenir "(?P<text>(?:[^"]|\\")*)"$/ blog | Then /^(?:|je )devrais voir "(?P<text>(?:[^"]|\\")*)" dans l'élément "(?P<element>[^"]*)"$/ blog | Then /^(?:|je )ne devrais pas voir "(?P<text>(?:[^"]|\\")*)" dans l'élément "(?P<element>[^"]*)"$/ blog | Then /^l'élément "(?P<element>[^"]*)" devrait contenir "(?P<value>(?:[^"]|\\")*)"$/ blog | Then /^l'élément "(?P<element>[^"]*)" ne devrait pas contenir "(?P<value>(?:[^"]|\\")*)"$/ blog | Then /^(?:|je )devrais voir l'élément "(?P<element>[^"]*)"$/ blog | Then /^(?:|je )ne devrais pas voir l'élément "(?P<element>[^"]*)"$/ blog | Then /^le champ "(?P<field>(?:[^"]|\\")*)" devrait contenir "(?P<value>(?:[^"]|\\")*)"$/ blog | Then /^le champ "(?P<field>(?:[^"]|\\")*)" ne devrait pas contenir "(?P<value>(?:[^"]|\\")*)"$/ blog | Then /^la case à cocher "(?P<checkbox>[^"]*)" devrait être cochée$/ blog | Then /^the checkbox "(?P<checkbox>(?:[^"]|\\")*)" (?:is|should be) checked$/ blog | Then /^la case à cocher "(?P<checkbox>(?:[^"]|\\")*)" ne devrait pas être cochée$/ blog | Then /^the checkbox "(?P<checkbox>(?:[^"]|\\")*)" should (?:be unchecked|not be checked)$/ blog | Then /^the checkbox "(?P<checkbox>(?:[^"]|\\")*)" is (?:unchecked|not checked)$/ blog | Then /^(?:|je )devrais voir (?P<num>\d+) éléments? "(?P<element>[^"]*)"$/ blog | Then /^print current URL$/ blog | Then /^imprimer la dernière réponse$/ blog | Then /^montrer la dernière réponse$/
Là, ça devient plus qu’intéressant, car on a déjà un paquet d’expressions que l’on peut utiliser. Si vous préférez la version anglaise, il suffit que vous tapiez bin/behat –suite=votreSuite -dl –lang=en
et ceci apparaîtra:
blog | Given /^(?:|I )am on (?:|the )homepage$/ blog | When /^(?:|I )go to (?:|the )homepage$/ blog | Given /^(?:|I )am on "(?P<page>[^"]+)"$/ blog | When /^(?:|I )go to "(?P<page>[^"]+)"$/ blog | When /^(?:|I )reload the page$/ blog | When /^(?:|I )move backward one page$/ blog | When /^(?:|I )move forward one page$/ blog | When /^(?:|I )press "(?P<button>(?:[^"]|\\")*)"$/ blog | When /^(?:|I )follow "(?P<link>(?:[^"]|\\")*)"$/ blog | When /^(?:|I )fill in "(?P<field>(?:[^"]|\\")*)" with "(?P<value>(?:[^"]|\\")*)"$/ blog | When /^(?:|I )fill in "(?P<field>(?:[^"]|\\")*)" with:$/ blog | When /^(?:|I )fill in "(?P<value>(?:[^"]|\\")*)" for "(?P<field>(?:[^"]|\\")*)"$/ blog | When /^(?:|I )fill in the following:$/ blog | When /^(?:|I )select "(?P<option>(?:[^"]|\\")*)" from "(?P<select>(?:[^"]|\\")*)"$/ blog | When /^(?:|I )additionally select "(?P<option>(?:[^"]|\\")*)" from "(?P<select>(?:[^"]|\\")*)"$/ blog | When /^(?:|I )check "(?P<option>(?:[^"]|\\")*)"$/ blog | When /^(?:|I )uncheck "(?P<option>(?:[^"]|\\")*)"$/ blog | When /^(?:|I )attach the file "(?P<path>[^"]*)" to "(?P<field>(?:[^"]|\\")*)"$/ blog | Then /^(?:|I )should be on "(?P<page>[^"]+)"$/ blog | Then /^(?:|I )should be on (?:|the )homepage$/ blog | Then /^the (?i)url(?-i) should match (?P<pattern>"(?:[^"]|\\")*")$/ blog | Then /^the response status code should be (?P<code>\d+)$/ blog | Then /^the response status code should not be (?P<code>\d+)$/ blog | Then /^(?:|I )should see "(?P<text>(?:[^"]|\\")*)"$/ blog | Then /^(?:|I )should not see "(?P<text>(?:[^"]|\\")*)"$/ blog | Then /^(?:|I )should see text matching (?P<pattern>"(?:[^"]|\\")*")$/ blog | Then /^(?:|I )should not see text matching (?P<pattern>"(?:[^"]|\\")*")$/ blog | Then /^the response should contain "(?P<text>(?:[^"]|\\")*)"$/ blog | Then /^the response should not contain "(?P<text>(?:[^"]|\\")*)"$/ blog | Then /^(?:|I )should see "(?P<text>(?:[^"]|\\")*)" in the "(?P<element>[^"]*)" element$/ blog | Then /^(?:|I )should not see "(?P<text>(?:[^"]|\\")*)" in the "(?P<element>[^"]*)" element$/ blog | Then /^the "(?P<element>[^"]*)" element should contain "(?P<value>(?:[^"]|\\")*)"$/ blog | Then /^the "(?P<element>[^"]*)" element should not contain "(?P<value>(?:[^"]|\\")*)"$/ blog | Then /^(?:|I )should see an? "(?P<element>[^"]*)" element$/ blog | Then /^(?:|I )should not see an? "(?P<element>[^"]*)" element$/ blog | Then /^the "(?P<field>(?:[^"]|\\")*)" field should contain "(?P<value>(?:[^"]|\\")*)"$/ blog | Then /^the "(?P<field>(?:[^"]|\\")*)" field should not contain "(?P<value>(?:[^"]|\\")*)"$/ blog | Then /^the "(?P<checkbox>(?:[^"]|\\")*)" checkbox should be checked$/ blog | Then /^the checkbox "(?P<checkbox>(?:[^"]|\\")*)" (?:is|should be) checked$/ blog | Then /^the "(?P<checkbox>(?:[^"]|\\")*)" checkbox should not be checked$/ blog | Then /^the checkbox "(?P<checkbox>(?:[^"]|\\")*)" should (?:be unchecked|not be checked)$/ blog | Then /^the checkbox "(?P<checkbox>(?:[^"]|\\")*)" is (?:unchecked|not checked)$/ blog | Then /^(?:|I )should see (?P<num>\d+) "(?P<element>[^"]*)" elements?$/ blog | Then /^print current URL$/ blog | Then /^print last response$/ blog | Then /^show last response$/
Dans le CoreContext, vous pouvez placer des fonctions comme:
/** * Click on element CSS with index name * * @When /^(?:|I )click on "(?P<id>(?:[^"]|\\")*)"$/ */ public function clickOn($element) { $this->assertSession()->elementExists('css', $element)->click(); }
Ainsi, à chaque fois que, dans le fichier de configuration behat, vous mettrez
contexts: - App\CoreBundle\Features\Context\CoreContext
cette fonction sera accessible de partout.
Mise en place des premiers scénarios
Passons aux choses sérieuses. On se place dans le bundle pour lequel le fichier Features/Context/FeatureContext.php a été généré, et on crée un répertoire appelé « Scénarios » dans le dossier « Features ». Dans ce répertoire, on crée un fichier de la page que l’on veut tester, par exemple home.feature (le .feature est extrêmement important)
Ce fichier va se découper en plusieurs parties:
- Feature
- Background (pas obligatoire)
- Scenario (obligatoire)
Feature: Homepage Background: When I am on homepage Scenario: Then I should see "Accueil" Then I click on "#categories" And I move forward one page Then I should see "jpSymfony & optimusThePrime 2015"
Ici, j’indique que ma feature traite de la homepage, j’indique un background qui va s’exécuter pour tous les tests, et je décris mon scénario.
Dans Mink, ce qui est particulier, c’est que lorsqu’on a cliqué sur un lien, pour accéder à la page suivante, il faut écrire « And I move forward one page ».
Lancer le test
Pour lancer le test, il faut faire deux choses:
- télécharger le fichier selenium server qui se trouve ici: http://www.seleniumhq.org/download/ (cliquez sur le fichier de téléchargement dans la section « Selenium Standalone Server »
- Le lancer dans une fenêtre shell en tapant java -jar selenium-server-standalone-2.47.1.jar
- Lancer la commande bin/behat –suite=votreSuite –append-snippets
La console nous dit:
Feature: Homepage Background: # /Users/jsaulnier/Sites/REST-BEHAT/src/App/BlogBundle/Features/Scenarios/home.feature:3 When I am on homepage # App\BlogBundle\Features\Context\FeatureContext::iAmOnHomepage() Scenario: # /Users/jsaulnier/Sites/REST-BEHAT/src/App/BlogBundle/Features/Scenarios/home.feature:6 Then I should see "Accueil" # App\BlogBundle\Features\Context\FeatureContext::assertPageContainsText() Then I click on "#categories" And I move forward one page # App\BlogBundle\Features\Context\FeatureContext::forward() Then I should see "jpSymfony & optimusThePrime 2015" # App\BlogBundle\Features\Context\FeatureContext::assertPageContainsText() 1 scénario (1 indéfinis) 5 étapes (2 succès, 1 indéfinis, 2 ignorés) 0m6.69s (17.36Mb)
Et une fonction est apparue dans notre fichier FeatureContext du bundle BlogBundle:
/** * @Then I click on :arg1 */ public function iClickOn($arg1) { throw new PendingException(); }
Behat nous dit qu’il ne sait comment gérer ça. C’est là qu’il faut alors écrire le code qui gère cette action, comme:
$this->assertSession()->elementExists('css', $element)->click();
Si jamais cette fonction existe dans le CoreContext, bien sûr, Behat ne va pas vous demander de la réécrire ici, mais j’ai fait comme s’il n’y avait pas de CoreContext, ou qu’il était vide.
Mais je me dis que vous vous demandez peut-être, depuis tout à l’heure, pourquoi nous avons mis –append-snippets. Eh bien, en fait, cette option vous permet de demander à Behat d’écrire justement le bout de code
/** * @Then I click on :arg1 */ public function iClickOn($arg1) { throw new PendingException(); }
Sans cela, voici ce que vous auriez eu dans la console:
Feature: Homepage Background: # /Users/jsaulnier/Sites/REST-BEHAT/src/App/BlogBundle/Features/Scenarios/home.feature:3 When I am on homepage # App\BlogBundle\Features\Context\FeatureContext::iAmOnHomepage() Scenario: # /Users/jsaulnier/Sites/REST-BEHAT/src/App/BlogBundle/Features/Scenarios/home.feature:6 Then I should see "Accueil" # App\BlogBundle\Features\Context\FeatureContext::assertPageContainsText() Then I click on "#categories" And I move forward one page # App\BlogBundle\Features\Context\FeatureContext::forward() Then I should see "jpSymfony & optimusThePrime 2015" # App\BlogBundle\Features\Context\FeatureContext::assertPageContainsText() 1 scénario (1 indéfinis) 5 étapes (2 succès, 1 indéfinis, 2 ignorés) 0m6.69s (17.36Mb) --- App\BlogBundle\Features\Context\FeatureContext a des étapes manquantes. Définissez-les avec les modèles suivants : /** * @Then I click on :arg1 */ public function iClickOn($arg1) { throw new PendingException(); }
Futé, le bison, n’est-ce pas?
Les profils
Une toute dernière chose. Vous vous demandez peut-être comment Behat sait sur quelle url il doit aller lorsqu’on écrit « When I am on homepage ».
Eh bien, c’est très simple. Si l’on regarde en détail le code, on voit ceci:
/** * Opens homepage. * * @Given /^(?:|I )am on (?:|the )homepage$/ * @When /^(?:|I )go to (?:|the )homepage$/ */ public function iAmOnHomepage() { $this->visitPath('/'); } /** * Visits provided relative path using provided or default session. * * @param string $path * @param string|null $sessionName */ public function visitPath($path, $sessionName = null) { $this->getSession($sessionName)->visit($this->locatePath($path)); } /** * Locates url, based on provided path. * Override to provide custom routing mechanism. * * @param string $path * * @return string */ public function locatePath($path) { $startUrl = rtrim($this->getMinkParameter('base_url'), '/') . '/'; return 0 !== strpos($path, 'http') ? $startUrl . ltrim($path, '/') : $path; }
On retrouve ce fameux base_url que l’on a défini dans notre fichier behat.yml !
Et comment faire si l’on veut que l’on appelle l’url de preprod? Tout simplement en rajoutant à la fin de l’appel, dans le shell: –profile=preprod
Rappelez-vous, nous avons défini quatre profils: default, dev, preprod et prod.
En appelant celui de preprod, vous allez taper sur l’url http://mon-url.preprod
A présent, vous êtes armés pour démarrer vos tests fonctionnels sur vos applications Symfony2!