Development

Experiment: Using Pest 4 as a Standalone Browser Testing Suite for Legacy Applications.

5 minutes

Here's a scenario we encounter regularly at Jump24: a client has a legacy application - perhaps built in CakePHP, CodeIgniter, or even a custom framework - and they want to add automated browser testing. The catch? They don't want to pollute the existing codebase with test dependencies, and frankly, the application wasn't built with testing in mind.

With Pest 4's new Playwright-powered browser testing, we wondered: could we create a completely standalone test suite that lives in its own repository and simply tests an external application via HTTP? No Laravel required, no framework dependencies in the legacy app - just a separate project that fires up a browser and tests your QA environment or one that even performs smoke tests against Prod if required.

Spoiler: yes, you can. Here's what we learned.

The Problem We're Solving

Traditional browser testing approaches typically assume your tests live inside your application. Laravel Dusk expects a Laravel app. Cypress and Playwright projects usually sit alongside your frontend code. But what if you're dealing with:

  • A legacy CakePHP 2.x application that's too risky to modify

  • A WordPress site where you can't easily add Composer dependencies

  • A client's application where you only have access to the QA environment, not the codebase

  • Multiple applications that you want to test from a single, centralised test suite

  • Your team are more comfortable in PHP than in Javascript

In these cases, having a standalone test repository makes a lot of sense. You get all the benefits of automated browser testing without touching the legacy codebase.

Why Pest 4?

Pest 4 introduced browser testing powered by Playwright - not Laravel Dusk. This is significant because Playwright is framework-agnostic. While Pest's browser plugin was designed with Laravel in mind, the underlying technology doesn't actually require Laravel at all.

The syntax is also beautifully expressive:

php
1<?php
2 
3it('can login to the legacy app', function () {
4 visit('https://qa.legacy-app.com/login')
5 ->type('#email', 'test@example.com')
6 ->type('#password', 'secret')
7 ->click('button[type="submit"]')
8 ->assertPathIs('/dashboard');
9});

If you're already comfortable with Pest's syntax, this feels immediately familiar.

The Setup

Our standalone test suite is surprisingly minimal. Here's the structure:

shell
1pest-browser-standalone/
2├── .env.example
3├── composer.json
4├── package.json
5├── phpunit.xml
6└── tests/
7 ├── bootstrap.php
8 ├── Pest.php
9 └── Browser/

The key dependencies are just Pest 4, the browser plugin, and phpdotenv for environment configuration:

php
1"require-dev": {
2 "pestphp/pest": "^4.0",
3 "pestphp/pest-plugin-browser": "^4.0",
4 "vlucas/phpdotenv": "^5.6"
5}

The Key Workaround: Environment-Based URLs

Here's where it gets interesting. Pest's browser plugin was built for Laravel, which means it expects to either start a local server or use Laravel's configuration for the base URL. Without Laravel, there's currently no built-in way to set a base URL.

There's actually an open issue on GitHub requesting this feature. Until it lands, our workaround is simple: use full URLs everywhere, built from environment variables.

In our bootstrap.php, we define a url() helper:

php
1<?php
2 
3function url(string $path = '/'): string
4{
5 $base = rtrim(env('APP_URL'), '/');
6 return $base . '/' . ltrim($path, '/');
7}

Then in tests:

php
1<?php
2 
3visit(url('/login'))->assertSee('Sign In');

Switching environments is just a matter of changing .env:

shell
1# Local testing against QA
2APP_URL=https://qa.client-app.com
3 
4# CI against staging
5APP_URL=https://staging.client-app.com

What Works Well

  • Smoke testing: The assertNoSmoke() method is brilliant for quickly checking that pages load without JavaScript errors or console warnings.

  • Accessibility testing: Built-in assertNoAccessibilityIssues() catches WCAG violations automatically.

  • Visual regression: Screenshot comparison with assertScreenshotMatches() works out of the box.

  • Device emulation: Testing on mobile viewports with ->on()->mobile() or ->on()->iPhone14Pro() is seamless.

  • CI integration: Test sharding across multiple GitHub Actions runners with --shard works perfectly.

  • Debug mode: Running with --debug opens a visible browser and pauses on failure—invaluable for troubleshooting.

Current Limitations

This is an experiment, so let's be honest about the rough edges:

  • No native base URL configuration: You need the url() helper workaround we described. The Pest team may add this in future.

  • IDE support: Your IDE might not recognise visit() as a valid function since it's dynamically registered. Tests still run fine—it's just a squiggly line annoyance.

  • No database access: You can't use Laravel's RefreshDatabase or factory helpers. You're testing through the browser only, so you'll need test accounts set up in your QA environment.

  • Documentation is Laravel-focused: Most examples assume Laravel, so you'll need to adapt them for standalone use.

Is This Production Ready?

For our use case - adding browser testing to legacy applications without modifying them - I believe it is.

However, this isn't the "official" way to use Pest's browser testing. We're working around the Laravel-centric design rather than with it. If the Pest team makes breaking changes to how the browser plugin works, we might need to adapt.

That said, the core approach - testing an external URL via Playwright - is fundamentally sound. Even if the Pest API changes, the underlying concept won't.

Wrapping Up

This experiment started from a real client need: how do we add modern browser testing to applications that weren't built for it? Pest 4's Playwright integration turned out to be the perfect foundation, even if we had to work around its Laravel assumptions.

If you're maintaining legacy applications and want to add automated browser testing without touching the original codebase, this approach is worth exploring. It's not perfect, but it works - and sometimes that's exactly what you need.