Tester son application fonctionnellement avec Panther

2021-11-17 | Pierre Picard

image article

Tester son application nécessite de trouver le bon compromis entre coût et pertinence. Les parties les plus critiques de l’application sont souvent couvertes par des tests d’intégration afin d’assurer une non-régression minimale.

Néanmoins, le côté interface utilisateur n’est pas couvert et le javascript éventuellement présent peut mener à des erreurs non détectées lors de la phase de build de l’application.

Les tests end-to-end vont nous permettre de détecter ces erreurs.

1°) Les tests End-to-End

L’objet des tests End-to-End (E2E) est de tester les chemins critiques de l’application (les cas nominaux ou des cas d’erreurs fréquents).

Les tests End-to-End sont des tests coûteux en ressources et en temps puisqu’ils simulent un navigateur.

Pyramide des tests :

Source : OpenClassroom

Ainsi, une application contiendra moins de tests E2E que de tests unitaires et d’intégration mais un seul échec de ces tests E2E traduira une erreur impactant directement l’utilisateur dans l’utilisation nominale de l’application ; donc une erreur critique.

2°) Panther

Panther est une bibliothèque qui permet d’extraire des contenus de sites web (“web scraping”) et de lancer des tests End-to-End (dépôt Github).

Cet outil tire partie du protocole WebDriver de W3C pour manipuler des navigateurs comme Mozilla Firefox ou Google Chrome de façon native car ceux-ci supportent cette norme.

L’implémentation de celle-ci se manifeste par la dépendance php-webdriver/php-webdriver (auparavant facebook/webdriver).

Dans cet article, nous allons illustrer l’utilisation de son API par rapport à plusieurs cas d’usage ; c'est-à-dire des cas nominaux de pages contenant du javascript ou non.

Panther est sponsorisé par les-tilleuls.coop.

3°) Cas d'usage

Les différents cas d'usage ont été réalisés avec Symfony et son composant Form.

a- Formulaire ne contenant pas de javascript

L’objet de ce formulaire est d’afficher les informations saisies en dessous de celui-ci lorsqu’il a été validé.

Apparence sur navigateur :

Code source du template Twig :

{% raw %}
{# first_example.html.twig #}

{% extends 'base.html.twig' %}

{% block title %}
My first example
{% endblock %}

{% block body %}
<main class="ui-width">
    <div class="container">
    <h2 class="mt-4">User creation</h2>
    <hr/>
    <div class="row">
        {% include '_add_user_form.html.twig' %}
    </div>
    {% if user_data %}
    <div class="row">
        <ul id="user-data">
            <li id="first-name">First name : {{ user_data.firstName }}</li>
            <li id="last-name">Last name : {{ user_data.lastName }}</li>
            <li id="email">Email : {{ user_data.email }}</li>
            <li id="password">Password : ********</li>
            <li id="company-name">Company name : {{ user_data.companyName }}</li>
        </ul>
    </div>
    {% endif %}
</main>
{% endblock %}
{% endraw %}

Lorsque l'on crée une classe de test E2E avec Panther, il est nécessaire de la faire hériter de PantherTestCase.

Dans notre cas, nous allons utiliser Firefox.

Un premier test simple consiste à vérifier que le contenu de la balise <title> correspond à ce que nous attendons.

<?php

namespace App\Tests;


use Symfony\Component\Panther\PantherTestCase;

class FirstExampleTest extends PantherTestCase
{
    public function testFirstExampleTitle(): void {
        // instantiate Firefox client
        $client = static::createPantherClient(['browser' => static::FIREFOX]);

        // request url
        $client->request('GET', '/first_example');

        self::assertPageTitleContains('Mon premier exemple');
    }
}

Nous allons maintenant procéder à la validation du formulaire :
<?php

namespace App\Tests;


use Symfony\Component\Panther\PantherTestCase;

class FirstExampleTest extends PantherTestCase
{
    private const FORM_SUBMIT_BUTTON_LABEL = 'Enregistrer';
    private const USER_DATA_SELECTOR = '#user-data';
    private const FIRST_NAME_SELECTOR = '#first-name';

    public function testFirstExampleForm() : void {
        $client = static::createPantherClient(['browser' => static::FIREFOX]);

        $client->request('GET', '/first_example');

        // fill the form
        $client->submitForm(self::FORM_SUBMIT_BUTTON_LABEL, [
            'add_user[firstName]' => 'Jean',
            'add_user[lastName]' => 'Dupont',
            'add_user[email]' => 'jean.dupont@itnetwork.fr',
            'add_user[password]' => 'azerty01',
            'add_user[companyName]' => 'ITNetwork'
        ]);

        // assert user data div exists
        self::assertSelectorExists(self::USER_DATA_SELECTOR);
        // assert first name value is as expected
        self::assertSelectorTextContains(self::FIRST_NAME_SELECTOR, 'Jean');
    }

}

Notons l'utilisation du "label" du bouton de validation du formulaire afin de procéder à sa soumission dans ce cas-ci.

Il existe néanmoins une alternative :

    public function testFirstExampleForm() : void {
        $client = static::createPantherClient(['browser' => static::FIREFOX]);

        $crawler = $client->request('GET', '/first_example');

        // fill the form 
        $form = $crawler->filter('form[name="add_user"]')->form();
        $form['add_user[firstName]'] = 'Jean';
        $form['add_user[lastName]'] = 'Dupont';
        $form['add_user[email]'] = 'jean.dupont@itnetwork.fr';
        $form['add_user[password]'] = 'azerty01';
        $form['add_user[companyName]'] = 'ITNetwork';
        $client->submit($form);

        self::assertSelectorExists(self::USER_DATA_SELECTOR);
        self::assertSelectorTextContains(self::FIRST_NAME_SELECTOR, 'Jean');
    }

Il s'agit de consommer la méthode : ```php $form = $crawler->filter('form[name="add_user"]')->form(); ``` Celle-ci utilise directement un sélecteur CSS pour récupérer le formulaire qui lui est lié, ce qui nous semble être une méthode plus robuste.

De plus, elle permet d'affecter des valeurs à certains champs du formulaire de manière unitaire :

$form['add_user[firstName]'] = 'Jean';

Cette dernière ligne affecte la valeur "Jean" au champ "First name" du formulaire sans soumettre son intégralité.

Remarques

Ce cas d'usage nous montre qu'il est assez simple et rapide de valider des formulaires sans javascript. De ce fait, il devient envisageable de contrôler des cas d'erreur en plus du cas nominal sans que cela ne constitue un coût conséquent.

b- Formulaire et javascript

L'objet de ce formulaire est d'afficher les informations saisies en bas de celui-ci ; comme pour le précédent.

Cependant, il posséde également la particularité d'avoir une checkbox qui lui permet d'afficher des informations supplémentaires à saisir concernant l'entreprise si celle-ci est cochée. De plus, l'affichage de ces informations supplémentaires s'affiche après un timeout de 1 seconde (idem désachiffage).

Apparence dans navigateur

Code source du template Twig

{% raw  %}
{# second_example.html.twig #}

{% extends 'base.html.twig' %}

{% block title %}
    My second example
{% endblock %}

{% block body %}
      <main class="ui-width">
        <div class="container">
            <h2 class="mt-4">User creation</h2>
            <hr/>
            <div class="row">
            {% include '_add_user_form.html.twig' %}
            </div>
            {% if user_data %}
                <div class="row mt-4">
                    <ul id="user-data">
                        <li id="first-name">First name : {{ user_data.firstName }}</li>
                        <li id="last-name">Last name : {{ user_data.lastName }}</li>
                        <li id="email">Email : {{ user_data.email }}</li>
                        <li id="password">Password : ********</li>
                        <li id="company-name">Company name : {{ user_data.companyName }}</li>
                        <li id="company-address">Company address : {{ user_data.companyAddress }}</li>
                        <li id="company-post-code">Company post code: {{ user_data.companyPostCode }}</li>
                        <li id="company-city">Company city : {{ user_data.companyCity }}</li>
                    </ul>
                </div>
            {% endif %}
        </div>
      </main>
{% endblock %}

{% block javascripts %}
<script type="application/javascript">
    function hide(element) {
        element.classList.add('d-none');
    }

    function show(element) {
        element.classList.remove('d-none');
    }

    function manageCompanyDetails() {
        const companyDetailsElement = document.getElementById('company-details');

        if(companyDetailsElement.classList.contains('d-none')) {
            show(companyDetailsElement);
        } else {
            hide(companyDetailsElement)
        }
    }

    document.getElementById('add_user_addCompanyDetails').addEventListener('change', e => {
        setTimeout(() => {
            manageCompanyDetails();
        }, 1000);
    });
</script>
{% endblock %}
{% endraw %}

**Formulaire inclus :**
{% raw  %}

{{ form_start(form) }}
{{ form_row(form.firstName) }}
{{ form_row(form.lastName) }}
{{ form_row(form.email) }}
{{ form_row(form.password) }}
{{ form_row(form.companyName) }}

{% if form.addCompanyDetails is defined %}
    {{ form_row(form.addCompanyDetails) }}
    <div id="company-details" {% if form.addCompanyDetails.vars.data != true %}class="d-none"{% endif %}>
        {{ form_row(form.companyAddress) }}
        {{ form_row(form.companyPostCode) }}
        {{ form_row(form.companyCity) }}
    </div>
{% endif %}

<input type="submit" value="Save"/>

{{ form_end(form) }}
{% endraw %}

Nous passons au cas de test de ce formulaire :
    public function testSecondExampleForm() : void {
        $client = static::createPantherClient(['browser' => static::FIREFOX]);

        $crawler = $client->request('GET', '/second_example');

        $form = $crawler->filter(self::SECOND_EXAMPLE_FORM_SELECTOR)->form();
        $form['add_user[firstName]'] = 'Jean';
        $form['add_user[lastName]'] = 'Dupont';
        $form['add_user[email]'] = 'jean.dupont@itnetwork.fr';
        $form['add_user[password]'] = 'azerty01';
        $form['add_user[companyName]'] = 'ITNetwork';

        $form['add_user[addCompanyDetails]'] = true;

        $client->waitForVisibility('#company-details');

        $form['add_user[companyAddress]'] = '15 rue du Pont';
        $form['add_user[companyPostCode]'] = '75000';
        $form['add_user[companyCity]'] = 'Paris';

        $client->submit($form);

        self::assertSelectorExists(self::USER_DATA_SELECTOR);
        self::assertSelectorTextContains(self::FIRST_NAME_SELECTOR, 'Jean');
    }

Notons que nous cochons bien la checkbox permettant d'afficher les champs de détail de l'entreprise.

Dans ce test, une méthode cruciale permet de vérifier le bon fonctionnement du formulaire, il s'agit de :

$client->waitForVisibility('#company-details');

Son rôle est d'attendre que l'élément souhaité (ici "#company-details") soit visible dans le navigateur.

Tant que cet élément n'est pas visible, il n'est pas possible de remplir les champs supplémentaires du formulaire. La visibilité est la condition sine qua non pour qu'un élément de formulaire soit remplissable.

Cela peut induire l'utilisation fréquente de cette méthode si le formulaire à tester est très interactif (formulaire à étapes par exemple).

De manière plus générale, Panther vient avec un ensemble de fonctions qui permettent d'attendre qu'un traitement asynchrone finisse. Voici la liste exhaustive présente sur le dépôt Github de Panther (dépôt) :

$client->waitFor('.popin'); // wait for element to be attached to the DOM
$client->waitForStaleness('.popin'); // wait for element to be removed from the DOM
$client->waitForVisibility('.loader'); // wait for element of the DOM to become visible
$client->waitForInvisibility('.loader'); // wait for element of the DOM to become hidden
$client->waitForElementToContain('.total', '25 €'); // wait for text to be inserted in the element content
$client->waitForElementToNotContain('.promotion', '5%'); // wait for text to be removed from the element content
$client->waitForEnabled('[type="submit"]'); // wait for the button to become enabled
$client->waitForDisabled('[type="submit"]'); // wait for  the button to become disabled
$client->waitForAttributeToContain('.price', 'data-old-price', '25 €'); // wait for the attribute to contain content
$client->waitForAttributeToNotContain('.price', 'data-old-price', '25 €'); // wait for the attribute to not contain content

Conclusion

Panther est un outil de test E2E simple d'utilisation et puissant par l'API qu'il met à disposition. Néanmoins, sa maintenance dans le temps peut s'avérer coûteuse en temps si l'application est sujette à des modifications lourdes côté front : il convient donc de bien choisir les parties de l'application à tester afin que les tests E2E perdurent et apportent une véritable plus-value en terme de détection de régression.

Tests Non régression End to end