---
title: Pest Browser-Test Setup
visibility: guest
---

# Pest Browser-Test Setup

Pest v4's browser plugin (`pestphp/pest-plugin-browser`) drives a real
Chromium instance under the hood via Playwright. It lets you write
expressive end-to-end tests against any WireKit-using Laravel app —
mobile-viewport rendering, click flows, animation behavior, console-
error assertions — all in PHP.

This page is a tested recipe for wiring it into a fresh project.
Follow the steps top-to-bottom and you should have a green smoke test
inside one setup pass.

## Prerequisites

| Tool | Minimum version |
|---|---|
| PHP | 8.4 |
| Composer | 2.5 |
| Node.js | 20 LTS |
| npm | 10 |
| WireKit | 2.0.0 |

## Step 1 — Install the plugin

```bash
composer require pestphp/pest-plugin-browser --dev
```

The plugin's Composer service-provider hook should auto-generate a
`vendor/pest-plugins.json` manifest. If your CI or local Composer is
running in root mode (the `COMPOSER_ALLOW_SUPERUSER` flag is set),
the hook is skipped — you'll need to run the dump manually:

```bash
composer pest:dump-plugins
```

You can verify the file exists:

```bash
test -f vendor/pest-plugins.json && echo "OK" || echo "missing"
```

## Step 2 — Install Playwright

Pest-plugin-browser bundles Playwright as a transitive Node dependency.
Add it to your project's package.json and install the Chromium binary:

```bash
npm install playwright@latest --save-dev
npx playwright install chromium
```

The first run downloads ~120 MB of browser binaries. Subsequent runs
reuse the cached install.

## Step 3 — Wire up `tests/Pest.php`

Add the `Browser` suite extension to your `tests/Pest.php`:

```php
<?php

declare(strict_types=1);

use Tests\TestCase;

pest()->extend(TestCase::class)
    ->in('Feature');

pest()->extend(TestCase::class)
    ->in('Browser');
```

Both suites share the same `Tests\TestCase` base class. The Browser
plugin auto-wires its WebSocket client to the Playwright server when
the test runs under the `Browser` suite filter.

## Step 4 — Verify your phpunit.xml

The browser plugin spins up an HTTP server to serve your Laravel app
during tests. That server needs the same Laravel environment variables
your normal app uses, especially:

```xml
<phpunit>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_KEY" value="base64:YOUR_TEST_KEY_HERE"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="CACHE_STORE" value="array"/>
    </php>
</phpunit>
```

Without `APP_KEY`, every page renders `500 Server Error` (No
application encryption key has been specified). Generate a fresh key
for your test environment:

```bash
php artisan key:generate --show
```

Copy the output into the `APP_KEY` env entry above.

## Step 5 — Build your front-end

Run a production build so the Vite manifest exists before the test
suite starts:

```bash
npm run build
```

This populates `public/build/manifest.json`, which Blade's `@vite()`
directive resolves to. Without the manifest, the test pages crash
with a `ViewException`.

## Step 6 — Write a smoke test

Create `tests/Browser/SmokeTest.php`:

```php
<?php

declare(strict_types=1);

it('homepage renders without JavaScript errors', function () {
    $page = $this->visit('/');
    $page->wait(2);

    // Capture any console.error or uncaught exception that fires
    // during render. The errors array MUST be empty.
    $page->script("
        window.__capturedErrors = [];
        console.error = (...args) => window.__capturedErrors.push(args.map(a => String(a)).join(' '));
        window.addEventListener('error', (e) => window.__capturedErrors.push('uncaught: ' + e.message));
    ");

    $page->wait(1);

    $errors = $page->script('window.__capturedErrors || []');

    expect($errors)->toBe([]);
});
```

Run it:

```bash
vendor/bin/pest tests/Browser/SmokeTest.php
```

You should see one passing test.

## Mobile-Viewport Testing

Switch the rendering viewport with the device API on the visited page:

```php
it('landing page renders correctly on iPhone 14 Pro', function () {
    $page = $this->visit('/')->on()->iPhone14Pro();
    $page->wait(2);

    expect($page->script('(() => window.innerWidth)()'))->toBe(393);
});

it('landing page renders correctly on iPad Pro', function () {
    $page = $this->visit('/')->on()->iPadPro();
    $page->wait(2);

    expect($page->script('(() => window.innerWidth)()'))->toBe(1024);
});
```

The full device catalog includes iPhone 14/15/15Pro/SE, iPad Pro/Mini,
Pixel 6a/7/8, Galaxy S22/S23/S24Ultra, plus macBook 14/16 and a
generic `desktop()` / `mobile()` selector. Browse
`vendor/pestphp/pest-plugin-browser/src/Api/On.php` for the full set.

## Prefers-Reduced-Motion Override

Headless Chromium defaults to `prefers-reduced-motion: reduce`. That
shortcuts every animation in your code path — including the ones you
might want to test. Override it with a media-query stub:

```php
$page->script("
    window.matchMedia = (q) => ({
        matches: false,
        media: q || '',
        onchange: null,
        addListener: () => {},
        removeListener: () => {},
        addEventListener: () => {},
        removeEventListener: () => {},
        dispatchEvent: () => false,
    });
");
```

Apply this before any assertion that depends on animation behavior.

## Known Issues

### Issue 1 — `vendor/pest-plugins.json` not generated under root composer

If composer ran with `COMPOSER_ALLOW_SUPERUSER=1` (common in Docker
containers, CI runners, and some Linux-as-root setups), the
post-autoload-dump hook that writes `vendor/pest-plugins.json` is
silently skipped. Without this file, every `$this->visit(...)` call
fails with:

```text
Call to a member function sendText() on null
  at vendor/pestphp/pest-plugin-browser/src/Playwright/Client.php:87
```

**Fix:** run `composer pest:dump-plugins` explicitly. The file is
checked into `vendor/`, so it gets regenerated on every install.

### Issue 2 — Playwright server process leaks across test runs

After every Pest browser run, the Node process spawned by
`./node_modules/.bin/playwright run-server` sometimes stays alive
because the Symfony Process graceful-shutdown grace period doesn't
terminate the child before the parent Pest process exits. Subsequent
runs spawn fresh servers on different ports, and RAM accumulates.

**Workaround:** add a post-run cleanup step in CI:

```bash
vendor/bin/pest tests/Browser/
pkill -9 -f playwright
```

For local development the leaked servers are short-lived (killed on
reboot), but in long-running CI containers the `pkill` line prevents
OOM.

## Cross-References

- [Authoring Custom Alpine Plugins](./authoring-custom-alpine-plugins.md) — discipline for the plugins your browser tests will exercise.
- [Console Output Baseline](./console-output-baseline.md) — what a clean install should emit (and what to filter out in your assertions).
