<!--
  canonical-url: https://www.avonture.be/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.
-->

# Writing functional tests with Pest

> Discover how to write clean and expressive functional tests using the Pest PHP testing framework.

---
![Writing functional tests with Pest](/img/v2/functional_tests.webp)

**TL;DR**

This article provides a comprehensive guide to writing functional tests for your website using Pest v4. It walks you through setting up a complete Docker-based testing environment from scratch, including creating a Dockerfile and a Makefile for easy command execution. You'll learn how to write a browser test case, run it against a live website, and interpret the results. The post also covers how Pest automatically captures screenshots on failure, making debugging easier, and provides insights into the underlying technology like Playwright.

Last year, I wrote [Write PHP unit tests using Pest](/blog/pest_tips). Since then [Nuno Maduro](https://nunomaduro.com/) has released [Pest](https://pestphp.com/) in a version 4 with **built-in browser testing**.

What does that mean? Now, aside unit testing (see my previous article), we can also ask Pest to visit a website and make actions like clicking on a button, filling in some fields (think to a login page, a search field, ...), submitting forms, navigating, ... and thus, yes, running some functional tests.

An example can be to visit the homepage of your website, click in the search text area, fill some keywords, select the first visible item, click on it and assert the new page will display some text.

I've already written an article about Behat, another one about Cypress; it was logical to do the same with Pest v4.

My idea, like always, is to use (or create) a Docker image so we can immediately start without the pain of installing and configuring everything.

Here is the payoff: 10 functional tests running in a real browser, in less than 26 seconds.

![Success](./images/success.webp)

And when a test fails — here, intentionally — Pest automatically takes a screenshot of what the browser actually saw:

![The test has failed](./images/it_can_search_for_a_post.webp)

No manual screenshot, no separate debugging step: the failure and the evidence arrive together. Let's build this.

## Let's create a temporary project

First, let's create a temporary folder and jump in it:

```bash
$ mkdir /tmp/pestphp && cd $_
```

Right now, there is no ready-to-use Docker image for Pest v4 so let's create ours.

Please create a file called `Dockerfile` in your temporary folder with this content:

**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"]
```

For our easiness, let's also create a [`makefile`](/blog/makefile_tips) to be able to run very easy command like `make build`, `make up`, ...

Create a file called `makefile` in your project with this content:

**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"
```

And ... we're almost there. We just need to create a Pest test script. Please create a file called `tests/Browser/HomepageTest.php` with content:

**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');
});
```

**Our current situation**

1. We'll create our own Docker image thanks to our `Dockerfile`,

2. We'll use a makefile for easiness and

3. We've a test scenario with 10 functional tests.

## Create the image

To create the Docker image, please run `make build`:

```bash
$ make build
```

**Don't have make yet? (click for the details)**

Please run `sudo apt-get update && sudo apt-get -y install make`.

```bash
$ sudo apt-get update && sudo apt-get -y install make
```

The build phase can be slow because a lot of things should be downloaded. The final image will be around 2 GB.

![Building our image](./images/make_build.webp)

## Create the container

Once the image has been created; we can now create our Docker container:

```bash
$ make up
docker run --detach -v ./tests:/var/www/html/tests --name demo_pest pestphp
12e700df85b54becb9eb537a09c5711265aaca730529c461b0e53a17b6928875
```

## Run tests

And, last thing to do, once the container has been created, you can run all tests by just running `make start`.

I've **intentionally** created an error to illustrate some nice feature:

```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`}
```

As we can see, the test `it can search for a post` has failed while the previous ones have succeeded.

> **Note:**
>
> Did you notice that other tests were also fired? Pest won't stop on failure.

The nice thing is: Pest has automatically taken a snapshot. See the error message, it says that a file `Tests/Browser/Screenshots/it_can_search_for_a_post` has been created automatically.

![Running tests; one has failed](./images/running_tests.webp)

Pest has taken a screenshot — the same one already shown at the top of this article; we were searching for `Create our own Docusaurus React component` (as coded in our `tests/Browser/HomepageTest.php` scenario) and indeed, we don't see that title at all. But we see `React component and provide a "Share on Bluesky" button` so let's update the 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');
});
```

We don't need to build the image again neither to recreate the container; just run `make start` again.

```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
```

Congratulations, we've just tested 10 features in less than 26 seconds — the same result already shown at the top of this article.

## In-Depth (skip this if you just want to use it)

* Pest is using [playwright](https://pestphp.com/docs/browser-testing#content-getting-started); this is why our Dockerfile is more complex because we should also install NodeJS,
* Something really cooooool: we don't need to `wait_for` something change on the page, Pest will do it for us. For instance, with Cypress and Behat, when we click on a button, we have to wait until, for instance, the Ajax script on that page has fired and the `DOM element` has been loaded. With Pest, we shouldn't care about this and that's an amazing feature!
* We also have to create a specific user in the Docker image to match our local one. This is because when a test fails, Pest (running in Docker) will create an image and that one should be created on our host machine with our UID/GID,
* The `make up` action defined in the `makefile` is mounting our local `tests` folder in the container. It means that if we create new tests files or update existing ones, they will be immediately synchronized with the running container; nothing special to do here,
* To reduce the need for configuration files, a `WEBSITE` operating system variable has been defined (see the `makefile`). That variable will be created in the container. A PHP script will then refer to the variable like this `getenv('WEBSITE')`.
* There is a `--parallel` flag for Pest. If you want to run your different tests files in parallel, edit the `makefile`, search for `docker exec -it 
  demo_pest
   sh -c "WEBSITE=$(WEBSITE) vendor/bin/pest"` and replace with `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"
```
