Pest 4 Browser Testing: End-to-End Tests That Feel Like Unit Tests
Pest 4 brings Playwright-powered browser testing to PHP with the visit() helper, smoke tests, visual regression, and parallel sharding. Here is how it works.
For years the dividing line in a PHP test suite was painfully clear. Unit and feature tests lived in PHP, ran fast, and were a pleasure to write. Anything that needed a real browser meant Dusk, a Selenium grid, or worse, a separate JavaScript stack with its own assertions, its own CI job, and its own set of flaky failures at 2 a.m. Pest 4 erases that line. Browser testing now happens in the same file, with the same syntax, as the rest of your suite.
Pest 4 first landed at Laracon US, and as of this writing the framework is up to 4.7.2 (released June 1, 2026), so the browser plugin has had plenty of point releases to stabilize. If you tried it on launch day and hit rough edges, it is worth another look.
What Browser Testing Looks Like
The whole pitch fits in one example. Here is a password reset flow tested end to end in a real browser, using Laravel testing helpers you already know:
<?php
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\Facades\Notification;
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in') // a real browser, not a simulated request
->on()->mobile() // or ->desktop(), ->tablet()
->inDarkMode(); // or ->inLightMode()
$page->assertSee('Sign In')
->click('Forgot Password?')
->type('email', '[email protected]')
->press('Send Reset Link')
->assertSee('We have emailed your password reset link!')
->assertNoJavascriptErrors();
Notification::assertSent(ResetPassword::class);
});
Look at what is happening. Notification::fake(), actingAs(), and Notification::assertSent() are the same Laravel facades and assertions you use in feature tests. But visit() launches an actual browser, renders the page with its JavaScript, and click() and type() drive real DOM interactions. The RefreshDatabase trait works too, including SQLite in-memory, so each browser test starts from a clean state just like your other tests.
Under the hood this is powered by Playwright rather than the older WebDriver protocol, which is a large part of why it feels fast and reliable instead of fragile.
Getting Started
Two commands and you are running:
composer require pestphp/pest-plugin-browser --dev
npm install playwright@latest
npx playwright install
That last step downloads the browser binaries (Chromium, Firefox, WebKit). After that, the visit() function is available anywhere in your test files, and ./vendor/bin/pest handles launching the browser and tearing it down. No grid to stand up, no separate process to babysit.
Smoke Testing in Three Lines
This is the feature I would reach for first on an existing app, because the effort-to-value ratio is absurd. You can visit a batch of routes and assert that none of them throw a JavaScript error or leave console noise behind:
$routes = ['/', '/about', '/contact', '/pricing', '/blog'];
visit($routes)->assertNoSmoke();
assertNoSmoke() is shorthand for assertNoJavascriptErrors() plus assertNoConsoleLogs(). Point it at your most important URLs and you have a regression net that catches the entire category of “we shipped a JS error on the homepage and nobody noticed for a day.” It does not assert behavior, but it tells you the page renders without exploding, which is more than most apps verify today.
Visual Regression Testing
Pest 4 can also catch the bugs that do not throw errors, the ones where a CSS change quietly shoves your checkout button off the screen. The assertScreenshotMatches() assertion captures a screenshot and compares it against a stored baseline:
$pages = visit(['/', '/about', '/contact']);
$pages->assertScreenshotMatches();
The first run records the baseline images. Subsequent runs diff against them and fail if the rendered pixels drift beyond tolerance. Commit the baselines to your repo and you get a visual diff in code review whenever the UI changes, intentionally or not.
Element Selectors That Match How You Think
Locating elements is flexible. You can target by visible text, CSS selector, ID, or a data-test attribute, and Pest figures out which one you mean:
$page->click('Login'); // by visible text
$page->click('.btn-primary'); // by CSS class
$page->click('#submit-button'); // by ID
$page->click('@login'); // by data-test="login"
The @ syntax for test attributes is the one I would standardize on for anything load-bearing. Text and class selectors break when a designer rewords a label or renames a utility class. A dedicated data-test hook survives both.
Sharding and Parallelism for CI
Browser tests are slower than unit tests, full stop. Pest 4 answers this with two levers. The --parallel flag spreads tests across cores on one machine, and the new --shard option splits the suite across multiple machines:
# Each CI worker runs one quarter of the suite
./vendor/bin/pest --shard=1/4 --parallel
./vendor/bin/pest --shard=2/4 --parallel
./vendor/bin/pest --shard=3/4 --parallel
./vendor/bin/pest --shard=4/4 --parallel
In GitHub Actions, a matrix strategy wires this up cleanly:
strategy:
matrix:
shard: [1, 2, 3, 4]
name: Tests (Shard ${{ matrix.shard }}/4)
steps:
- name: Run tests
run: ./vendor/bin/pest --parallel --shard ${{ matrix.shard }}/4
Since CI runners scale horizontally far more cheaply than vertically, sharding is how you keep a growing browser suite from becoming the slowest part of your pipeline.
A Few Extras Worth Knowing
Pest 4 ships more than browser features. Type coverage got a new engine that is roughly twice as fast on first run and effectively instant afterward, and it supports sharding too. There is a --profanity plugin that flags salty language in your codebase, handy for shared repos. And two small but practical helpers arrived: skipLocally() and skipOnCi() let you fence off tests that only make sense in one environment, which is exactly what you want for tests that depend on local-only services or, conversely, CI-only credentials.
Should You Adopt It?
If you are on Laravel and have been avoiding end-to-end tests because Dusk felt heavy, Pest 4 removes most of the excuses. The barrier was never that browser testing lacked value, it was that the tooling demanded a context switch every time. Writing a browser test in the same syntax as your unit tests, with your Laravel helpers intact, changes the economics. Start with assertNoSmoke() across your top ten routes this week. It is fifteen minutes of work and it will probably find something.