Panther : testez vos apps Symfony avec un vrai navigateur web !

Retrouvez la publication d'origine [EN] ici.

Depuis sa toute première version, le framework Symfony 2 fournit une suite d’outils permettant de créer des tests fonctionnels. Les composants BrowserKit et DomCrawler sont notamment utilisés pour simuler un navigateur web avec une API developer-friendly

La classe WebTestCase

Rafraîchissons un peu notre mémoire en créant un petit site d’information, et en écrivant la suite de tests fonctionnels qui va bien :

# créez le projet
$ composer create-project symfony/skeleton news-website
$ cd news-website/

# ajoutez quelques dépendances
$ composer require twig annotations
$ composer require --dev maker tests

# lancez le serveur web PHP intégré
$ php -S 127.0.0.1:8000 -t public

Nous sommes prêts à coder ! Commençons par ajouter une classe pour stocker et récupérer les publications de notre site :

// src/Repository/NewsRepository.php
namespace App\Repository;

class NewsRepository
{
    private const NEWS = [
        'week-601' => [
            'slug' => 'week-601',
            'title' => 'A week of symfony #601 (2-8 July 2018)',
            'body' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.',
        ], 
        'symfony-live-usa-2018' => [
            'slug' => 'symfony-live-usa-2018',
            'title' => 'Join us at SymfonyLive USA 2018!',
            'body' => 'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur.'
        ],
    ];

    public function findAll(): iterable
    {
        return array_values(self::NEWS);
    }

    public function findOneBySlug(string $slug): ?array
    {
        return self::NEWS[$slug] ?? null;
    }
}

Cette implémentation n’est pas très dynamique mais elle fera l’affaire pour le moment. Maintenant, nous avons besoin d’un contrôleur et du template Twig correspondant pour afficher les dernières informations de notre projet. Utilisons MakerBundle pour les générer :

$ ./bin/console make:controller News

Changeons un peu le code pour qu’il corresponde à ce que nous voulons :

// src/Controller/NewsController.php
namespace App\Controller;

use App\Repository\NewsRepository;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class NewsController extends Controller
{
    private $newsRepository;

    public function __construct(NewsRepository $newsRepository)
    {
        $this->newsRepository = $newsRepository;
    }

    /**
     * @Route("/", name="news_index")
     */
    public function index(): Response
    {
        return $this->render('news/index.html.twig', [
            'collection' => $this->newsRepository->findAll(),
        ]);
    }

    /**
     * @Route("/news/{slug}", name="news_item")
     */
    public function item(string $slug): Response
    {
        if (null === $news = $this->newsRepository->findOneBySlug($slug)) {
            throw $this->createNotFoundException();
        }

        return $this->render('news/item.html.twig', ['item' => $news]);
    }
}
{# templates/news/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}News{% endblock %}

{% block body %}
{% for item in collection %}
   <article id="{{ item.slug }}">
       <h1><a href="{{ path('news_item', {slug: item.slug}) }}">{{ item.title }}</a></h1>
       {{ item.body }}
   </article>
{% endfor %}
{% endblock %}
{% extends 'base.html.twig' %}

{% block title %}{{ item.title }}{% endblock %}

{% block body %}
   <h1>{{ item.title }}</h1>
   {{ item.body }}
{% endblock %}

Grâce à l'aide de WebTestCase, ajouter des tests fonctionnels va être très simple ! Générez un squelette de test fonctionnel :

$ ./bin/console make:functional-test NewsControllerTest

Ajoutez des assertions pour vérifier que notre contrôleur fonctionne correctement :

// tests/NewsControllerTest.php
namespace App\Tests;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class NewsControllerTest extends WebTestCase
{
    public function testNews()
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/');

        $this->assertCount(2, $crawler->filter('h1'));
        $this->assertSame(['week-601', 'symfony-live-usa-2018'], $crawler->filter('article')->extract('id'));

        $link = $crawler->selectLink('Join us at SymfonyLive USA 2018!')->link();
        $crawler = $client->click($link);

        $this->assertSame('Join us at SymfonyLive USA 2018!', $crawler->filter('h1')->text());
    }
}

Et maintenant, exécutez les tests :

$ ./bin/phpunit

Tout est parfait ! Symfony fournit une API très pratique pour naviguer sur le site web, vérifier que les liens fonctionnent et s’assurer que le contenu attendu est affiché. C’est facile à configurer et extrêmement rapide !

Utiliser Panther pour exécuter un scénario dans un navigateur

WebTestCase n’utilise pas un vrai navigateur web. Il en simule un avec des composants écrits en PHP. Il n’utilise pas non plus le protocole HTTP : il crée des instances d’objets Request de HttpFoundation, les passe au noyau de Symfony et permet d’effectuer des assertions sur l’instance Response de HttpFoundation retournée par l’application.

Maintenant, que faire si un problème empêche le chargement de la page web dans le navigateur ? Les raisons peuvent être nombreuses :

Eh bien Panther vous permet d’exécuter ces scénarios dans de vrais navigateurs ! Il implémente aussi l’API publique des composants BrowserKit et DomCrawler, mais en interne, il utilise la bibliothèque PHP WebDriver de Facebook.

Vous pouvez choisir d'exécuter le même scénario de navigation via une implémentation en pur PHP (WebTestCase) ou dans n’importe quel navigateur web moderne, à travers le protocole d’automatisation de navigateur WebDriver, devenu une recommandation officielle du W3C en juin dernier.  

Encore mieux, pour utiliser Panther, vous n’avez besoin que d’une installation locale de Chrome, et de rien d’autre : pas de serveur Selenium, ni de pilote obscur ou d’extension. Puisque Panther est désormais une dépendance du méta-paquet symfony/test-pack, vous avez déjà installé Panther sans le savoir quand vous avez tapé composer req --dev tests. Vous pouvez également installer Panther directement dans n’importe quel projet PHP en exécutant composer require symfony/panther.

Modifions quelques lignes de notre scénario de test existant :

// tests/NewsControllerTest.php
namespace App\Tests;

-use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\Panther\PantherTestCase;

-class NewsControllerTest extends WebTestCase
+class NewsControllerTest extends PantherTestCase
{
    public function testNews()
    {
-        $client = static::createClient(); // Still work, if needed
+        $client = static::createPantherClient();

Exécutez à nouveau les tests :

$ ./bin/phpunit

Tout fonctionne ! Cette fois-ci, nous sommes assurés que notre site d’information fonctionne correctement sur Google Chrome.       

Sans que vous l’ayez remarqué, Panther a :

  • Démarré votre projet avec le serveur web intégré sur localhost:9000  
  • Démarré la version de Chromedriver livrée avec la bibliothèque pour piloter votre version locale de Chrome
  • Exécuté le scénario de navigation défini dans le test avec Chrome en mode headless

Si vous êtes du genre à ne croire que ce que vous voyez, saisissez la commande suivante : 

$ PANTHER_NO_HEADLESS=1 ./bin/phpunit

Comme vous aurez pu le constater dans l’enregistrement, des appels à  sleep() ont été ajoutés afin de mettre en évidence le fonctionnement. Avoir accès à la fenêtre du navigateur (et aux outils de développement) est également très utile pour déboguer un scénario défaillant.

Comme les deux outils implémentent la même API, Panther peut également exécuter des scénarios d’extraction de contenus web écrits pour la populaire bibliothèque Goutte. Dans des scénarios de tests, Panther vous permet de choisir si le scénario doit être exécuté à l'aide du noyau Symfony (quand c’est disponible, static::createClient()), en utilisant Goutte (envoi de réelles requêtes HTTP mais pas de support JavaScript et CSS, static::createGoutteClient()) ou en utilisant de vrais navigateurs web (static::createPantherClient())

Même si Chrome est le choix par défaut, Panther peut contrôler tout navigateur prenant en charge le protocole WebDriver. Il supporte également les services de tests de navigateurs à distance tels que Selenium Grid (open source), SauceLabs et Browserstack.

Il existe également une branche expérimentale qui utilise Geckodriver pour démarrer et piloter automatiquement une installation locale de Firefox au lieu de Chrome.

Tester le code HTML généré par le client

Notre site d’information semble opérationnel, nous venons de prouver qu’il fonctionne dans Chrome. Maintenant, nous voulons recueillir les réactions de la communauté à propos de nos publications. Ajoutons alors un système de commentaires sur notre site Web !

Pour ce faire, nous allons exploiter les fonctionnalités de Symfony 4 et de la plateforme web moderne : nous allons gérer les commentaires via une API Web et nous les afficherons en utilisant les Web Component et Vue.js. L'utilisation de JavaScript pour cette fonctionnalité permet d'améliorer les performances globales ainsi que l'expérience utilisateur : chaque fois que nous publions un nouveau commentaire, celui-ci sera affiché dans la page existante sans nécessiter un rechargement complet.

Symfony fournit une intégration officielle avec le framework API Platform, qui permet de créer des API Web modernes (hypermedia et / ou GraphQL) super facilement. Installez-le : 

$ composer require api

Ensuite, utilisez à nouveau le MakerBundle pour créer une classe d’entité Comment, et exposez-le via un point de terminaison API de lecture et d’écriture :

$ ./bin/console make:entity --api-resource Comment

Cette commande est interactive, et permet de spécifier les champs à créer. Nous en avons uniquement besoin de deux : news (le slug des informations), et body (le contenu des commentaires). news est de type string (longueur maximale de 255 caractères) alors que body est de type text. Les deux ne sont pas nullables.

Voici la transcription complète des interactions avec la commande :

New property name (press  to stop adding fields):
> news

Field type (enter ? to see all types) [string]:
>

Field length [255]:
>

Can this field be null in the database (nullable) (yes/no) [no]:
>

updated: src/Entity/Comment.php

Add another property? Enter the property name (or press  to stop adding fields):
> body

Field type (enter ? to see all types) [string]:
> text

Can this field be null in the database (nullable) (yes/no) [no]:
>

updated: src/Entity/Comment.php

Mettez à jour le fichier .env pour définir la valeur de DATABASE_URL à l’adresse de votre RDBMS et exécutez la commande suivante pour créer la table correspondante à notre entité :  

$ ./bin/console doctrine:schema:create

Si vous ouvrez http://localhost:8000/api, vous verrez que l’API fonctionne déjà et est documentée !

Apportons maintenant quelques modifications mineures à la classe Comment que nous venons de générer. Actuellement, l’API permet d’effectuer des opérations GET, POST, PUT et DELETE. C’est trop ouvert. Puisque nous n’avons aucun mécanisme d’authentification pour le moment, nous voulons seulement que nos utilisateurs soient en mesure de créer et lire des commentaires :

/**
- * @ApiResource()
+ * @ApiResource(
+ *     collectionOperations={"post", "get"},
+ *     itemOperations={"get"}
+ * )

Nous voulons également être en mesure de récupérer les commentaires postés sur un article en particulier. Nous utiliserons un filtre permettant de faire cela :

+ use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiResource;
+ use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
+ use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiResource;
+ use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;

Enfin, ajoutez des contraintes de validation pour être certain·e que les commentaires soumis sont corrects :

/**
 * @ORM\Column(type="string", length=255)
+ * @Assert\Choice(choices={"week-601", "symfony-live-usa-2018"})
 * @ApiFilter(SearchFilter::class)
 */
private $news;

/**
 * @ORM\Column(type="text")
+ * @Assert\NotBlank()
 */
private $body;

Rechargez http://localhost:8000/api, les changements seront automatiquement pris en compte.

La création d'une contrainte de validation personnalisée au lieu de coder en dur la liste des slugs disponibles dans l'assertion de choix est laissé comme exercice au lecteur.

C'est tout pour la partie PHP ! Facile, non ? Maintenant, utilisons notre API avec Vue.js ! Nous utiliserons pour cela l’intégration Vue.js fournie par Symfony Webpack Encore.

Installez Encore et son intégration Vue.js :

$ composer require encore 
# Si vous n’avez pas encore installé le gestionnaire de paquets Yarn, récupérez le sur https://yarnpkg.com/en/ 
$ yarn install 
$ yarn add --dev vue vue-loader@^14 vue-template-compiler

Mettez à jour la configuration de Encore pour activer le loader Vue :

// webpack.config.js

Encore
    // ...
+   .addEntry('js/comments', './assets/comments/index.js')
+   .enableVueLoader()

Nous sommes prêts à créer une chouette interface ! Commençons par un composant Vue rendant la liste des commentaires et un formulaire pour en poster un nouveau :

<!-- assets/comments/CommentSystem.vue -->
<template>
    <div>
        <ol reversed v-if="comments.length">
            <li v-for="comment in comments" :key="comment['@id']">{{ comment.body }}</li>
        </ol>
        <p v-else>No comments yet</p>

        <form id="post-comment" @submit.prevent="onSubmit">
            <textarea name="new-comment" v-model="newComment"
                      placeholder="Your opinion matters! Send us your comment."></textarea>

            <input type="submit" :disabled="!newComment">
        </form>
    </div>
</template>

<script>
    export default {
        props: {
            news: {type: String, required: true}
        },
        methods: {
            fetchComments() {
                fetch(`/api/comments?news=${encodeURIComponent(this.news)}`)
                    .then((response) => response.json())
                    .then((data) => this.comments = data['hydra:member'])
                ;
            },
            onSubmit() {
                fetch('/api/comments', {
                    method: 'POST',
                    headers: {
                        'Accept': 'application/ld+json',
                        'Content-Type': 'application/ld+json'
                    },
                    body: JSON.stringify({news: this.news, body: this.newComment})
                })
                    .then(({ok, statusText}) => {
                        if (!ok) {
                            alert(statusText);
                            return;
                        }

                        this.newComment = '';
                        this.fetchComments();
                    })
                ;
            }
        },
        data() {
            return {
                comments: [],
                newComment: '',
            };
        },
        created() {
            this.fetchComments();
        }
    }
</script>

Ce n’était pas si compliqué ! Ensuite, créez le point d’entrée de notre app de commentaires :

// assets/comments/index.js
import Vue from 'vue';
import CommentSystem from './CommentSystem';

new Vue({
    el: '#comments',
    components: {CommentSystem}
});

Enfin, référencez le fichier JavaScript et initialisez le composant avec le slug courant dans le template item.html.twig.

{% block body %}
<h1>{{ item.title }}</h1>

{{ item.body }}

+ <div id="comments">
+     <comment-system news="{{ item.slug }}"></comment-system>
+ </div>
{% endblock %}

+ {% block javascripts %}
+     <script src="{{ asset('build/js/comments.js') }}"></script>
+ {% endblock %}

Compilez le fichier JS transpilé et minifié (en mode développement, vous pouvez également utiliser le Hot Module Reloading) :

$ yarn encore production

Wow ! Grâce à Symfony 4, nous avons créé une API web et une webapp Vue.js en seulement quelques lignes de code. Ajoutons désormais quelques tests pour notre système de commentaires !

Attendez un peu… Les commentaires sont récupérés en utilisant Ajax, et rendus côté client en JavaScript. Les nouveaux commentaires sont aussi ajoutés de manière asynchrone en JS. Malheureusement, il ne sera pas possible d’utiliser WebTestCase ni Goutte pour tester notre nouvelle fonctionnalité : ils sont écrits en PHP, et ne supportent ni le JS ni Ajax :(

Ne vous inquiétez pas, Panther est capable de tester de telles applications. Rappelez-vous : il utilise un vrai navigateur web ! Testons notre système de commentaires :

namespace App\Tests;

use Symfony\Component\Panther\PantherTestCase;

class CommentsTest extends PantherTestCase
{
    public function testComments()
    {
        $client = static::createPantherClient();
        $crawler = $client->request('GET', '/news/symfony-live-usa-2018');

        $client->waitFor('#post-comment'); // Wait for the form to appear, it may take some time because it's done in JS
        $form = $crawler->filter('#post-comment')->form(['new-comment' => 'Symfony is so cool!']);
        $client->submit($form);

        $client->waitFor('#comments ol'); // Wait for the comments to appear

        $this->assertSame(self::$baseUri.'/news/symfony-live-usa-2018', $client->getCurrentURL()); // Assert we're still on the same page
        $this->assertSame('Symfony is so cool!', $crawler->filter('#comments ol li:first-child')->text());
    }
}

Faites attention : en mode test, les variables d’environnement doivent être définies par phpunit.xml.dist. Assurez-vous de mettre à jour DATABASE_URL afin de référencer une base de données avec les tables requises. Quand la base de données est prête, exécutez les tests :

$ ./bin/phpunit
 

Grâce à Panther, vous pouvez tirer parti de vos compétences en Symfony et de l’API BrowserKit pour tester des applications JavaScript modernes

Fonctionnalités supplémentaires (Captures d’écran, Injections JS)

Panther tire parti du fait qu’il utilise de vrais navigateurs web pour fournir des fonctionnalités qui ne sont pas supportées par le composant Browserkit : il est capable de prendre des captures d’écrans, d’attendre l’apparition des éléments et d’exécuter du code JavaScript personnalisé dans le contexte d'exécution de la page.

En complément de l’API BrowserKit, Panther implémente l’interface Facebook\WebDriver\WebDriver donnant accès à toutes les fonctionnalités de PHP WebDriver.

Essayons : mettez à jour le scénario de test précédent pour prendre une capture d'écran de la page rendue.

$this->assertSame('Symfony is so cool!', $crawler->filter('#comments ol li:first-child')->text());
+ $client->takeScreenshot('screen.png');

Panther est également conçu pour fonctionner dans des systèmes d’intégration continue : il supporte Travis, AppVeyor et est compatible avec Docker ! 

Pour lire la documentation complète, ou laisser une étoile sur le projet, consultez le repo Github.  

Merci, l’Open Source

Panther est basé sur plusieurs bibliothèques FOSS, et a été inspiré par Nightwatch.js, un outil de tests JS basé sur WebDriver que nous utilisons depuis plusieurs années. Relisez notre étude de cas sur Nightwatch.Js.

Pour créer cette bibliothèque, nous avons utilisé plusieurs dépendances, et avons dû corrigé quelques bugs  dont elles souffraient :

L'Open Source est un écosystème vertueux : vous bénéficiez de puissantes bibliothèques existantes pour créer des outils de haut niveau et en même temps vous pouvez les améliorer et les redistribuer.

Nous avons également fait remonter des briques de bas niveau qui ont été conçues lors du développement de Panther au projet  PHP WebDriver :

Ces briques seront d’une grande aide pour l’ensemble de la communauté, y compris pour les alternatives à Panther construites également sur PHP Webdriver (Laravel Dusk, Codeception, ...).

Nous tenons à remercier tous les contributeurs de ces projets, notamment Ondřej Machulda, le mainteneur actuel de PHP Webdriver, qui a pris le temps de consulter et merger nos patches.

Un grand merci également à George S. Baugh, qui a ajouté le support du protocole W3C Webdriver à son implémentation Perl WebDriver. Lorsque nous l’avons découvert, elle nous a grandement aidés à comprendre comment le nouveau protocole se démarquait du de la version Selenium.

Panther en est encore à ses balbutiements. Vous voulez contribuer au projet, ajouter une nouvelle fonctionnalité, signaler (ou corriger) un bug ? Rendez-vous à cette adresse et n’oubliez pas de lui attribuer une étoile !