thelemon2020 / pest-plugin-pom
A Pest plugin for writing browser tests using the Page Object Model.
Requires
- php: ^8.3
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-browser: ^4.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
README
A Pest plugin for writing expressive browser tests using the Page Object Model pattern.
Page Objects keep your browser tests readable and maintainable by encapsulating page-specific selectors and interactions into dedicated classes. The plugin integrates with pest-plugin-browser and automatically starts the Playwright server for any test that uses a Page Object — no manual setup required.
Early Release: This plugin is in early development (pre-v1). Bugs are expected — if you encounter one, please open an issue. Feedback and suggestions are very welcome.
Note: This plugin is designed for Laravel applications. It requires the Laravel framework for configuration, service provider registration, and the included Artisan generator commands.
Requirements
- PHP ^8.3
- Laravel ^11.0|^12.0|^13.0
- Pest ^4.0
- pest-plugin-browser ^4.0
Installation
composer require thelemon2020/pest-plugin-pom --dev
Publishing the config
php artisan vendor:publish --tag=pest-plugin-pom-config
This creates config/pest-plugin-pom.php:
return [ 'path' => 'tests/Browser/Pages', ];
path controls where the pest:page generator writes new files and where Page::open() expects page classes to live at runtime. Change it if your project uses a different directory.
Core Concepts
Page Objects
Each page in your application is represented by a class that extends Page. The class defines the URL for the page and exposes methods that describe meaningful user interactions — clicking a button, filling a form, asserting content — in the language of your application rather than raw browser commands.
namespace Tests\Browser\Pages; use Thelemon2020\PestPom\Page; use Thelemon2020\PestPom\Concerns\InteractsWithForms; class LoginPage extends Page { use InteractsWithForms; public static function url(): string { return '/login'; } public function loginAs(string $email, string $password): static { return $this ->fillForm([ 'Email' => $email, 'Password' => $password, ]) ->submitForm('Log in'); } }
Navigating to a Page
Use the page() helper or the static ::open() method to navigate to a page and receive a typed instance:
$page = page(LoginPage::class); // or equivalently $page = LoginPage::open();
Both return an instance of LoginPage, giving you full IDE autocompletion for any methods you have defined on that class.
Parameterized URLs
For pages whose URL includes a dynamic segment — a product ID, a user slug, a post number — use {param} placeholders in url() and pass the values when navigating:
class ProductPage extends Page { public static function url(): string { return '/products/{id}'; } }
// Navigate directly page(ProductPage::class, ['id' => 42]); // or equivalently ProductPage::open(['id' => 42]); // Navigate from another page $page->navigateTo(ProductPage::class, ['id' => 42]);
Multiple placeholders work the same way:
class PostPage extends Page { public static function url(): string { return '/users/{userId}/posts/{postId}'; } } PostPage::open(['userId' => 3, 'postId' => 99]);
When using nowOn() after a server-side redirect to a parameterized page, you do not need to supply the values — the URL is matched as a pattern, so /products/42 and /products/99 both satisfy nowOn(ProductPage::class):
$page->submitPurchase()->nowOn(ProductPage::class);
Fluent Chaining
Every method on a Page Object returns static, so you can chain interactions naturally:
page(LoginPage::class) ->loginAs('jane@example.com', 'password') ->assertSee('Welcome back, Jane');
Writing Tests
The plugin automatically detects any test that calls page() or ::open() and marks it as a browser test, starting the Playwright server as needed. You do not need to annotate tests or configure anything.
it('allows a user to log in', function () { page(LoginPage::class) ->loginAs('jane@example.com', 'password') ->assertSee('Dashboard'); });
Creating Page Objects
Artisan Generator
The quickest way to create a page is with the included Artisan command:
php artisan pest:page Login
This creates tests/Browser/Pages/LoginPage.php with a basic scaffold. The Page suffix is optional — it won't be doubled if you include it.
Pass --concerns to include traits in the generated class:
php artisan pest:page Register --concerns=forms,alerts php artisan pest:page UserSettings --concerns=forms,alerts,modals,navigation
Available concern names: forms, alerts, modals, navigation.
The generated file for pest:page Register --concerns=forms,alerts looks like:
<?php declare(strict_types=1); namespace Tests\Browser\Pages; use Thelemon2020\PestPom\Page; use Thelemon2020\PestPom\Concerns\InteractsWithForms; use Thelemon2020\PestPom\Concerns\InteractsWithAlerts; class RegisterPage extends Page { use InteractsWithForms; use InteractsWithAlerts; public static function url(): string { return '/'; } }
The namespace is inferred automatically from your project's composer.json autoload-dev PSR-4 map, falling back to Tests\Browser\Pages.
Manually
Create a class for each page (or distinct section of a page) in your application, extending the abstract Page base class.
namespace Tests\Browser\Pages; use Thelemon2020\PestPom\Page; class DashboardPage extends Page { public static function url(): string { return '/dashboard'; } public function assertWelcomeMessage(string $name): static { return $this->assertSee("Welcome, {$name}"); } }
Navigating Between Pages
There are two ways to move from one page object to another, depending on whether you want the browser to navigate or whether it has already arrived.
navigateTo()
Explicitly navigates the browser to the destination page's URL and returns a typed instance. Use this when you want to send the browser somewhere directly — the session and authentication cookies are preserved across the navigation.
it('redirects to the dashboard after login', function () { $dashboard = page(LoginPage::class) ->loginAs('jane@example.com', 'password') ->navigateTo(DashboardPage::class); $dashboard->assertWelcomeMessage('Jane'); });
Pass a parameters array as the second argument for pages with {param} placeholders in their URL:
$page->navigateTo(ProductPage::class, ['id' => 42]);
nowOn()
Re-wraps the current browser session as a different page type without reloading the page. Use this after an action (like submitting a form) that causes a server-side redirect — the browser has already landed on the new page, so there's no need to navigate again.
nowOn() verifies that the browser's current URL matches the destination page's URL and throws an exception if it doesn't, catching unexpected redirects (e.g. an auth failure that sends the user back to /login) immediately. For pages with {param} placeholders in their URL, the check is pattern-based — any value in that segment is accepted, so you don't need to know the exact ID the server redirected to.
it('redirects to the dashboard after login', function () { $dashboard = page(LoginPage::class) ->loginAs('jane@example.com', 'password') ->nowOn(DashboardPage::class); // no reload — already here after the POST redirect $dashboard->assertWelcomeMessage('Jane'); });
navigateTo() |
nowOn() |
|
|---|---|---|
| Navigates the browser | Yes | No |
| Preserves session | Yes | Yes |
| Verifies current URL | No | Yes (exact or pattern) |
Accepts {param} values |
Yes | Pattern-matched automatically |
| Use when | You want to send the browser somewhere | The browser already arrived via redirect |
Components
Components let you encapsulate a reusable piece of UI — a navigation bar, a data table, a search widget — into its own class, separate from any particular page. A Component works exactly like a Page: it exposes methods that describe meaningful interactions and returns static for fluent chaining. The difference is that a component has no URL and is always obtained through a page, sharing the same browser session.
Creating Components
Use the Artisan generator to scaffold a component:
php artisan pest:component SearchBar
This creates tests/Browser/Components/SearchBarComponent.php. The Component suffix is optional and won't be doubled.
Pass --concerns to include traits, just like with pages:
php artisan pest:component SearchBar --concerns=forms php artisan pest:component DataTable --concerns=navigation,modals
The generated file looks like:
<?php declare(strict_types=1); namespace Tests\Browser\Components; use Thelemon2020\PestPom\Component; class SearchBarComponent extends Component { public static function selector(): string { return ''; } }
Fill in selector() with the CSS selector that identifies the component's root element, then add your interaction methods.
Using Components
Call component() on any page instance, passing the component class name:
$search = page(DashboardPage::class)->component(SearchBarComponent::class);
This returns a SearchBarComponent instance backed by the same browser session as the page. From there, call any methods you have defined on the component:
page(DashboardPage::class) ->component(SearchBarComponent::class) ->search('pest php') ->assertSee('pest-plugin-browser');
Example Component
namespace Tests\Browser\Components; use Thelemon2020\PestPom\Component; use Thelemon2020\PestPom\Concerns\InteractsWithForms; class SearchBarComponent extends Component { use InteractsWithForms; public static function selector(): string { return '#search-bar'; } public function search(string $query): static { return $this ->fillForm(['Search' => $query]) ->submitForm('Search'); } }
it('returns results for a valid search', function () { page(DashboardPage::class) ->component(SearchBarComponent::class) ->search('pest php') ->assertSee('No results found') ->assertSee('pest-plugin-browser'); });
Components support all four concern traits (InteractsWithForms, InteractsWithAlerts, InteractsWithModals, InteractsWithNavigation) in exactly the same way as pages.
Available Concerns (Traits)
The plugin ships with four traits that cover common browser interactions. Include only the ones each Page Object needs.
InteractsWithForms
use Thelemon2020\PestPom\Concerns\InteractsWithForms; class RegistrationPage extends Page { use InteractsWithForms; public static function url(): string { return '/register'; } }
| Method | Description |
|---|---|
fillForm(array $fields) |
Fill multiple fields at once, keyed by label |
submitForm(string $button = 'Submit') |
Click a submit button by its label |
checkBox(string $label) |
Check a checkbox by label |
choose(string $field, array|string|int $option) |
Select a dropdown option by label |
page(RegistrationPage::class) ->fillForm([ 'Name' => 'Jane Doe', 'Email' => 'jane@example.com', 'Password' => 'super-secret', ]) ->checkBox('I agree to the terms and conditions') ->choose('Country', 'United States') ->submitForm('Create account');
InteractsWithAlerts
use Thelemon2020\PestPom\Concerns\InteractsWithAlerts; class ProfilePage extends Page { use InteractsWithAlerts; public static function url(): string { return '/profile'; } }
| Method | Description |
|---|---|
assertSuccessMessage(string $message) |
Assert a success alert or flash message is visible |
assertErrorMessage(string $message) |
Assert an error alert or flash message is visible |
assertFieldError(string $field, string $message) |
Assert a validation error is visible for a specific field |
page(ProfilePage::class) ->fillForm(['Name' => '']) ->submitForm('Save') ->assertFieldError('Name', 'The name field is required.') ->assertErrorMessage('Your profile could not be saved.');
InteractsWithModals
use Thelemon2020\PestPom\Concerns\InteractsWithModals; class UserListPage extends Page { use InteractsWithModals; public static function url(): string { return '/users'; } }
| Method | Description |
|---|---|
openModal(string $trigger) |
Click the element that opens the modal |
confirmModal(string $confirmButton = 'Confirm') |
Click the confirm button inside the modal |
dismissModal(string $cancelButton = 'Cancel') |
Click the cancel button inside the modal |
closeModal(string $closeButton = 'Close') |
Close the modal without confirming |
page(UserListPage::class) ->openModal('Delete Jane Doe') ->confirmModal('Yes, delete user') ->assertSuccessMessage('User deleted.');
InteractsWithNavigation
use Thelemon2020\PestPom\Concerns\InteractsWithNavigation; class ArticlePage extends Page { use InteractsWithNavigation; public static function url(): string { return '/articles'; } }
| Method | Description |
|---|---|
clickLink(string $label) |
Click a navigation link by its visible text |
goBack() |
Navigate back in browser history |
goForward() |
Navigate forward in browser history |
refresh() |
Reload the current page |
page(ArticlePage::class) ->clickLink('Getting Started') ->assertSee('Introduction') ->goBack() ->assertSee('All Articles');
Custom Expectations
The plugin registers two Pest expectations for Page Objects:
expect($page)->toBeOnPage(DashboardPage::class); expect($page)->toSee('Welcome back');
| Expectation | Description |
|---|---|
toBeOnPage(string $pageClass) |
Asserts the current URL contains the page's defined URL path |
toSee(string $text) |
Asserts the given text is visible on the page |
Full Example
Here is a complete end-to-end registration flow demonstrating Page Objects, traits, navigation, and expectations working together.
Page Objects:
// tests/Browser/Pages/RegistrationPage.php namespace Tests\Browser\Pages; use Thelemon2020\PestPom\Page; use Thelemon2020\PestPom\Concerns\InteractsWithAlerts; use Thelemon2020\PestPom\Concerns\InteractsWithForms; class RegistrationPage extends Page { use InteractsWithForms; use InteractsWithAlerts; public static function url(): string { return '/register'; } public function register(string $name, string $email, string $password): static { return $this ->fillForm([ 'Name' => $name, 'Email' => $email, 'Password' => $password, ]) ->checkBox('I agree to the terms') ->submitForm('Create account'); } }
// tests/Browser/Pages/DashboardPage.php namespace Tests\Browser\Pages; use Thelemon2020\PestPom\Page; use Thelemon2020\PestPom\Concerns\InteractsWithNavigation; class DashboardPage extends Page { use InteractsWithNavigation; public static function url(): string { return '/dashboard'; } public function assertUserIsLoggedIn(string $name): static { return $this->assertSee("Welcome, {$name}"); } }
Tests:
// tests/Browser/RegistrationTest.php use Tests\Browser\Pages\DashboardPage; use Tests\Browser\Pages\RegistrationPage; it('allows a new user to register', function () { $dashboard = page(RegistrationPage::class) ->register('Jane Doe', 'jane@example.com', 'password') ->nowOn(DashboardPage::class); // server redirected here after successful registration expect($dashboard) ->toBeOnPage(DashboardPage::class) ->toSee('Welcome, Jane Doe'); }); it('shows a validation error when the email is taken', function () { page(RegistrationPage::class) ->register('Jane Doe', 'existing@example.com', 'password') ->assertFieldError('Email', 'This email is already in use.') ->assertErrorMessage('Registration failed. Please correct the errors below.'); });
License
MIT