Tests bout à bout avec Symfony et Panther

Publié le 04/04/2021 • Mis à jour le 04/04/2021

Dans cet article, nous allons voir comment faire un test "bout à bout" avec Symfony et Panther avec un exemple concret. C'est parti ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧 Close

Prérequis

Je présumerai que vous avez au moins des connaissances basiques de Symfony et que vous savez comment lancer des tests fonctionnels et unitaires.

Configuration

  • PHP 7.4
  • Symfony 5.2.6
  • Panther v1.0.1
  • Vue.js 2.6.12

Introduction

En tant que développeur backend, nous avons l'habitude d'écrire des tests unitaires et fonctionnels (enfin j'espère !). Les tests E2E (end-to-end) peuvent être un peu compliqués à mettre en place. Le but principal de Panther est de remédier à cela en proposant une expérience développeur (DX) similaire à ce dont nous avons l'habitude de faire lorsque l'on écrit des tests fonctionnels avec le WebTestCase de Symfony. Deux ans après sa première publication, Panther est désormais stable, voyons ce qu'il en est.

But

Nous allons avoir un exemple concret de test d'un formulaire d'inscription où un bouton de validation n'est affiché que si certaines conditions sont remplies.

Installation

Vous pouvez suivre aussi bien l'article sur le blog des Tilleuls (en français) que le README de la libraire sur GitHub(🇬🇧). Quelques conseils :

  • Votre bundle Symfony maker doit être à jour pour profiter des dernières options relatives à la commande make:tests.
  • Vos navigateurs doivent être à jour ou vous aurez des erreurs indiquant que les drivers ne supportent pas ces versions.
  • Soyez sûr d'avoir un contrôleur principal accessible : index.php, dans le répertoire public de votre application ou vous aurez des erreurs 404.

Le formulaire d'inscription d'un utilisateur

Tout d'abord nous avons besoin formulaire typique pour l'inscription d'un utilisateur, il sera très simple et contiendra juste un identifiant et un mot de passe. Nous allons récupérer le formulaire que j'ai utilisé dans un précédent article au sujet du validateur NotCompromisedPassword. Le voici :

Si vous jouez avec (c'est un vrai formulaire propulsé par Symfony ! 😉), vous constaterez que le bouton permettant de valider l'inscription n'apparait que si vous remplissez à la fois l'identifiant et le mot de passe. Voilà, nous avons notre formulaire de test, maintenant voyons comment créer notre premier test E2E !

Cliquez ici pour voir le code source de ce form type.
<?php

declare(strict_types=1);

// src/Form/AccountCreateType.php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

/**
 * Fake account creation form.
 */
final class AccountCreateType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('login', TextType::class, ['constraints' => [new NotBlank()]]);
        $builder->add('password', PasswordType::class, ['constraints' => [new NotBlank()]]);
        $builder->add('check_password', CheckboxType::class, ['required' => false]);
    }

    /**
     * Conditional validation depending on the checkbox.
     *
     * @param array<string,mixed> $data
     */
    public function validate(array $data, ExecutionContextInterface $context): void
    {
        // Not checked so continue.
        if (is_bool($data['check_password']) && !$data['check_password']) {
            return;
        }

        $violations = $context->getValidator()->validate($data['password'], [
            new NotCompromisedPassword(),
        ]);

        // If compromised assign the error to the password field
        if ($violations instanceof ConstraintViolationList && $violations->count() > 0) {
            $password = $context->getRoot()->get('password');
            if ($password instanceof Form) {
                $password->addError(new FormError((string) $violations));
            }
        }
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'constraints' => [
                new Callback([$this, 'validate']),
            ],
        ]);
    }
}

Création du scénario de test E2E

Il y a le bundle maker pour cela ™ :

bin/console make:test

Entrez les informations demandées comme ci-dessous :

$ bin/console make:test

 Which test type would you like?:
  [TestCase       ] basic PHPUnit tests
  [KernelTestCase ] basic tests that have access to Symfony services
  [WebTestCase    ] to run browser-like scenarios, but that don't execute JavaScript code
  [ApiTestCase    ] to run API-oriented scenarios
  [PantherTestCase] to run e2e scenarios, using a real-browser or HTTP client and a real web server
 > PantherTestCase

Choose a class name for your test, like:
 * UtilTest (to create tests/UtilTest.php)
 * Service\UtilTest (to create tests/Service/UtilTest.php)
 * \App\Tests\Service\UtilTest (to create tests/Service/UtilTest.php)

 The name of the test class (e.g. BlogPostTest):
 > BlogPost138Test

 created: tests/BlogPost138Test.php

          
 Success! 
          


 Next: Open your new test class and start customizing it.
 Find the documentation at https://github.com/symfony/panther#testing-usage

Ajoutons une première assertion tout en faisant un peu de nettoyage de ce qui a été généré. Tout d'abord, nous allons tester que nous accédons bien à notre article et que le tag <h1> contenant le titre est bien trouvé. Nous utilisons Firefox à la place de Chrome, car nous voulons utiliser un navigateur qui respecte la vie privée. Pour cela, nous passons l'option browser avec comme valeur la constante PantherTestCase::FIREFOX dans les paramètres du tableau du premier argument de la fonction createPantherClient(). J'ai déplacé ce fichier dans un nouveau sous répertoire App\Tests\E2E pour le séparer des autres tests unitaires et fonctionnels.

<?php

/** @noinspection PhpUndefinedClassInspection */

declare(strict_types=1);

namespace App\Tests\E2E;

use Symfony\Component\Panther\PantherTestCase;

final class BlogPost138Test extends PantherTestCase
{
    /**
     * @debug make test filter=BlogPost138Test
     */
    public function testPost138(): void
    {
        $client = self::createPantherClient([
            'browser' => PantherTestCase::FIREFOX,
        ]);
        $client->request('GET', '/en/blog/end-to-end-testing-with-symfony-and-panther');
        self::assertSelectorTextContains('h1', 'End-to-end testing with Symfony and Panther');
    }
}

Lançons-le avec la commande suivante. Ici, j'utilise mon Makefile qui me fournit un raccourci me permettant de lancer les tests que je veux. Vous pouvez trouver mon Makefile complet ici.

make test filter=BlogPost138Test
## which is equal to
./vendor/bin/phpunit --testsuite='main' --filter=BlogPost138Test --stop-on-failure
## We can also use:
./vendor/bin/phpunit tests/E2E/BlogPost138Test.php

Si tout est OK, on a la sortie suivante :

$ ./vendor/bin/phpunit tests/E2E/BlogPost138Test.php
PHPUnit 9.5.4 by Sebastian Bergmann and contributors.

Testing App\Tests\E2E\BlogPost138Test
.                                                                   1 / 1 (100%)

Time: 00:03.313, Memory: 32.50 MB

OK (1 test, 1 assertion)

Le test JavaScript

Dans la section précédente, nous avons initialisé un nouveau test, mais en fait nous aurions écrit quasiment exactement le même test fonctionnel avec le WebTestCase de Symfony. Avant d'écrire un test utilisant vraiment Panther, voyons comment notre formulaire fonctionne. Voici les quelques lignes gérant la permutation de l'affichage du bouton de validation. J'utilise la directive Vue :v-if pour afficher conditionnellement certains éléments. Veuillez noter que dans ce cas, si la condition n'est pas remplie, l'élément n'est même pas dans le DOM, du point de vue du navigateur, il n'existe tout simplement pas. Voici le snippet :

<div class="card-footer justify-content-center">
    <button id="subscribe_button_panther" v-if="this.post138.login.trim() !== '' && this.post138.password.trim() !== ''" class="btn btn-primary">{{ 'form2_submit'|trans }}</button>
    <p id="error_msg_panther" v-else class="h5">{{ 'form_error'|trans({}, 'post_138') }}</p>
</div>

La condition est assez explicite, on affiche le bouton de validation seulement si les deux champs ne sont pas vides. Maintenant, nous pouvons modifier notre test pour vérifier que le bouton apparait bien dès que ces deux champs sont remplis. Tout d'abord, on vérifie que le bouton n'existe pas et que le message d'erreur est affiché, puis l'inverse.

<?php

/** @noinspection PhpUndefinedClassInspection */

declare(strict_types=1);

namespace App\Tests\E2E;

use Symfony\Component\Panther\PantherTestCase;

final class BlogPost138Test extends PantherTestCase
{
    private const BUTTON_SELECTOR = '#subscribe_button_panther';
    private const ERROR_MESSAGE_SELECTOR = '#error_msg_panther';
    private const FORM_SELECTOR = '#account_create';

    /**
     * @debug make test filter=BlogPost138Test
     */
    public function testPost138(): void
    {
        $client = self::createPantherClient([
            'browser' => PantherTestCase::FIREFOX,
        ]);
        $crawler = $client->request('GET', '/en/blog/end-to-end-testing-with-symfony-and-panther');
        self::assertSelectorTextContains('h1', 'End-to-end testing with Symfony and Panther');

        // At first load, the error message is shown and the button isn't there
        self::assertSelectorExists(self::ERROR_MESSAGE_SELECTOR);
        self::assertSelectorNotExists(self::BUTTON_SELECTOR);

        // Fill the form so the subscribe button appears
        $crawler->filter(self::FORM_SELECTOR)->form([
            'account_create[login]' => 'Les',
            'account_create[password]' => 'Tilleuls',
        ]);
        $client->waitForVisibility(self::BUTTON_SELECTOR); // wait for the button to appear!

        // Ok, now the button is visble and the error message should be removed from the DOM!
        self::assertSelectorNotExists(self::ERROR_MESSAGE_SELECTOR);
        self::assertSelectorExists(self::BUTTON_SELECTOR);
    }
}

Lançons de nouveau les tests, nous devrions avoir cinq assertions valides et tout devrait être "vert". ✅ 🎉

$ ./vendor/bin/phpunit tests/E2E/BlogPost138Test.php
PHPUnit 9.5.4 by Sebastian Bergmann and contributors.

Testing App\Tests\E2E\BlogPost138Test
.                                                                   1 / 1 (100%)

Time: 00:05.814, Memory: 87.00 MB

OK (1 test, 5 assertions)

Outils communautaires et librairies

Même si Panther est relativement jeune, il a déjà un éco-système. Par exemple, la librairie zenstruck/browser fournit une très pratique interface fluide qui le supporte déjà. Voici le même test que nous avons écrit précédemment, mais en utilisant cette librairie.

<?php

/** @noinspection PhpUndefinedClassInspection */

declare(strict_types=1);

namespace App\Tests\E2E;

use Symfony\Component\Panther\PantherTestCase;
use Zenstruck\Browser\Test\HasBrowser;

final class BlogPost138ZenstruckTest extends PantherTestCase
{
    use HasBrowser;

    private const BUTTON_SELECTOR = '#subscribe_button_panther';
    private const ERROR_MESSAGE_SELECTOR = '#error_msg_panther';
    private const FORM_SELECTOR = '#account_create';

    /**
     * @debug make test filter=BlogPost138Zenstruck
     */
    public function testPost138(): void
    {
        $this->pantherBrowser(['browser' => PantherTestCase::FIREFOX])
            ->visit('/en/blog/end-to-end-testing-with-symfony-and-panther')
            ->assertSeeIn('h1', 'End-to-end testing with Symfony and Panther')
            ->assertSeeElement(self::ERROR_MESSAGE_SELECTOR)
            ->assertNotSeeElement(self::BUTTON_SELECTOR)
            ->fillField('account_create[login]', 'Les') // use the input name
            ->fillField('Password', 'Tilleuls')         // uses the input placeholder
            ->waitUntilVisible(self::BUTTON_SELECTOR)
            ->assertNotSeeElement(self::ERROR_MESSAGE_SELECTOR)
        ;
    }
}

Quelle version préférez-vous 🧐 ? Merci à Wouter de m'avoir fourni la conversion de ce test.😉.

Conclusion

Nous avons vu un exemple concret où nous avons testé les interactions entre un formulaire Symfony et Vue.js qui affiche des éléments conditionnellement. Nous avons seulement utilisé trois assertions spécifiques à Panther, il y a bien d'autres choses à découvrir, la liste est longue (captures d'écran, navigateur à distance...) ! Panther est parfaitement intégré dans l'environnement de test de Symfony et est un plaisir à utiliser. On est très loin de l'expérience développeur que j'ai pu rencontrer par le passé avec des outils similaires. Alors plus d'excuse pour ne pas essayer ! 😉

Panther est sponsorisé par Les-Tilleuls.coop. 🌳

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À tantôt ! COil. 😊

  Lire la doc  Plus sur le web

Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : wouterjnl, jmsche. 👍


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf le tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil