# Functional testing A series of 3 article(s), in reading order. # Introduction à Behat > Apprenez à utiliser Behat, le framework open source de Behavior-Driven Development (BDD) pour PHP. Cette introduction couvre l'écriture de tests fonctionnels puissants en langage naturel et la mise en place de votre premier projet. --- ![Introduction à Behat](/img/v2/functional_tests.webp) **TL;DR** Ce tutoriel présente le Behavior-Driven Development (BDD) en PHP avec Behat et Docker. Il couvre la mise en place d'un environnement de test avec Chrome et Mink, l'écriture de scénarios Gherkin et l'implémentation des définitions d'étapes pour automatiser les tests fonctionnels d'applications web. > Lisez aussi mon article sur [Cypress](/blog/cypress) Au travail, je fais partie d'une équipe de développeurs PHP qui construit ensemble une très grosse application propriétaire avec une interface web. Cette application est développée avec le framework Laravel et nous utilisons [PHPUnit](https://phpunit.de/index.html) et [Pest](https://pestphp.com/) pour exécuter nos tests unitaires (voir [Write PHP unit tests using Pest](/blog/pest_tips)). Mais en plus des tests unitaires, nous mettons aussi en place un outil qui simule des actions sur notre interface : accéder à la page de login, saisir un identifiant et un mot de passe, simuler le clic sur le bouton « login », attendre que notre écran principal s'affiche puis, sur la nouvelle page, vérifier un tas de choses / effectuer un tas de tâches. C'est un peu comme demander à un humain de rejouer des scénarios encore et encore, chaque jour, pour s'assurer que nous n'avons pas introduit de régression dans nos derniers développements — par exemple une belle nouvelle fonctionnalité dont les changements de code ont cassé une fonctionnalité existante. Pour cela, nous utilisons [Behat](https://docs.behat.org/en/latest/). C'est une dépendance PHP [composer](https://getcomposer.org/) que vous pouvez ajouter à votre projet (ou à un nouveau). L'idée est de pouvoir écrire des assertions en anglais (ou en français) courant, du genre « je vais sur le site ABC puis je clique sur l'entrée de menu blog et je devrais recevoir une liste d'articles » ou « dans la zone de recherche, je tape Docker puis je clique sur le bouton de recherche et je devrais recevoir des articles portant le tag Docker ». Dans cet article, nous allons en apprendre plus sur Behat et voir comment l'utiliser pour un premier test. Comme on peut le lire sur le site de [Behat](https://docs.behat.org/en/latest/), *Behat est un framework open source de Behavior-Driven Development pour PHP. C'est un outil qui vous aide à livrer un logiciel qui compte, grâce à une communication continue, une découverte délibérée et l'automatisation des tests.* Cela veut dire que vous pouvez commencer à écrire vos scénarios avant même de coder. Vous pouvez demander à votre client d'écrire ses assertions en langage courant. Ensuite, le développeur se met à coder et, une fois terminé, il lui suffit d'exécuter les scénarios écrits pour s'assurer que le logiciel répond aux exigences du client. ## Introduction au BDD - Behavior-Driven Development {#introduction-to-bdd---behavior-driven-development} Imaginez que vous êtes un client et que vous demandez à un développeur de créer un nouveau site web. Dans votre cahier des charges, vous lui écrivez des choses comme : * En tant que visiteur, je voudrais une entrée de menu qui, une fois cliquée, va (faire ceci) ; * En tant que visiteur, je veux pouvoir accéder à un moteur de recherche qui me permet de faire une sélection dans une catégorie d'articles puis, dans cette catégorie, de lancer une recherche sur le mot (un mot). Une liste d'articles sur ce thème s'affiche alors ; * En tant que gestionnaire du site, je veux pouvoir me connecter à une interface d'administration et, après un login réussi, je dois voir les options (liste des fonctionnalités) ; * En tant que gestionnaire du site, je dois pouvoir ajouter un nouvel article où je dois spécifier un titre, une catégorie, une image principale et un texte. Une fois enregistré, je dois voir le nouvel article dans la liste des articles et, en triant la liste sur la date/heure de création, il doit être le premier de la liste ; * (et beaucoup plus) En termes Behat, ces phrases s'appellent des scénarios. Vous pouvez les écrire avant même que le site existe. Et pendant les étapes de codage, le développeur exécutera vos scénarios de temps en temps. Au premier lancement, tous les scénarios vont échouer (le site n'existe pas ; les fonctions demandées ne sont pas encore développées). Le développeur travaillera sur l'une ou l'autre fonctionnalité ; il relancera les tests BDD et, au fil du projet, les scénarios passeront au vert jusqu'à la fin du projet, quand tous les scénarios seront réussis et que le projet pourra vous être envoyé pour recette. ## Résultat {#result} Un scénario est juste un fichier `.feature` écrit en anglais courant, une ligne par étape : **Blog.feature** ```feature Feature: Clicking on the Blog menu item should give me the list of articles Scenario: I click on the Blog menu Given I am on "https://www.avonture.be" Then I click on the "Blog" menu item Then I should be on "/blog" ``` **Ce scénario utilise trois étapes :** 1. `Given I am on "https://www.avonture.be"` 2. `Then I click on the "Blog" menu item` et 3. `Then I should be on "/blog"`. Avant d'écrire une seule ligne de PHP, demandez simplement à Behat de l'exécuter — il comprend déjà le scénario et nous dit exactement ce qui manque encore : ![Premier lancement](./images/first_run.webp) C'est tout l'argument : des étapes en langage courant, analysées et suivies par Behat, et du PHP uniquement pour faire en sorte que chaque étape *fasse* réellement quelque chose. Le reste de cet article construit ce PHP. ## Mettre en place le projet {#set-up-the-project} Pour cet article, nous allons créer un nouveau projet Behat et valider ce scénario : **« En tant que visiteur, je veux pouvoir visiter le site XYZ ; cliquer sur un élément de navigation appelé "Blog". Le site m'affiche alors la liste des articles de blog. »** Comme vous le savez si vous lisez régulièrement mon blog, je bois, mange et respire Docker, ce qui veut dire que je n'ai plus ni PHP, ni composer, ni Apache sur mon ordinateur. Donc, pour notre nouveau défi ci-dessus (que j'accepte avec plaisir), je vais créer un petit projet avec Docker, PHP 8.2 et composer. C'est parti... * D'abord, créons un nouveau répertoire : `mkdir /tmp/behat && cd $_`. * Là, créons un fichier appelé `Dockerfile` avec le contenu ci-dessous. Ce script est déjà gros mais, comme ça, nous aurons tout ce qu'il faut dès maintenant. **Dockerfile** ```docker # cspell:disable # The user ID and name and group ID of the user to create in our image ARG DOCKER_UID=1000 ARG DOCKER_GID=1000 ARG USERNAME=johndoe # The version of Chrome we'll install ARG CHROME_VERSION=115.0.5763.0 # This is a PHP project FROM php:8.2-fpm # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends git jq zip unzip wget gnupg \ && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/* # We'll create a new user with the same uid/gid than ours, on our host machine. ARG DOCKER_UID ARG DOCKER_GID ARG USERNAME RUN groupadd --gid ${DOCKER_GID} "${USERNAME}" \ && useradd --home-dir /home/"${USERNAME}" --create-home --uid ${DOCKER_UID} \ --gid ${DOCKER_GID} --shell /bin/sh --skel /dev/null "${USERNAME}" # Download and install Chrome ARG CHROME_VERSION ARG DOWNLOAD_URL="https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json" # Download Chrome (the browser and the driver) RUN wget --no-check-certificate --no-verbose -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ && sh -c 'echo "deb https://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ && apt-get update -yqq \ && apt-get install -y --no-install-recommends -yq gconf-service libasound2 libatk1.0-0 libc6 libcairo2 \ libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 \ libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 \ libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \ libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \ libxss1 libxtst6 ca-certificates fonts-liberation libnss3 lsb-release \ xdg-utils \ && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/* \ # --- Download the browser --- && ZIP_URL=$(curl $DOWNLOAD_URL | jq -r ".versions[] | select(.version==\""$CHROME_VERSION"\").downloads.chrome[] | select(.platform==\""linux64"\") .url") \ && wget --no-check-certificate --no-verbose -O /tmp/chrome_browser.zip $ZIP_URL \ && printf "\e[0;105m%s\e[0;0m\n" "Using chromedriver $(/usr/local/bin/chrome/chromedriver --version)" \ # Unzip and create the /usr/local/bin/chromedriver executable (-j means don't create a subfolder with the name of the archive; unzip in the folder directly) && unzip -j /tmp/chrome_browser.zip -d /usr/local/bin/chrome \ && rm -f /tmp/chrome_browser.zip \ && ls -alh /usr/local/bin/chrome \ && chmod +x /usr/local/bin/chrome/chrome \ # --- Download the driver --- && ZIP_URL=$(curl $DOWNLOAD_URL | jq -r ".versions[] | select(.version==\""$CHROME_VERSION"\").downloads.chromedriver[] | select(.platform==\""linux64"\") .url") \ && wget --no-check-certificate --no-verbose -O /tmp/chrome_driver.zip $ZIP_URL \ # Unzip and create the /usr/local/bin/chromedriver executable (-j means don't create a subfolder with the name of the archive; unzip in the folder directly) && unzip -j /tmp/chrome_driver.zip -d /usr/local/bin/chrome \ && rm -f /tmp/chrome_driver.zip \ && chmod +x /usr/local/bin/chrome/chrome \ # Make some cleanup && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/* # Get latest composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer # Where we'll put our script RUN mkdir /opt/behat # Set working directory WORKDIR /opt/behat ``` * Créez un fichier appelé `compose.yaml` avec ce contenu : **compose.yaml** ```yaml # cspell:disable services: app: build: context: . args: # Please update IDs below if you've another values by running "id -u" (UID) and "id -g" (GID) in your Linux console - DOCKER_UID=1000 - DOCKER_GID=1000 # The name of the user in Docker; didn't have any importance - USERNAME=johndoe container_name: php-app working_dir: /opt/behat volumes: # We'll synchronize our local project folder (on our machine) with the Docker container - ./:/opt/behat ``` * Lancez `docker compose up --detach` pour créer votre container Docker Nous allons vérifier que le driver Chrome est bien installé en lançant `docker compose exec -u $(id -u):$(id -g) app /usr/local/bin/chrome/chromedriver --version`. Idem pour le navigateur Chrome : `docker compose exec -u $(id -u):$(id -g) app /usr/local/bin/chrome/chrome --version`. Parfait ! Voir ces valeurs signifie que Chrome est prêt à être utilisé. ![Versions de Chrome](./images/chrome_versions.webp) Ok, encore une chose : nous devons créer notre fichier `composer.json` puisqu'il faut inclure la dépendance PHP `Behat`. * Lancez `docker compose exec -u $(id -u):$(id -g) app /bin/bash` pour démarrer un shell interactif dans votre container Docker en étant vous-même (c'est-à-dire qu'avec `-u $(id -u):$(id -g)`, les fichiers et dossiers créés dans Docker vous appartiendront), * Lancez `composer init` pour démarrer l'assistant composer ; saisissez les valeurs que vous voulez, par exemple * Package name : `johndoe/behat`, * Description : `Introduction to Behat`, * Author : `John Doe`, * Minimum Stability : *appuyez simplement sur Entrée*, * Package Type : `project`, * License : *appuyez simplement sur Entrée*, * Composer vous demandera si vous voulez définir des dépendances et des dépendances de dev ; répondez `no` deux fois pour l'instant, * La question suivante concerne PSR-4, *appuyez simplement sur Entrée*, * Enfin, répondez `yes` au message de confirmation. Maintenant, si vous êtes curieux, vous verrez que vous avez un nouveau fichier appelé `composer.json` et deux dossiers appelés `src` et `vendor`. ![Composer a été installé](./images/composer_installed.webp) ## Installons Behat {#lets-install-behat} Comme indiqué dans la documentation [How to install?](https://docs.behat.org/en/latest/quick_start.html#installation), il suffit d'exécuter `composer require --dev behat/behat:^3` pour installer Behat comme dépendance de dev. > **Information:** > > Assurez-vous d'être toujours dans une session Bash interactive dans le container Docker avant de lancer `composer require --dev behat/behat:^3`. Une session Bash interactive se démarre en lançant `docker compose exec -u $(id -u):$(id -g) app /bin/bash`. ![Installer Behat](./images/composer_install_behat.webp) Une fois installé, vous pouvez démarrer Behat en lançant `vendor/bin/behat` mais, pour l'instant, vous obtiendrez une erreur et c'est parfaitement normal puisque nous devons d'abord écrire notre premier scénario. ![FeatureContext introuvable](./images/FeatureContext_not_found.webp) Comme indiqué dans la documentation officielle, lancez simplement `vendor/bin/behat --init` pour créer les fichiers minimums requis. ![Exécution de behat init](./images/behat_init.webp) Maintenant, si vous regardez votre dossier, vous voyez un nouveau dossier appelé `features/bootstrap` avec un fichier appelé `FeatureContext.php`. La capture ci-dessous illustre à quoi ressemble le projet dans mon éditeur VSCode à ce stade du tutoriel : ![FeatureContext dans vscode](./images/vscode_FeatureContext.webp) ## Écrire vos étapes en PHP {#writing-your-steps-in-php} Cela doit être placé dans un fichier portant l'extension `.feature` dans le dossier `features` ; créons le fichier `Blog.feature` (montré au début de cet article) avec ce contenu : ![Le fichier Blog.feature](./images/blog_feature.webp) > **Pensez à installer `Cucumber (Gerkin) Full Support`:** > > Si vous utilisez Visual Studio Code, vous pouvez installer [Cucumber (Gerkin) Full Support](https://marketplace.visualstudio.com/items?itemName=alexkrechik.cucumberautocomplete) pour obtenir la coloration et une meilleure intégration dans vscode, comme l'autocomplétion. Maintenant que nous avons notre scénario, nous devons apprendre à Behat comment *traduire* ces phrases (c'est le [langage Gherkin](https://cucumber.io/docs/gherkin/)) dans notre code PHP. Ouvrez maintenant le fichier `features/bootstrap/FeatureContext.php` dans votre éditeur préféré : ![Édition du fichier FeatureContext.php](./images/vscode_edit_0_FeatureContext.webp) Comme vous le voyez, c'est le fichier par défaut créé plus tôt. Nous devons y écrire du code PHP, mais comment ? Retournez dans votre console Bash interactive et lancez `vendor/bin/behat --dry-run --append-snippets`. Cela demande à Behat de créer les étapes pour vous, automagiquement. ![Append snippets](./images/append_snippets.webp) Tapez `1` pour demander à Behat d'utiliser votre fichier `FeatureContext.php`. Vous obtiendrez alors une confirmation à l'écran : ```bash u features/bootstrap/FeatureContext.php - `I am on "https://www.avonture.be"` definition added u features/bootstrap/FeatureContext.php - `I click on the "Blog" menu item` definition added u features/bootstrap/FeatureContext.php - `I should be on "/blog"` definition added ``` Ouvrez à nouveau le fichier `features/bootstrap/FeatureContext.php` dans votre éditeur préféré : ![Nous avons maintenant nos étapes](./images/vscode_edit_1_FeatureContext.webp) Sympa, non ? Behat a regardé notre fichier `Blog.feature` et a créé autant de méthodes dans notre code PHP que nous avions de phrases dans notre scénario, et chaque méthode est associée à sa phrase (regardez le commentaire PHP avant chaque méthode). Et maintenant, avant même de commencer à coder, demandons à Behat d'exécuter notre scénario ; lancez `vendor/bin/behat` dans la console — c'est la sortie « Premier lancement » montrée au début de cet article. Wow ! Jusqu'ici, parfait ! Nous avons donc demandé à Behat d'exécuter notre scénario et il sait que nous avons trois étapes et qu'il nous reste à écrire le code associé en PHP (d'où le message **TODO: write pending definition** en jaune). Donnons-nous les moyens de faire au mieux le plus vite possible, sans réinventer la roue si quelqu'un d'autre l'a déjà fait. Installons deux nouvelles dépendances. Dans votre console, lancez `composer require --dev friends-of-behat/mink` et `composer require --dev dmore/behat-chrome-extension`. `friends-of-behat/mink` est une très chouette dépendance qui arrive avec des étapes déjà prédéfinies : faire un login, naviguer vers des pages, vérifier que la page courante est ... Et en utilisant `friends-of-behat/mink`, la vie sera plus simple et nous n'aurons pas à réinventer la roue. ## Utiliser Mink {#using-mink} Maintenant que Mink est installé, allez dans votre éditeur, ouvrez le fichier `features/bootstrap/FeatureContext.php` et remplacez la ligne `class FeatureContext implements Context` par `class FeatureContext extends \Behat\MinkExtension\Context\MinkContext`. ![Utilisation de MinkContext](./images/MinkContext.webp) Ce petit changement nous donne beaucoup de pouvoir car notre code utilise désormais Mink et bénéficie donc de toutes ses méthodes existantes. Pour obtenir la liste des méthodes existantes, lançons `clear ; vendor/bin/behat -di` dans votre console : ![Affichage des définitions](./images/print_definitions.webp) Vous avez vu ? Nous avons nos trois étapes (les trois premières affichées) puis des étapes supplémentaires venant de Mink. Et vous pouvez scroller longtemps : il y a déjà beaucoup d'étapes que Mink vous permet de réutiliser. Relancez `vendor/bin/behat` dans la console : ![Correspondance ambiguë](./images/ambiguous_match.webp) Nous obtenons l'erreur *Ambiguous match of ...* sur la toute première étape, notre étape *I am on *. Ambiguë signifie que nous essayons d'ajouter du code PHP dans notre contexte alors que cette étape est déjà définie (par Mink). Donc, supprimez-la simplement : éditez votre fichier `FeatureContext.php`, sélectionnez `public function iAmOn` et supprimez toute la fonction. ![Supprimer la méthode iAmOn](./images/drop_iamon.webp) En relançant `vendor/bin/behat`, vous aurez la même erreur pour `iShouldBeOn` ; supprimez donc aussi cette fonction. Lancez `clear ; vendor/bin/behat` une fois de plus et nous obtenons maintenant une nouvelle erreur : ![Instance Mink non définie](./images/mink_instance_not_set.webp) ## Installer l'extension Mink {#installing-the-mink-extension} Nous devons maintenant référencer `behat-chrome-extension` dans un fichier appelé `behat.yml`. Créez le fichier `behat.yml` à la racine de votre projet, avec ce contenu : **behat.yaml** ```yaml default: extensions: DMore\ChromeExtension\Behat\ServiceContainer\ChromeExtension: ~ Behat\MinkExtension: browser_name: chrome base_url: https://www.avonture.be sessions: default: chrome: api_url: http://0.0.0.0:9222 ``` Éditez également le fichier `features/bootstrap/FeatureContext.php`, supprimez tout et remplacez par le contenu ci-dessous. Nous avons modifié quelques instructions `use` pour ajouter les bibliothèques Mink (et retirer celles qui ne servent plus). Nous avons aussi ajouté une propriété privée `$mink` et quelques lignes dans le `__constructor`. > **Pensez à adapter l'url `https://www.avonture.be` à votre site:** **features/bootstrap/FeatureContext.php** ```php mink = new Mink( [ 'browser' => new Session( new ChromeDriver( 'http://0.0.0.0:9222', null, "https://www.avonture.be" // <-- Think to update to the site you wish to test ) ) ] ); } /** * @Then I click on the :arg1 menu item */ public function iClickOnTheMenuItem($arg1) { throw new PendingException(); } } ``` En relançant `vendor/bin/behat`, nous avons maintenant une autre erreur : ![Could not fetch version information](./images/could_not_fetch.webp) ## Place à Chrome {#time-to-run-chrome} Créez un fichier appelé `run.sh` avec ce contenu : **run.sh** ```bash #!/usr/bin/env bash clear # We'll start Chrome on URL http://0.0.0.0:9222 # If this URL has to be modified, think to update the constructor of FeatureContext.php too # Note: /usr/bin/google-chrome-stable has been installed in our Dockerfile nohup /usr/local/bin/chrome/chrome --headless --remote-debugging-address=0.0.0.0 \ --remote-debugging-port=9222 --no-sandbox --window-size="1920,1080" --disable-dev-shm-usage \ --disable-extensions --no-startup-window --no-first-run --no-pings > /dev/null 2>&1 & # Get the process ID of chrome so we can kill the process when we've finished chromePID=$! # shellcheck disable=SC2048,SC2086 php -d memory_limit=-1 "vendor/bin/behat" --config=behat.yaml if ((chromePID > 0)); then kill "${chromePID}" > /dev/null 2>&1 fi ``` Puis rendez le fichier exécutable en lançant `chmod +x ./run.sh`. Vous pouvez maintenant démarrer Behat en lançant `./run.sh` dans votre console. Si tout se passe bien, vous obtiendrez ceci : ![Premier lancement](./images/run_sh_pending.webp) Comme vous le voyez, la ligne `Given I am on...` est en vert : Behat, grâce à notre driver Chrome, a réussi à atteindre la page. Ensuite, la ligne `Then I click on the "Blog" menu item` est en jaune et c'est normal puisque nous n'avons pas encore créé la fonction et, logiquement, `Then I should be on "/blog"` est en bleu parce que cette phrase n'a pas encore été exécutée (skippée). ## Écrire notre méthode iClickOnTheMenuItem {#writing-our-iclickonthemenuitem-method} Retour au fichier `bootstrap/FeatureContext.php`. Remplacez la méthode `iClickOnTheMenuItem` par ce code : **features/bootstrap/FeatureContext.php** ```php /** * @Then I click on the :menuItem menu item */ public function iClickOnTheMenuItem(string $menuItem): void { if (!is_dir('.output')) { mkdir('.output', 0777); } // When entering in the method, take a screenshot so we can assert we're on the homepage file_put_contents('.output/homepage.png', $this->getSession()->getDriver()->getScreenshot()); throw new PendingException(); } ``` Comme vous le voyez, nous allons créer un dossier `.output` et la seule chose que nous faisons est de prendre une capture d'écran. Juste après avoir relancé `./run.sh` dans votre console, vous devriez voir un nouveau dossier `.output` avec une image : ![Nous sommes sur la page d'accueil](./images/screenshot_homepage.webp) Qu'avons-nous fait ? Nous avons vérifié que lorsque Behat exécute notre méthode `iClickOnTheMenuItem`, Chrome accède bien à notre page d'accueil. Nous pouvons maintenant simuler un clic sur l'entrée de menu voulue : le menu Blog stocké dans notre paramètre $menuItem. Remplacez la méthode par ce nouveau code : **features/bootstrap/FeatureContext.php** ```php /** * @Then I click on the :menu menu item */ public function iClickOnTheMenuItem(string $menu): void { if (!is_dir('.output')) { mkdir('.output', 0777); } // When entering in the method, take a screenshot so we can assert we're on the homepage file_put_contents('.output/homepage.png', $this->getSession()->getDriver()->getScreenshot()); /** * @var $elements Behat\Mink\Element\NodeElement */ $elements = $this->getSession()->getPage()->findAll('css', 'a[class="navbar__item navbar__link"]'); // Now we'll get all links from our navigation bar. $elements will contain more than one link in our case. // The CSS selector has been written using our Web developer console; in Edge or Chromium for instance. // Loop any elements (our navigation links) and when we've found the one having `Blog` as text, click on it foreach ($elements as $element) { if ($element->getText() === $menu) { $element->click(); } } // Give one second to the website to handle the click and, so, to navigate to, as we expect it, the `/blog` path sleep(1); // Take a second image, now we should be in the list of posts file_put_contents('.output/iClickOnBlog.png', $this->getSession()->getDriver()->getScreenshot()); } ``` Lancez `./run.sh` une fois de plus et bingo ! ![Succès](./images/success.webp) C'est un succès parce que le `Then I should be on "/Blog"` a été vérifié par Mink. Nous n'avons pas besoin d'écrire la méthode `Then I should be on`, elle est déjà définie par Mink. Essayons autre chose : retour dans le PHP et remplacez `if ($element->getText() === $menu) {` par `if ($element->getText() === 'Archive') {`. Nous ne cliquerons donc plus sur le menu Blog mais sur `Archive` ; voyons ce qui se passe : ![Échec](./images/failure.webp) Et ça échoue ; parfait ! ## Conclusion {#conclusion} C'est la fin de ce tutoriel. Avec Behat et Mink, nous pouvons automatiser les tests fonctionnels. Nous allons pouvoir écrire des scénarios, simuler des actions utilisateur comme cliquer sur n'importe quel objet de la page, uploader des documents, faire des choses comme insérer un nouvel article de blog puis injecter du contenu dans l'éditeur et, après avoir enregistré l'élément, visiter la page du blog, la trier par date et vérifier que notre nouvel article s'affiche bien et qu'il est le premier de la liste. Nous pouvons le faire avant que l'application existe (concept du BDD), pendant le codage (pour s'assurer que les fonctionnalités marchent toujours, même après un gros refactoring par exemple) ou pendant tout le cycle de vie (pour s'assurer que l'application fonctionne toujours comme prévu). Continuez votre exploration grâce à ces sites : * [Automate testing with Behat](https://docs.pantheon.io/behat), * [Behat-chrome-extension repository](https://gitlab.com/behat-chrome/behat-chrome-extension), * [Behat: The Easy and Effective Way to Write Acceptance Tests](https://dev.to/jszutkowski/behat-the-easy-and-effective-way-to-write-acceptance-tests-cm4), * [Behat - Official website](https://docs.behat.org/en/latest/), * [Cucumber - Gherkin Tutorial](https://cucumber.io/), * [DMore - Chrome Mink Driver](https://packagist.org/packages/dmore/chrome-mink-driver) et * [Mink at a Glance](https://mink.behat.org/en/latest/at-a-glance.html) ## Annexe - Liste des fichiers, leur contenu et comment les exécuter {#appendix---list-of-files-and-their-contents-and-how-to-run-them} Dans ce tutoriel, nous avons manipulé beaucoup de fichiers et de commandes. Pour vous permettre de vérifier que votre version est bien celle utilisée à la fin de cet article, vous trouverez ci-dessous chaque fichier avec son contenu en fin de tutoriel. Pour rappel, quand tous les fichiers ci-dessous ont été créés sur votre disque, vous devez d'abord créer votre image Docker et un container ; cela se fait en lançant `docker compose up --detach` dans votre console. La deuxième commande à lancer est `docker compose exec -u $(id -u):$(id -g) app composer install` pour installer toutes les dépendances PHP. Enfin, pour exécuter notre script `run.sh`, vous devez lancer `docker compose exec -u $(id -u):$(id -g) app ./run.sh` ### behat.yaml {#behatyaml} **behat.yaml** ```yaml default: extensions: DMore\ChromeExtension\Behat\ServiceContainer\ChromeExtension: ~ Behat\MinkExtension: browser_name: chrome base_url: https://www.avonture.be sessions: default: chrome: api_url: http://0.0.0.0:9222 ``` ### Blog.feature {#blogfeature} Le nom de fichier relatif est `features/Blog.feature`. **features/Blog.feature** ```feature Feature: Clicking on the Blog menu item should gives me the list of articles Scenario: I click on the Blog menu Given I am on "https://www.avonture.be" Then I click on the "Blog" menu item Then I should be on "/blog" ``` ### composer.json {#composerjson} **composer.json** ```json { "name": "johndoe/behat", "description": "Introduction to behat", "type": "project", "autoload": { "psr-4": { "Johndoe\\Behat\\": "src/" } }, "authors": [ { "name": "John Doe" } ], "require-dev": { "behat/behat": "^3", "behat/mink-extension": "^2.3", "dmore/behat-chrome-extension": "^1.4", "friends-of-behat/mink": "^1.11" } } ``` ### compose.yaml {#composeyaml} **compose.yaml** ```yaml # cspell:disable services: app: build: context: . args: # Please update IDs below if you've another values by running "id -u" (UID) and "id -g" (GID) in your Linux console - DOCKER_UID=1000 - DOCKER_GID=1000 # The name of the user in Docker; didn't have any importance - USERNAME=johndoe container_name: php-app working_dir: /opt/behat volumes: # We'll synchronize our local project folder (on our machine) with the Docker container - ./:/opt/behat ``` ### Dockerfile {#dockerfile} **Dockerfile** ```docker # cspell:disable # The user ID and name and group ID of the user to create in our image ARG DOCKER_UID=1000 ARG DOCKER_GID=1000 ARG USERNAME=johndoe # The version of Chrome we'll install ARG CHROME_VERSION=115.0.5763.0 # This is a PHP project FROM php:8.2-fpm # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends git jq zip unzip wget gnupg \ && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/* # We'll create a new user with the same uid/gid than ours, on our host machine. ARG DOCKER_UID ARG DOCKER_GID ARG USERNAME RUN groupadd --gid ${DOCKER_GID} "${USERNAME}" \ && useradd --home-dir /home/"${USERNAME}" --create-home --uid ${DOCKER_UID} \ --gid ${DOCKER_GID} --shell /bin/sh --skel /dev/null "${USERNAME}" # Download and install Chrome ARG CHROME_VERSION ARG DOWNLOAD_URL="https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json" # Download Chrome (the browser and the driver) RUN wget --no-check-certificate --no-verbose -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ && sh -c 'echo "deb https://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ && apt-get update -yqq \ && apt-get install -y --no-install-recommends -yq gconf-service libasound2 libatk1.0-0 libc6 libcairo2 \ libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 \ libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 \ libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \ libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \ libxss1 libxtst6 ca-certificates fonts-liberation libnss3 lsb-release \ xdg-utils \ && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/* \ # --- Download the browser --- && ZIP_URL=$(curl $DOWNLOAD_URL | jq -r ".versions[] | select(.version==\""$CHROME_VERSION"\").downloads.chrome[] | select(.platform==\""linux64"\") .url") \ && wget --no-check-certificate --no-verbose -O /tmp/chrome_browser.zip $ZIP_URL \ && printf "\e[0;105m%s\e[0;0m\n" "Using chromedriver $(/usr/local/bin/chrome/chromedriver --version)" \ # Unzip and create the /usr/local/bin/chromedriver executable (-j means don't create a subfolder with the name of the archive; unzip in the folder directly) && unzip -j /tmp/chrome_browser.zip -d /usr/local/bin/chrome \ && rm -f /tmp/chrome_browser.zip \ && ls -alh /usr/local/bin/chrome \ && chmod +x /usr/local/bin/chrome/chrome \ # --- Download the driver --- && ZIP_URL=$(curl $DOWNLOAD_URL | jq -r ".versions[] | select(.version==\""$CHROME_VERSION"\").downloads.chromedriver[] | select(.platform==\""linux64"\") .url") \ && wget --no-check-certificate --no-verbose -O /tmp/chrome_driver.zip $ZIP_URL \ # Unzip and create the /usr/local/bin/chromedriver executable (-j means don't create a subfolder with the name of the archive; unzip in the folder directly) && unzip -j /tmp/chrome_driver.zip -d /usr/local/bin/chrome \ && rm -f /tmp/chrome_driver.zip \ && chmod +x /usr/local/bin/chrome/chrome \ # Make some cleanup && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/* # Get latest composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer # Where we'll put our script RUN mkdir /opt/behat # Set working directory WORKDIR /opt/behat ``` ### FeatureContext.php {#featurecontextphp} Le nom de fichier relatif est `features/bootstrap/FeatureContext.php`. **features/bootstrap/FeatureContext.php** ```php mink = new Mink( [ 'browser' => new Session( new ChromeDriver( 'http://0.0.0.0:9222', null, "https://www.avonture.be" // <-- Think to update to the site you wish to test ) ) ] ); } /** * @Then I click on the :menu menu item */ public function iClickOnTheMenuItem(string $menu): void { if (!is_dir('.output')) { mkdir('.output', 0777); } // When entering in the method, take a screenshot so we can assert we're on the homepage file_put_contents('.output/homepage.png', $this->getSession()->getDriver()->getScreenshot()); /** * @var $elements Behat\Mink\Element\NodeElement */ $elements = $this->getSession()->getPage()->findAll('css', 'a[class="navbar__item navbar__link"]'); // Now we'll get all links from our navigation bar. $elements will contain more than one link in our case. // The CSS selector has been written using our Web developer console; in Edge or Chromium for instance. // Loop any elements (our navigation links) and when we've found the one having `Blog` as text, click on it foreach ($elements as $element) { if ($element->getText() === $menu) { $element->click(); } } // Give one second to the website to handle the click and, so, to navigate to, as we expect it, the `/blog` path sleep(1); // Take a second image, now we should be in the list of posts file_put_contents('.output/iClickOnBlog.png', $this->getSession()->getDriver()->getScreenshot()); } } ``` ### run.sh {#runsh} **run.sh** ```bash #!/usr/bin/env bash clear # We'll start Chrome on URL http://0.0.0.0:9222 # If this URL has to be modified, think to update the constructor of FeatureContext.php too # Note: /usr/bin/google-chrome-stable has been installed in our Dockerfile nohup /usr/local/bin/chrome/chrome --headless --remote-debugging-address=0.0.0.0 \ --remote-debugging-port=9222 --no-sandbox --window-size="1920,1080" --disable-dev-shm-usage \ --disable-extensions --no-startup-window --no-first-run --no-pings > /dev/null 2>&1 & # Get the process ID of chrome so we can kill the process when we've finished chromePID=$! # shellcheck disable=SC2048,SC2086 php -d memory_limit=-1 "vendor/bin/behat" --config=behat.yaml if ((chromePID > 0)); then kill "${chromePID}" > /dev/null 2>&1 fi ``` --- # Introduction à Cypress > Démarrez avec Cypress pour vos tests fonctionnels. Ce guide complet couvre l'installation, l'exécution des tests dans des containers Docker, la synchronisation de vos fichiers de tests et la capture de screenshots en cas d'erreur. --- ![Introduction à Cypress](/img/v2/functional_tests.webp) **TL;DR** Ce guide propose une introduction pas à pas à Cypress pour automatiser vos tests fonctionnels front-end. Vous allez apprendre à créer un nouveau projet Cypress, écrire vos premiers tests et les exécuter dans un container Docker. Le tutoriel couvre les workflows de développement essentiels : comment synchroniser vos fichiers de tests locaux avec le container pour un retour rapide et comment configurer Docker pour récupérer automatiquement les screenshots d'erreur du container vers votre machine hôte, afin de déboguer facilement. Vous avez créé un site web pour vous-même ou pour un client : comment être sûr qu'il fonctionne toujours ? OK, à l'époque où vous travailliez dessus, tout marchait ; le formulaire de recherche, le formulaire de contact, les différents liens étaient tous fonctionnels. Mais comment en être sûr dans le temps ? Ne serait-il pas utile de disposer d'une procédure permettant de lancer plusieurs tests dits « fonctionnels » pour vérifier que tout marche encore ? Cela pourrait faire partie du contrat de maintenance que vous proposez à votre client. Quel outil utiliser pour ce type de besoin ? *Les tests fonctionnels répondent à la question « le site fonctionne-t-il toujours ? » ; les tests unitaires répondent à « cette fonction fonctionne-t-elle toujours ? ». Pour ces derniers, voyez [Write PHP unit tests using Pest](/blog/pest_tips) et [Linux - Bash scripts - Running unit tests with bats/bats](/blog/bats-unit-tests).* Cypress est un outil de test front-end qui permet aux développeurs d'automatiser des tests fonctionnels directement dans le navigateur. Il permet de naviguer entre les pages, d'interagir avec les éléments, de soumettre des formulaires et de vérifier le contenu ou les changements d'URL, ce qui simplifie les tests end-to-end et d'intégration. En juin 2024, j'ai écrit un article sur un outil de tests fonctionnels PHP appelé [Behat](/blog/behat-introduction) ; voyons maintenant comment procéder avec Cypress, qui est un outil JavaScript. > **Attention, spoiler:** > > Cypress est vraiment facile à utiliser et écrire des tests est plutôt direct. Contrairement à Behat, qui demande beaucoup plus de compétences et de travail de mise en place, Cypress se prend en main rapidement ; le dépannage est largement simplifié grâce aux screenshots pris dès qu'une erreur survient et Cypress se charge d'attendre le chargement complet d'une page, contrairement à Behat. Une poignée de fichiers, un `docker build`, un `docker run`, et voici le résultat : ![Première exécution](./images/first_run.webp) > **Information:** > > À ce stade, vous avez copié quelques fichiers, construit une image Docker, lancé Cypress dans un container et obtenu un message *All specs passed!*. > > Si on prend quelques secondes pour y réfléchir, nous avons déjà créé un premier test fonctionnel : s'assurer que notre site est en ligne et que le contenu HTML contient un ensemble de mots que nous nous attendons à y trouver. Si notre site est généré par un CMS, par exemple, le simple fait que le test passe prouve déjà que toute la partie base de données et PHP a parfaitement fonctionné. Un petit test pour nous, un grand pas pour notre confiance envers notre hébergeur. ## Créons quelques fichiers et lançons un premier test {#lets-create-some-files-and-run-a-first-test} Créez un répertoire temporaire : ```bash $ mkdir -p /tmp/cypress && cd $_ ``` Créez le fichier `package.json` avec le contenu suivant. L'objectif est d'indiquer que nous avons besoin de la dépendance `cypress` et de définir deux commandes, `open` et `run`. **package.json** ```json { "name": "cypress_sandbox", "version": "1.0.0", "description": "Playing with cypress", "scripts": { "cypress:open": "cypress open", "cypress:run": "cypress run" }, "devDependencies": { "cypress": "^15.19.0" } } ``` Nous avons aussi besoin d'un fichier de configuration, qui doit s'appeler `cypress.config.js`. Créez ce fichier avec le contenu ci-dessous. En résumé, nous définissons l'URL de notre moteur Cypress local à `http://localhost:3100`, nous remplaçons le port par défaut `3000` par `3100` et nous précisons que nous n'utilisons pas de fichier de configuration de support Cypress. **cypress.config.js** ```javascript const { defineConfig } = require('cypress'); module.exports = defineConfig({ e2e: { baseUrl: 'http://localhost:3100', port: 3100, supportFile: false, }, }); ``` Créons un premier test tout bête : nous allons visiter mon blog et vérifier que, quelque part, mon nom complet apparaît : **cypress/e2e/example.cy.js** ```javascript describe('Testing my blog', () => { it('Check my last name is somewhere', () => { cy.visit('https://www.avonture.be') cy.contains('Christophe Avonture') }) }) ``` Un fichier optionnel à créer est `.dockerignore` : il indique à Docker de ne pas copier certains fichiers dans notre image Docker. Dans ce tutoriel, nous n'en avons pas vraiment besoin, mais c'est toujours une bonne idée d'avoir ce fichier comme pense-bête : Docker peut ignorer des fichiers lors de la copie des fichiers et répertoires pendant la construction d'une image. **.dockerignore** ``` node_modules Dockerfile .dockerignore ``` Enfin, dernier fichier à créer : notre propre image Docker. **Dockerfile** ```docker FROM cypress/included:15.19.0 WORKDIR /app COPY --chown=node:node package*.json ./ RUN npm install COPY --chown=node:node cypress/ ./cypress/ COPY --chown=node:node cypress.config.js ./ RUN npx cypress verify USER node CMD ["npx", "cypress", "run", "--browser", "chrome"] ``` Si nous regardons notre projet actuel dans VSCode, cela ressemble à ceci : ![Notre projet Cypress dans VSCode](./images/vscode.webp) Pour créer notre image et lancer Cypress, ouvrez une console et lancez cette commande : `clear ; docker build -t cypress-test . && docker run --rm cypress-test`. Si tout se passe bien, vous obtiendrez la même sortie que celle déjà montrée en haut de cet article. ## Synchroniser les fichiers entre notre host et le container {#synchronize-files-between-our-host-and-the-container} Bien, ajoutons un nouveau test mais, d'abord, une question : *devons-nous reconstruire notre image Docker encore et encore ?* Certainement pas ! Si nous ne devons pas modifier le `Dockerfile` ou des réglages, ce n'est pas nécessaire. Mais, jusqu'ici, dans notre `Dockerfile`, nous avons utilisé des instructions `COPY` pour placer nos tests directement dans l'image Docker. Ce n'est pas un problème du tout ; il suffit de dire à Docker d'ignorer ces fichiers et d'utiliser ceux de notre host. C'est simple. D'abord, créons un deuxième test : **cypress/e2e/navigation.cy.js** ```javascript describe('Navigation Test', () => { it('should navigate to the tags page', () => { cy.visit('https://www.avonture.be'); cy.contains('a', 'Tags').click(); cy.url().should('include', '/tags'); cy.get('h1').should('contain', 'Tags'); }); }); ``` En résumé, nous voulons aller sur la page d'accueil puis cliquer sur l'élément de navigation **Tags**. Nous attendons ensuite que l'URL de la nouvelle page contienne `/tags` et, aussi, que le contenu HTML de la nouvelle page comporte un élément `h1` avec le mot `Tags`. Et pour lancer ce nouveau test, exécutez simplement `docker run --rm -v ./cypress:/app/cypress cypress-test`. Comme vous pouvez le voir, ça fonctionne. ![Navigation](./images/navigation.webp) > **Monter un volume:** > > Donc, en utilisant `-v ./cypress:/app/cypress`, nous montons le dossier `cypress` de notre host dans le dossier `/app/cypress` du container. En modifiant un test sur notre host, Docker répliquera le changement dans le container. ### Récupérer les screenshots {#retrieve-screenshots} Ok, créons un troisième test et, cette fois, nous allons provoquer une erreur. Créez un troisième fichier. > **Caution:** > > Si vous êtes attentif, vous remarquerez une faute d'orthographe, et c'est volontaire. **cypress/e2e/joomla.cy.js** ```javascript describe("Joomla Test", () => { it("use tags and display Joomla posts", () => { cy.visit("https://www.avonture.be"); // Find the "TAGS" link and click it. Adjust the selector if needed. cy.contains("a", "Tags").click(); // Assert that the URL includes '/tags'. // cspell:disable-next-line cy.contains("a", "jomla").click(); }); }); ``` Relançons Cypress : `docker run --rm -v ./cypress:/app/cypress cypress-test`. Quand une erreur survient, Cypress prend un screenshot, comme on le voit sur l'image ci-dessous : ![Prise d'un screenshot](./images/screenshots.webp) Comme vous le voyez sur l'image, Cypress a pris un screenshot et l'a enregistré dans le dossier `/app/cypress/screenshots/`. Ce dossier est à l'intérieur du container et c'est inutile : nous voulons le récupérer sur notre disque. Pour cela, nous devons mettre à jour notre image Docker afin de créer un nouvel utilisateur. Au final, nous pourrons partager notre host avec l'instance Cypress en cours d'exécution et déposer sur notre disque les screenshots créés par Cypress. Remplacez votre Dockerfile existant par celui-ci : **Dockerfile** ```docker # cspell:ignore johndoe ARG USER_ID=1000 ARG GROUP_ID=1000 ARG USER_NAME="johndoe" FROM cypress/included:15.19.0 # Where we'll put our files in the image WORKDIR /app # We'll create our specific user ARG USER_ID ARG GROUP_ID ARG USER_NAME # Our user will be member of the standard node group too RUN groupadd -g ${GROUP_ID} "${USER_NAME}" && \ useradd -u ${USER_ID} -g "${USER_NAME}" -G node -m "${USER_NAME}" # Change the ownership of our /app folder RUN chown -R "${USER_NAME}":"${USER_NAME}" /app USER "${USER_NAME}" # Copy the package.json file to the Docker image COPY --chown="${USER_NAME}":"${USER_NAME}" package*.json ./ # And run npm install i.e. install Cypress RUN npm install # Copy files into our image COPY --chown="${USER_NAME}":"${USER_NAME}" cypress/ ./cypress/ COPY --chown="${USER_NAME}":"${USER_NAME}" cypress.config.js ./ # Check Cypress installation RUN npx cypress verify # And run Cypress and specify we'll use Chrome CMD ["npx", "cypress", "run", "--browser", "chrome"] ``` Le script ci-dessus va créer un utilisateur appelé `johndoe` dans l'image (le nom n'a pas d'importance ici) mais, le plus important, c'est que cet utilisateur aura un UID / GID spécifique. Ces paramètres sont des arguments de build et nous devons les initialiser comme ceci : `clear ; docker build --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t cypress-test .` > **Information:** > > Les commandes `id -u` et `id -g` retournent l'ID de notre utilisateur (très probablement `1000`) et son ID de groupe (ici aussi, très probablement `1000`). Cela dit à Docker d'utiliser notre propre user id et group id (ceux que nous utilisons sur notre host) lors de la construction de l'image. Donc, en bref, `johndoe` ce sera nous : si Docker crée un fichier sur notre disque, le fichier appartiendra à l'utilisateur ayant le même UID / GID, donc à nous. Maintenant que c'est expliqué, nous pouvons lancer la commande complète comme ceci : `clear ; docker build --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t cypress-test . && docker run --rm -v ./cypress:/app/cypress cypress-test`. Nous montons toujours notre dossier `cypress` courant dans le container grâce au flag `-v ./cypress:/app/cypress` mais c'est **bidirectionnel**. Désormais, les fichiers créés par Cypress dans le container (dans le dossier `/app/cypress`) seront aussi copiés sur notre disque. Et comme nous avons pris le temps de créer un utilisateur spécifique dans l'image Docker, les fichiers créés par Docker nous appartiendront. Notre workspace actuel ressemble à ceci dans VSCode : ![vscode avec nos trois tests](./images/vscode_user.webp) En lançant `docker run --rm -v ./cypress:/app/cypress cypress-test` une nouvelle fois, nous voyons maintenant les changements : ![Tag Joomla](./images/tag_joomla.webp) Cypress a pris un screenshot et l'a enregistré sur notre disque. Maintenant, c'est très clair en regardant l'image : l'erreur concerne la vérification `-contains a, jomla` sur la page Tags. Oups, il y a une faute de frappe. Éditez le fichier `cypress/e2e/joomla.cy.js` et corrigez la faute en écrivant `cy.contains('a', 'joomla').click();`. Relancez Cypress : l'erreur est résolue et le screenshot a disparu. ## Conclusion {#conclusion} L'idée de ce tutoriel est de rester simple, nous n'irons donc pas plus loin ici. Si vous êtes francophone, rendez-vous sur [Openclassrooms](https://openclassrooms.com/fr/courses/8157231-automatisez-des-tests-fonctionnels-pour-le-web-avec-cypress), il y a un très bon tutoriel sur Cypress. --- # Écrire des tests fonctionnels avec Pest > Découvrez comment écrire des tests fonctionnels clairs et expressifs avec le framework de test PHP Pest. --- ![Écrire des tests fonctionnels avec Pest](/img/v2/functional_tests.webp) **TL;DR** Cet article est un guide complet pour écrire des tests fonctionnels pour votre site web avec Pest v4. Il vous accompagne dans la mise en place d'un environnement de test complet basé sur Docker, en partant de zéro : création d'un Dockerfile et d'un Makefile pour lancer les commandes facilement. Vous allez apprendre à écrire un test navigateur, à l'exécuter contre un site en ligne et à interpréter les résultats. L'article explique aussi comment Pest capture automatiquement des screenshots en cas d'échec, ce qui simplifie le débogage, et donne quelques éclairages sur la technologie sous-jacente comme Playwright. L'année dernière, j'ai écrit [Write PHP unit tests using Pest](/blog/pest_tips). Depuis, [Nuno Maduro](https://nunomaduro.com/) a publié [Pest](https://pestphp.com/) en version 4 avec le **browser testing intégré**. Qu'est-ce que ça veut dire ? Maintenant, en plus des tests unitaires (voir mon article précédent), on peut aussi demander à Pest de visiter un site web et d'y faire des actions : cliquer sur un bouton, remplir des champs (pensez à une page de login, un champ de recherche, ...), soumettre des formulaires, naviguer, ... et donc, oui, lancer des tests fonctionnels. Un exemple : visiter la page d'accueil de votre site, cliquer dans la zone de recherche, saisir quelques mots-clés, sélectionner le premier élément visible, cliquer dessus et vérifier que la nouvelle page affiche bien un certain texte. J'ai déjà écrit un article sur Behat, un autre sur Cypress ; c'était logique de faire la même chose avec Pest v4. Mon idée, comme toujours, c'est d'utiliser (ou de créer) une image Docker pour pouvoir démarrer immédiatement, sans la douleur de tout installer et configurer. Voici le résultat : 10 tests fonctionnels exécutés dans un vrai navigateur, en moins de 26 secondes. ![Succès](./images/success.webp) Et quand un test échoue — ici, volontairement — Pest prend automatiquement un screenshot de ce que le navigateur a réellement vu : ![Le test a échoué](./images/it_can_search_for_a_post.webp) Pas de screenshot manuel, pas d'étape de débogage séparée : l'échec et la preuve arrivent ensemble. Construisons tout ça. ## Créons un projet temporaire {#lets-create-a-temporary-project} D'abord, créons un dossier temporaire et allons-y : ```bash $ mkdir /tmp/pestphp && cd $_ ``` Pour l'instant, il n'existe pas d'image Docker prête à l'emploi pour Pest v4, alors créons la nôtre. Créez un fichier appelé `Dockerfile` dans votre dossier temporaire avec ce contenu : **Dockerfile** ```docker # cspell:disable # syntax=docker/dockerfile:1.4 ARG PHP_CLI_VERSION=8.3-cli ARG PHP_FPM_VERSION=8.3.24-fpm ARG COMPOSER_VERSION=2.8.11 ARG OS_USERID=1000 ARG OS_GROUPID=1000 ARG OS_USERNAME="docker" ARG TIMEZONE="Europe/Brussels" FROM php:${PHP_CLI_VERSION} AS builder ARG COMPOSER_VERSION WORKDIR /var/www/html # Set cache environment variables for all subsequent commands ENV NPM_CONFIG_CACHE=/var/www/html/.npm-cache \ PLAYWRIGHT_BROWSERS_PATH=/var/www/html/.cache/ms-playwright # Install system dependencies, PHP extensions, and Node.js with browser dependencies RUN --mount=type=cache,target=/var/cache/apt,id=apt-cache,sharing=private \ set -eux && \ apt-get update && apt-get install -y --no-install-recommends \ unzip zip git curl libzip-dev gnupg2 wget ca-certificates \ libgtk-3-0 libxshmfence-dev libxkbcommon-x11-dev \ libnspr4 libnss3 libgbm1 libasound2t64 \ && docker-php-ext-install zip sockets \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && apt-get clean && rm -rf /var/lib/apt/lists/* # Install Composer RUN curl -sS https://getcomposer.org/installer \ | php -- --version=${COMPOSER_VERSION} --install-dir=/usr/local/bin --filename=composer # Create a temporary package.json to install node dependencies RUN echo '{ "name": "pest-browser-tests", "version": "1.0.0", "devDependencies": { "playwright": "^1.55.0", "playwright-core": "^1.55.0" } }' > package.json # Install Node dependencies with cache RUN npm install # Setup Composer dependencies with cache RUN --mount=type=cache,target=/root/.composer \ composer init --name="function_test/pestphp" --require-dev="pestphp/pest:^4.0" --no-interaction && \ composer config --no-plugins allow-plugins.pestphp/pest-plugin true && \ composer require pestphp/pest-plugin-browser --dev && \ composer install --no-interaction --prefer-dist --no-progress FROM php:${PHP_FPM_VERSION} AS php ARG TIMEZONE ENV TZ=${TIMEZONE} WORKDIR /var/www/html # Install PHP and Node.js dependencies RUN --mount=type=cache,target=/var/cache/apt,id=apt-cache,sharing=private \ set -eux && \ apt-get update && apt-get install -y --no-install-recommends \ unzip zip libzip-dev curl gnupg2 wget ca-certificates \ libgtk-3-0 libxshmfence-dev libxkbcommon-x11-dev \ libnspr4 libnss3 libgbm1 libasound2t64 \ && docker-php-ext-install pcntl zip sockets \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && apt-get clean && rm -rf /var/lib/apt/lists/* ARG OS_USERID ARG OS_GROUPID ARG OS_USERNAME RUN set -e -x \ && groupadd --gid ${OS_GROUPID} "${OS_USERNAME}" \ && useradd --password '' -G www-data,root --uid ${OS_USERID} --gid ${OS_GROUPID} -l -d "/home/${OS_USERNAME}" "${OS_USERNAME}" \ && mkdir -p "/home/${OS_USERNAME}" \ && chown -R "${OS_USERNAME}":"${OS_USERNAME}" "/home/${OS_USERNAME}" # Copy all files from the builder stage and set permissions COPY --from=builder --chown=${OS_USERNAME}:${OS_USERNAME} /var/www/html /var/www/html # Set cache and path environment variables for all subsequent commands ENV NPM_CONFIG_CACHE=/var/www/html/.npm-cache \ PLAYWRIGHT_BROWSERS_PATH="/var/www/html/.cache/ms-playwright" \ PATH="/var/www/html/node_modules/.bin:$PATH" # Switch to our user USER ${OS_USERNAME} # Install Playwright browsers in the new, accessible location RUN npx playwright install chromium # Dev entrypoint (keep container alive) ENTRYPOINT ["tail", "-f", "/dev/null"] ``` Pour nous faciliter la vie, créons aussi un [`makefile`](/blog/makefile_tips) afin de pouvoir lancer des commandes très simples comme `make build`, `make up`, ... Créez un fichier appelé `makefile` dans votre projet avec ce contenu : **makefile** ``` # Default value. To override it, run something like "WEBSITE=my_website make start" WEBSITE ?= https://www.avonture.be UID:=$(shell id -u) GID:=$(shell id -g) .PHONY: build build: clear docker build --build-arg OS_USERID=${UID} --build-arg OS_GROUPID=${GID} --tag pestphp . .PHONY: remove remove: docker rm demo_pest --force .PHONY: up up: docker run --detach -v ./tests:/var/www/html/tests --name demo_pest pestphp .PHONY: bash bash: docker exec -it demo_pest /bin/bash .PHONY: start start: docker exec -it demo_pest sh -c "WEBSITE=$(WEBSITE) vendor/bin/pest" ``` Et ... on y est presque. Il ne reste plus qu'à créer un script de test Pest. Créez un fichier appelé `tests/Browser/HomepageTest.php` avec ce contenu : **HomepageTest.php** ```php assertSee('Avonture'); }); it('displays my name somewhere', function () { visit(getenv('WEBSITE')) ->assertSee('Christophe Avonture'); }); it('shows blog posts on the blog page', function () { visit(getenv('WEBSITE') . '/blog') ->assertSee('All posts'); }); it('navigates to the blog from the homepage', function () { visit(getenv('WEBSITE')) ->click('Blog') ->assertPathIs('/blog') ->assertSee('All Posts'); }); it('loads a specific blog post', function () { visit(getenv('WEBSITE') . '/blog/docker-joomla') ->assertSee('Create your Joomla website using Docker'); }); it('displays the about page', function () { visit(getenv('WEBSITE') . '/about') ->assertSee('My name is Christophe Avonture'); }); it('can search for a post', function () { visit(getenv('WEBSITE')) ->click('.DocSearch-Button') ->type('.DocSearch-Input', 'Bluesky') ->assertSee('Create our own Docusaurus React component'); }); it('navigates to the archive and click on aeSecure QuickScan article', function () { visit(getenv('WEBSITE') . '/blog/archive') ->click('August 1 - aeSecure - QuickScan - Free viruses scanner') ->assertPathIs('/blog/aesecure-quickscan') ->assertSee('Just start your browser and go to the URL where your site is accessible.'); }); it('shows 404 for non-existent page', function () { visit(getenv('WEBSITE') . '/this-page-does-not-exist') ->assertSee('Page Not Found'); }); it('can load the robots.txt file', function () { visit(getenv('WEBSITE') . '/robots.txt') ->assertSee('User-agent'); }); ``` **Notre situation actuelle** 1. Nous allons créer notre propre image Docker grâce à notre `Dockerfile`, 2. Nous allons utiliser un makefile pour nous simplifier la vie et 3. Nous avons un scénario de test avec 10 tests fonctionnels. ## Créer l'image {#create-the-image} Pour créer l'image Docker, lancez `make build` : ```bash $ make build ``` **Vous n'avez pas encore make ? (cliquez pour les détails)** Lancez `sudo apt-get update && sudo apt-get -y install make`. ```bash $ sudo apt-get update && sudo apt-get -y install make ``` La phase de build peut être lente parce qu'il y a beaucoup de choses à télécharger. L'image finale pèsera environ 2 GB. ![Construction de notre image](./images/make_build.webp) ## Créer le container {#create-the-container} Une fois l'image créée, on peut maintenant créer notre container Docker : ```bash $ make up docker run --detach -v ./tests:/var/www/html/tests --name demo_pest pestphp 12e700df85b54becb9eb537a09c5711265aaca730529c461b0e53a17b6928875 ``` ## Lancer les tests {#run-tests} Et, dernière chose à faire, une fois le container créé, vous pouvez lancer tous les tests simplement avec `make start`. J'ai **volontairement** introduit une erreur pour illustrer une fonctionnalité sympa : ```bash $ make start {`docker exec -it demo_pest sh -c "WEBSITE=https://www.avonture.be vendor/bin/pest" FAIL Tests\Browser\HomepageTest ✓ it loads the homepage 3.00s ✓ it displays my name somewhere 2.50s ✓ it shows blog posts on the blog page 2.89s ✓ it navigates to the blog from the homepage 3.39s ✓ it loads a specific blog post 3.09s ✓ it displays the about page 2.68s ⨯ it can search for a post 8.77s ✓ it navigates to the archive and click on aeSecure QuickScan article 5.42s ✓ it shows 404 for non-existent page 2.73s ✓ it can load the robots.txt file 0.59s ────────────────────────────────────────────────────────────────────────────── FAILED Tests\Browser\HomepageTest > it can search for a post Expected to see text [Create our own Docusaurus React component] on the page initially with the url [https://www.avonture.be], but it was not found or not visible. A screenshot of the page has been saved to [Tests/Browser/Screenshots/it_can_search_for_a_post]. at tests/Browser/HomepageTest.php:39 35▕ it('can search for a post', function () { 36▕ visit(getenv('WEBSITE')) 37▕ ->click('.DocSearch-Button') 38▕ ->type('.DocSearch-Input', 'Bluesky') ➜ 39▕ ->assertSee('Create our own Docusaurus React component'); 40▕ }); 41▕ 42▕ it('navigates to the archive and click on aeSecure QuickScan article', function () { 43▕ visit(getenv('WEBSITE') . '/blog/archive') Tests: 1 failed, 9 passed (11 assertions) Duration: 35.46s make: *** [makefile:26: start] Error 1`} ``` Comme on peut le voir, le test `it can search for a post` a échoué alors que les précédents ont réussi. > **Note:** > > Vous avez remarqué que les autres tests ont aussi été lancés ? Pest ne s'arrête pas au premier échec. Le truc sympa : Pest a automatiquement pris une capture. Regardez le message d'erreur, il indique qu'un fichier `Tests/Browser/Screenshots/it_can_search_for_a_post` a été créé automatiquement. ![Exécution des tests ; l'un a échoué](./images/running_tests.webp) Pest a pris un screenshot — le même que celui déjà montré en haut de cet article ; nous recherchions `Create our own Docusaurus React component` (comme codé dans notre scénario `tests/Browser/HomepageTest.php`) et en effet, on ne voit pas du tout ce titre. Mais on voit `React component and provide a "Share on Bluesky" button`, alors mettons à jour le script : **HomepageTest.php** ```php assertSee('Avonture'); }); it('displays my name somewhere', function () { visit(getenv('WEBSITE')) ->assertSee('Christophe Avonture'); }); it('shows blog posts on the blog page', function () { visit(getenv('WEBSITE') . '/blog') ->assertSee('All posts'); }); it('navigates to the blog from the homepage', function () { visit(getenv('WEBSITE')) ->click('Blog') ->assertPathIs('/blog') ->assertSee('All Posts'); }); it('loads a specific blog post', function () { visit(getenv('WEBSITE') . '/blog/docker-joomla') ->assertSee('Create your Joomla website using Docker'); }); it('displays the about page', function () { visit(getenv('WEBSITE') . '/about') ->assertSee('My name is Christophe Avonture'); }); it('can search for a post', function () { visit(getenv('WEBSITE')) ->click('.DocSearch-Button') ->type('.DocSearch-Input', 'Bluesky') // highlight-next-line ->assertSee('React component and provide a "Share on Bluesky" button'); }); it('navigates to the archive and click on aeSecure QuickScan article', function () { visit(getenv('WEBSITE') . '/blog/archive') ->click('August 1 - aeSecure - QuickScan - Free viruses scanner') ->assertPathIs('/blog/aesecure-quickscan') ->assertSee('Just start your browser and go to the URL where your site is accessible.'); }); it('shows 404 for non-existent page', function () { visit(getenv('WEBSITE') . '/this-page-does-not-exist') ->assertSee('Page Not Found'); }); it('can load the robots.txt file', function () { visit(getenv('WEBSITE') . '/robots.txt') ->assertSee('User-agent'); }); ``` Pas besoin de reconstruire l'image ni de recréer le container ; il suffit de relancer `make start`. ```bash $ make start docker exec -it demo_pest sh -c "WEBSITE=https://www.avonture.be vendor/bin/pest" PASS Tests\Browser\HomepageTest ✓ it loads the homepage 2.39s ✓ it displays my name somewhere 2.33s ✓ it shows blog posts on the blog page 2.57s ✓ it navigates to the blog from the homepage 3.30s ✓ it loads a specific blog post 2.79s ✓ it displays the about page 2.47s ✓ it can search for a post 3.51s ✓ it navigates to the archive and click on aeSecure QuickScan article 2.69s ✓ it shows 404 for non-existent page 2.68s ✓ it can load the robots.txt file 0.59s Tests: 10 passed (12 assertions) Duration: 25.56s ``` Félicitations, nous venons de tester 10 fonctionnalités en moins de 26 secondes — le même résultat que celui déjà montré en haut de cet article. ## Pour aller plus loin (passez cette section si vous voulez juste l'utiliser) {#in-depth-skip-this-if-you-just-want-to-use-it} * Pest utilise [playwright](https://pestphp.com/docs/browser-testing#content-getting-started) ; c'est pour ça que notre Dockerfile est plus complexe, parce qu'on doit aussi installer NodeJS, * Un truc vraiment cooooool : on n'a pas besoin de `wait_for` un changement sur la page, Pest le fait pour nous. Par exemple, avec Cypress et Behat, quand on clique sur un bouton, on doit attendre que, par exemple, le script Ajax de la page se soit déclenché et que le `DOM element` soit chargé. Avec Pest, on ne doit pas s'en soucier et c'est une fonctionnalité géniale ! * On doit aussi créer un utilisateur spécifique dans l'image Docker pour correspondre à notre utilisateur local. C'est parce que, quand un test échoue, Pest (qui tourne dans Docker) va créer une image et celle-ci doit être créée sur notre machine hôte avec nos UID/GID, * L'action `make up` définie dans le `makefile` monte notre dossier local `tests` dans le container. Ça veut dire que si on crée de nouveaux fichiers de test ou qu'on modifie les existants, ils seront immédiatement synchronisés avec le container en cours d'exécution ; rien de spécial à faire ici, * Pour réduire le besoin de fichiers de configuration, une variable système `WEBSITE` a été définie (voir le `makefile`). Cette variable sera créée dans le container. Un script PHP y fera ensuite référence comme ceci : `getenv('WEBSITE')`. * Il existe un flag `--parallel` pour Pest. Si vous voulez exécuter vos différents fichiers de test en parallèle, éditez le `makefile`, cherchez `docker exec -it demo_pest sh -c "WEBSITE=$(WEBSITE) vendor/bin/pest"` et remplacez par `docker exec -it demo_pest sh -c "WEBSITE=$(WEBSITE) vendor/bin/pest --parallel"` **makefile.diff** ```diff - docker exec -it demo_pest sh -c "WEBSITE=$(WEBSITE) vendor/bin/pest" + docker exec -it demo_pest sh -c "WEBSITE=$(WEBSITE) vendor/bin/pest --parallel" ``` ---