thelemon2020/pest-plugin-pom

A Pest plugin for writing browser tests using the Page Object Model.

Maintainers

Package info

github.com/thelemon2020/pest-plugin-pom

pkg:composer/thelemon2020/pest-plugin-pom

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1 2026-05-05 13:22 UTC

This package is auto-updated.

Last update: 2026-05-05 14:08:32 UTC


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