<!--
  canonical-url: https://www.avonture.be/fr/blog/pest-functional-testing
  published:     2025-08-30
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# É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
<?php

it('loads the homepage', function () {
    visit(getenv('WEBSITE'))
        ->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
<?php

it('loads the homepage', function () {
    visit(getenv('WEBSITE'))
        ->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"
```
