Introduction
Once you have a handful of Dusk tests, duplication creeps in: every login test types the same email and password, every dashboard test starts from the same nav menu. Page Objects and Components let you extract those repeated flows into classes, so tests read like high-level scripts and refactors only touch one file.
Key Concepts
- Page Object: A class representing a full page, with
url(),assert(),elements(), and custom action methods. - Component: A class representing a reusable UI piece (dropdown, modal, date picker) that appears on many pages.
- JavaScript dialog helpers:
acceptDialog(),dismissDialog(),typeInDialog()for nativealert/confirm/prompt. - Multi-browser testing: Passing multiple
Browserparameters to$this->browse()for testing realtime features.
Real World Context
A 50-test Dusk suite that hits the login page will have 50 copies of type('email', ...) → type('password', ...) → press('Log in'). One change to the login flow means 50 edits. Extract those three lines into a LoginPage::login() method and you get the change in one place.
Deep Dive
Page Objects
Page Objects encapsulate page-specific logic:
bashphp artisan dusk:page LoginPage
php// tests/Browser/Pages/LoginPage.php <?php namespace Tests\Browser\Pages; use Laravel\Dusk\Browser; use Laravel\Dusk\Page; class LoginPage extends Page { public function url(): string { return '/login'; } public function assert(Browser $browser): void { $browser->assertPathIs($this->url()) ->assertSee('Log in'); } public function elements(): array { return [ '@email' => 'input[name="email"]', '@password' => 'input[name="password"]', '@submit' => 'button[type="submit"]', '@error' => '.text-red-600', ]; } public function login(Browser $browser, string $email, string $password): void { $browser->type('@email', $email) ->type('@password', $password) ->click('@submit'); } public function assertHasError(Browser $browser, string $message): void { $browser->assertSeeIn('@error', $message); } }
Using the Page Object:
phppublic function test_user_can_login(): void { $user = User::factory()->create(['password' => bcrypt('password')]); $this->browse(function (Browser $browser) use ($user) { $browser->visit(new LoginPage) ->login($user->email, 'password') ->assertPathIs('/dashboard'); }); } public function test_shows_error_for_invalid_credentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new LoginPage) ->login('wrong@example.com', 'wrongpassword') ->on(new LoginPage) // Still on login page ->assertHasError('credentials do not match'); }); }
Components
Reusable components for repeated UI elements:
bashphp artisan dusk:component DropdownComponent
php// tests/Browser/Components/DropdownComponent.php <?php namespace Tests\Browser\Components; use Laravel\Dusk\Browser; use Laravel\Dusk\Component as BaseComponent; class DropdownComponent extends BaseComponent { public function selector(): string { return '.dropdown'; } public function assert(Browser $browser): void { $browser->assertVisible($this->selector()); } public function elements(): array { return [ '@trigger' => '.dropdown-trigger', '@menu' => '.dropdown-menu', '@items' => '.dropdown-item', ]; } public function open(Browser $browser): void { $browser->click('@trigger') ->waitFor('@menu'); } public function selectItem(Browser $browser, string $text): void { $this->open($browser); $browser->clickLink($text); } }
phppublic function test_user_can_change_settings(): void { $this->browse(function (Browser $browser) { $browser->loginAs($this->user) ->visit('/dashboard') ->within(new DropdownComponent, function (Browser $browser) { $browser->selectItem('Settings'); }) ->assertPathIs('/settings'); }); }
Testing JavaScript Interactions
phppublic function test_modal_opens_and_closes(): void { $this->browse(function (Browser $browser) { $browser->visit('/posts') ->click('@create-post-button') ->waitFor('@modal') ->assertVisible('@modal') ->type('@modal-title', 'My Post') ->within('@modal', function (Browser $modal) { $modal->press('Cancel'); }) ->waitUntilMissing('@modal') ->assertMissing('@modal'); }); } public function test_infinite_scroll(): void { Post::factory()->count(50)->create(); $this->browse(function (Browser $browser) { $browser->visit('/posts') ->assertSeeIn('.post-count', '20') // Initial load ->scrollToBottom() ->waitForText('Loading more...') ->waitUntilMissingText('Loading more...') ->assertSeeIn('.post-count', '40'); // After scroll }); }
Handling JavaScript Dialogs
phppublic function test_delete_confirmation(): void { $post = Post::factory()->create(); $this->browse(function (Browser $browser) use ($post) { // Accept the dialog $browser->loginAs($post->user) ->visit('/posts/' . $post->id) ->click('@delete-button') ->acceptDialog() ->assertPathIs('/posts') ->assertDontSee($post->title); }); } public function test_cancel_delete(): void { $post = Post::factory()->create(); $this->browse(function (Browser $browser) use ($post) { $browser->loginAs($post->user) ->visit('/posts/' . $post->id) ->click('@delete-button') ->dismissDialog() ->assertPathIs('/posts/' . $post->id); }); } public function test_prompt_dialog(): void { $this->browse(function (Browser $browser) { $browser->visit('/settings') ->click('@rename-button') ->typeInDialog('New Name') ->acceptDialog() ->assertSee('New Name'); }); }
Screenshots and Console
phppublic function test_with_debugging(): void { $this->browse(function (Browser $browser) { $browser->visit('/complex-page') ->screenshot('step-1-initial') ->click('@action') ->screenshot('step-2-after-click') ->assertSee('Result'); }); } // Screenshots saved to tests/Browser/screenshots/ // Automatic screenshots on failure (in DuskTestCase) protected function captureFailuresFor() { return collect($this->storeScreenshotsAt()); }
Console Logs
phppublic function test_no_javascript_errors(): void { $this->browse(function (Browser $browser) { $browser->visit('/') ->assertNoJavascriptErrors(); }); } public function test_check_console_output(): void { $this->browse(function (Browser $browser) { $browser->visit('/'); $logs = $browser->driver->manage()->getLog('browser'); foreach ($logs as $log) { $this->assertNotEquals('SEVERE', $log['level']); } }); }
Multiple Browsers
Test interactions between users:
phppublic function test_realtime_chat(): void { $alice = User::factory()->create(['name' => 'Alice']); $bob = User::factory()->create(['name' => 'Bob']); $this->browse(function (Browser $first, Browser $second) use ($alice, $bob) { // Alice joins chat $first->loginAs($alice) ->visit('/chat') ->waitFor('@chat-ready'); // Bob joins chat $second->loginAs($bob) ->visit('/chat') ->waitFor('@chat-ready'); // Alice sends message $first->type('@message-input', 'Hello Bob!') ->press('Send'); // Bob receives message $second->waitForText('Hello Bob!') ->assertSee('Alice: Hello Bob!'); // Bob replies $second->type('@message-input', 'Hi Alice!') ->press('Send'); // Alice receives reply $first->waitForText('Hi Alice!') ->assertSee('Bob: Hi Alice!'); }); }
Common Pitfalls
- Putting business logic in Page Objects — a
LoginPagethat checks whether a user is an admin mixes UI navigation with domain logic. Keep Page Objects focused on clicks, types, and selectors. - Overusing Components — not every repeated three-line pattern needs a Component class. Extract when the pattern appears four or five times, not two.
Best Practices
- One Page Object per page in your app —
LoginPage,DashboardPage,SettingsPage. Navigation flows become high-level scripts like->visit(new LoginPage)->login(...)->on(new DashboardPage). - Screenshot at key steps during hard-to-debug flows —
$browser->screenshot('after-submit')gives you a visual trail when a test fails on CI.
Summary
- Page Objects encapsulate page-specific selectors and actions.
- Components are reusable UI pieces that work across pages.
acceptDialog/dismissDialoghandle native JavaScript prompts.$this->browse(function ($first, $second) { ... })runs two browsers for realtime tests.