Skip to main content
Back to Blog
Tutorial
2026-03-24

Laravel Testing with Dusk: Complete PHP E2E Guide

Master Laravel Dusk for end-to-end browser testing in PHP. Learn setup, authentication testing, form handling, JavaScript execution, database assertions, and CI/CD integration with ChromeDriver.

Introduction to Laravel Dusk

Laravel Dusk provides an expressive, easy-to-use browser automation and testing API for your PHP applications. Unlike traditional HTTP testing tools that simulate requests without a real browser, Dusk operates a real Google Chrome instance through ChromeDriver, giving you the ability to test JavaScript-heavy applications, SPAs, and complex user flows exactly as your users experience them.

In this comprehensive guide, we will walk through everything you need to know about Laravel Dusk, from initial setup to advanced testing patterns, database assertions, and CI/CD integration.

Why Choose Dusk for E2E Testing?

Before diving in, it is worth understanding where Dusk fits in the PHP testing ecosystem. Laravel ships with built-in HTTP testing through PHPUnit, which is excellent for API endpoints and simple page loads. However, HTTP tests cannot interact with JavaScript, wait for AJAX calls, or test complex UI flows like drag-and-drop or file uploads.

Dusk fills that gap. It provides a fluent API that feels native to Laravel, integrates with your existing test suite, and supports the full range of browser interactions. Compared to standalone tools like Selenium or Cypress, Dusk has the advantage of deep Laravel integration, meaning you can use model factories, database transactions, and authentication helpers directly.

Key Benefits

  • Real browser testing with Google Chrome and ChromeDriver
  • Fluent API designed specifically for Laravel applications
  • Authentication helpers that bypass the login form for faster tests
  • Database integration with migrations, seeders, and the DatabaseMigrations trait
  • Screenshot capture on test failure for easy debugging
  • Waiting mechanisms for AJAX and JavaScript-rendered content
  • Page objects for organizing complex test logic
  • CI/CD ready with headless Chrome support

Setting Up Laravel Dusk

Installation

Getting started with Dusk requires just a few commands. First, install the package via Composer:

composer require laravel/dusk --dev

After installation, run the Dusk install command to scaffold the necessary files:

php artisan dusk:install

This creates a tests/Browser directory with an example test, a DuskTestCase.php base class, and a screenshots directory for failure captures. Dusk also downloads the appropriate ChromeDriver binary for your operating system.

Configuration

The DuskTestCase.php file configures how Chrome is launched. By default, it starts Chrome in headless mode:

use Laravel\Dusk\TestCase as BaseTestCase;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;

abstract class DuskTestCase extends BaseTestCase
{
    use CreatesApplication;

    protected function driver(): RemoteWebDriver
    {
        $options = (new ChromeOptions)->addArguments([
            '--disable-gpu',
            '--headless=new',
            '--window-size=1920,1080',
            '--no-sandbox',
            '--disable-dev-shm-usage',
        ]);

        return RemoteWebDriver::create(
            'http://localhost:9515',
            DesiredCapabilities::chrome()->setCapability(
                ChromeOptions::CAPABILITY_W3C, $options
            )
        );
    }
}

Environment Setup

Dusk uses a .env.dusk.local file when running tests. Create this file to configure a testing database and application URL:

APP_URL=http://localhost:8001
DB_DATABASE=your_app_testing
SESSION_DRIVER=file

Start the application server before running tests:

php artisan serve --port=8001 &
php artisan dusk

Writing Your First Dusk Test

Creating a Test

Generate a new Dusk test using Artisan:

php artisan dusk:make LoginTest

This creates tests/Browser/LoginTest.php:

namespace Tests\Browser;

use Laravel\Dusk\Browser;
use Tests\DuskTestCase;

class LoginTest extends DuskTestCase
{
    public function test_user_can_login(): void
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/login')
                ->type('email', 'user@example.com')
                ->type('password', 'password')
                ->press('Log in')
                ->assertPathIs('/dashboard')
                ->assertSee('Welcome back');
        });
    }
}

Running Tests

Execute all Dusk tests with:

php artisan dusk

Run a specific test file:

php artisan dusk tests/Browser/LoginTest.php

Run a specific test method:

php artisan dusk --filter test_user_can_login

Authentication Testing

One of Dusk's most powerful features is its authentication helpers. Instead of navigating through login forms for every test, you can authenticate programmatically.

Using loginAs

use App\Models\User;

public function test_dashboard_shows_user_projects(): void
{
    $user = User::factory()->create();

    $this->browse(function (Browser $browser) use ($user) {
        $browser->loginAs($user)
            ->visit('/dashboard')
            ->assertSee('My Projects')
            ->assertSee($user->name);
    });
}

The loginAs method sets the session cookie directly, skipping the login form entirely. This makes tests significantly faster and more reliable.

Testing Registration Flows

For the registration flow itself, test the full form interaction:

public function test_user_can_register(): void
{
    $this->browse(function (Browser $browser) {
        $browser->visit('/register')
            ->type('name', 'Jane Doe')
            ->type('email', 'jane@example.com')
            ->type('password', 'SecurePass123!')
            ->type('password_confirmation', 'SecurePass123!')
            ->press('Create Account')
            ->assertPathIs('/dashboard')
            ->assertAuthenticated();
    });
}

Testing Authorization

Test that unauthorized users are redirected appropriately:

public function test_guests_cannot_access_admin(): void
{
    $this->browse(function (Browser $browser) {
        $browser->visit('/admin')
            ->assertPathIs('/login');
    });
}

public function test_regular_users_see_403_on_admin(): void
{
    $user = User::factory()->create(['role' => 'user']);

    $this->browse(function (Browser $browser) use ($user) {
        $browser->loginAs($user)
            ->visit('/admin')
            ->assertSee('403')
            ->assertSee('Forbidden');
    });
}

Form Handling and Interactions

Dusk provides a rich set of methods for interacting with forms, covering every common input type.

Text Inputs and Textareas

$browser->type('title', 'My New Post')
    ->type('body', 'This is the content of my post...')
    ->append('body', ' with additional text')
    ->clear('title')
    ->type('title', 'Updated Title');

Select Dropdowns

// Select by value
$browser->select('category', 'technology');

// Select a random option
$browser->select('category');

Checkboxes and Radio Buttons

$browser->check('agree_terms')
    ->uncheck('subscribe_newsletter')
    ->radio('plan', 'premium');

File Uploads

$browser->attach('avatar', __DIR__ . '/fixtures/photo.jpg');

// Multiple files
$browser->attach('documents[]', [
    __DIR__ . '/fixtures/doc1.pdf',
    __DIR__ . '/fixtures/doc2.pdf',
]);

Date and Time Inputs

$browser->type('start_date', '2026-03-24')
    ->type('start_time', '09:00');

Complex Form Test Example

public function test_user_can_create_event(): void
{
    $user = User::factory()->create();

    $this->browse(function (Browser $browser) use ($user) {
        $browser->loginAs($user)
            ->visit('/events/create')
            ->type('title', 'Laravel Meetup')
            ->type('description', 'Monthly Laravel community meetup')
            ->select('category', 'meetup')
            ->type('date', '2026-04-15')
            ->type('time', '18:00')
            ->type('location', '123 Main St')
            ->check('is_public')
            ->attach('banner', __DIR__ . '/fixtures/banner.jpg')
            ->press('Create Event')
            ->assertPathIs('/events')
            ->assertSee('Laravel Meetup')
            ->assertSee('Event created successfully');
    });
}

JavaScript Execution and Waiting

Executing JavaScript

Dusk lets you run arbitrary JavaScript in the browser context:

$browser->script('window.scrollTo(0, document.body.scrollHeight)');

// Get a return value
$result = $browser->script('return document.title');

// Multiple scripts
$browser->script([
    'window.localStorage.setItem("theme", "dark")',
    'document.body.classList.add("dark-mode")',
]);

Waiting for Elements

Modern applications often load content asynchronously. Dusk provides several waiting methods:

// Wait for text to appear (default 5 seconds)
$browser->waitForText('Data loaded');

// Wait for an element
$browser->waitFor('.results-table');

// Wait for an element to disappear
$browser->waitUntilMissing('.loading-spinner');

// Wait with custom timeout (in seconds)
$browser->waitFor('.slow-content', 15);

// Wait for a JavaScript expression to be true
$browser->waitUntil('window.appReady === true');

// Wait for a route (Vue/React)
$browser->waitForRoute('dashboard');

// Pause for a fixed duration (use sparingly)
$browser->pause(1000);

Handling Modals and Dialogs

// Accept a JavaScript confirm dialog
$browser->press('Delete')
    ->acceptDialog();

// Dismiss a dialog
$browser->press('Delete')
    ->dismissDialog();

// Type into a prompt
$browser->press('Rename')
    ->typeInDialog('New Name')
    ->acceptDialog();

Working with Frames

$browser->withinFrame('#payment-iframe', function (Browser $browser) {
    $browser->type('card-number', '4242424242424242')
        ->type('expiry', '12/28')
        ->type('cvc', '123')
        ->press('Pay');
});

Page Objects

Page objects encapsulate the structure and behavior of a page, keeping tests clean and maintainable.

Creating a Page Object

php artisan dusk:page CreateProject

This creates tests/Browser/Pages/CreateProject.php:

namespace Tests\Browser\Pages;

use Laravel\Dusk\Browser;
use Laravel\Dusk\Page;

class CreateProject extends Page
{
    public function url(): string
    {
        return '/projects/create';
    }

    public function assert(Browser $browser): void
    {
        $browser->assertPathIs($this->url())
            ->assertSee('Create New Project');
    }

    public function elements(): array
    {
        return [
            '@name' => '#project-name',
            '@description' => '#project-description',
            '@framework' => 'select[name="framework"]',
            '@submit' => 'button[type="submit"]',
        ];
    }

    public function fillProjectForm(
        Browser $browser,
        string $name,
        string $description,
        string $framework
    ): void {
        $browser->type('@name', $name)
            ->type('@description', $description)
            ->select('@framework', $framework)
            ->click('@submit');
    }
}

Using Page Objects in Tests

use Tests\Browser\Pages\CreateProject;

public function test_user_can_create_project(): void
{
    $user = User::factory()->create();

    $this->browse(function (Browser $browser) use ($user) {
        $browser->loginAs($user)
            ->visit(new CreateProject)
            ->fillProjectForm(
                'My Laravel App',
                'A web application built with Laravel',
                'laravel'
            )
            ->assertSee('Project created successfully');
    });
}

Database Assertions and Test Data

Using DatabaseMigrations

The DatabaseMigrations trait resets the database between each test:

use Illuminate\Foundation\Testing\DatabaseMigrations;

class ProjectTest extends DuskTestCase
{
    use DatabaseMigrations;

    public function test_projects_are_listed(): void
    {
        $user = User::factory()
            ->has(Project::factory()->count(3))
            ->create();

        $this->browse(function (Browser $browser) use ($user) {
            $browser->loginAs($user)
                ->visit('/projects')
                ->assertSee($user->projects->first()->name);
        });
    }
}

Asserting Database State After Actions

While Dusk focuses on browser assertions, you can combine it with PHPUnit database assertions:

public function test_creating_project_persists_to_database(): void
{
    $user = User::factory()->create();

    $this->browse(function (Browser $browser) use ($user) {
        $browser->loginAs($user)
            ->visit('/projects/create')
            ->type('name', 'Test Project')
            ->type('description', 'A test project')
            ->press('Create')
            ->assertSee('Project created');
    });

    $this->assertDatabaseHas('projects', [
        'name' => 'Test Project',
        'user_id' => $user->id,
    ]);
}

Seeding Test Data

public function test_admin_can_see_all_users(): void
{
    $this->seed(UserSeeder::class);
    $admin = User::factory()->create(['role' => 'admin']);

    $this->browse(function (Browser $browser) use ($admin) {
        $browser->loginAs($admin)
            ->visit('/admin/users')
            ->assertSee('Showing 25 users');
    });
}

Advanced Patterns

Multiple Browser Instances

Test real-time features with multiple browser windows:

public function test_live_chat_between_users(): void
{
    $alice = User::factory()->create(['name' => 'Alice']);
    $bob = User::factory()->create(['name' => 'Bob']);

    $this->browse(function (Browser $first, Browser $second) use ($alice, $bob) {
        $first->loginAs($alice)
            ->visit('/chat/general');

        $second->loginAs($bob)
            ->visit('/chat/general');

        $first->type('message', 'Hello from Alice!')
            ->press('Send');

        $second->waitForText('Hello from Alice!')
            ->assertSee('Alice');

        $second->type('message', 'Hi Alice!')
            ->press('Send');

        $first->waitForText('Hi Alice!')
            ->assertSee('Bob');
    });
}

Component Testing with Dusk Selectors

Use the dusk attribute in your Blade templates for stable selectors:

<button dusk="submit-order">Place Order</button>
<div dusk="order-total">{{ $total }}</div>

Then reference them in tests:

$browser->click('@submit-order')
    ->waitFor('@order-confirmation')
    ->assertSeeIn('@order-total', '$99.99');

Custom Assertions

Extend the Browser class with custom assertions:

// In DuskTestCase.php
Browser::macro('assertHasNotification', function (string $message) {
    /** @var Browser $this */
    return $this->waitFor('.notification')
        ->assertSeeIn('.notification', $message);
});

// In tests
$browser->press('Save')
    ->assertHasNotification('Changes saved successfully');

Testing API-Driven UIs

For SPAs or Livewire components that fetch data via API:

public function test_search_filters_results(): void
{
    Project::factory()->create(['name' => 'Laravel Blog']);
    Project::factory()->create(['name' => 'React Dashboard']);
    $user = User::factory()->create();

    $this->browse(function (Browser $browser) use ($user) {
        $browser->loginAs($user)
            ->visit('/projects')
            ->waitFor('.project-list')
            ->type('search', 'Laravel')
            ->waitUntilMissing('.loading')
            ->waitFor('.project-card')
            ->assertSee('Laravel Blog')
            ->assertDontSee('React Dashboard');
    });
}

Screenshots and Debugging

Automatic Failure Screenshots

Dusk automatically captures screenshots when a test fails. They are stored in tests/Browser/screenshots/. The naming convention includes the test class and method name for easy identification.

Manual Screenshots

Capture screenshots at any point in your test for debugging:

$browser->screenshot('before-submit')
    ->press('Submit')
    ->screenshot('after-submit');

Console Log Access

Access browser console output for debugging JavaScript issues:

$browser->visit('/app')
    ->waitFor('#loaded');

$logs = $browser->driver->manage()->getLog('browser');
foreach ($logs as $log) {
    dump($log['message']);
}

Storing Page Source

Save the HTML source for inspection:

$browser->storeSource('page-debug');

CI/CD Integration with ChromeDriver

GitHub Actions Configuration

Here is a complete GitHub Actions workflow for running Dusk tests:

name: Dusk Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  dusk:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: testing
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_mysql

      - name: Install dependencies
        run: composer install --no-progress --prefer-dist

      - name: Install Chrome
        uses: browser-actions/setup-chrome@latest

      - name: Start ChromeDriver
        run: |
          chromedriver --port=9515 &

      - name: Prepare Dusk
        run: |
          cp .env.dusk.ci .env
          php artisan key:generate
          php artisan migrate

      - name: Start application
        run: |
          php artisan serve --port=8001 &
          sleep 3

      - name: Run Dusk tests
        run: php artisan dusk

      - name: Upload screenshots
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: dusk-screenshots
          path: tests/Browser/screenshots/

      - name: Upload console logs
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: dusk-console
          path: tests/Browser/console/

Docker-Based CI

For Docker-based CI environments:

FROM php:8.3-cli

RUN apt-get update && apt-get install -y \
    chromium \
    chromium-driver \
    zip unzip git

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /app
COPY . .

RUN composer install --no-dev --optimize-autoloader

ENV CHROME_BIN=/usr/bin/chromium
ENV CHROMEDRIVER_BIN=/usr/bin/chromedriver

ChromeDriver Version Management

Keep ChromeDriver in sync with Chrome. In your DuskTestCase.php:

public static function prepare(): void
{
    if (! static::runningInSail()) {
        static::startChromeDriver(['--port=9515']);
    }
}

For CI, you may want to pin a specific ChromeDriver version. Dusk provides a command to update it:

php artisan dusk:chrome-driver --detect

Best Practices

Test Organization

Organize your Dusk tests by feature area. Keep them separate from your unit and feature tests:

tests/
  Browser/
    Auth/
      LoginTest.php
      RegistrationTest.php
    Projects/
      CreateProjectTest.php
      EditProjectTest.php
    Admin/
      UserManagementTest.php
    Pages/
      CreateProject.php
      Dashboard.php
    screenshots/
    console/

Selector Strategy

Prefer dusk attributes over CSS classes or IDs. CSS classes change with styling updates, but dusk attributes are dedicated to testing:

<!-- Fragile -->
<button class="btn btn-primary submit-btn">Save</button>

<!-- Robust -->
<button dusk="save-project">Save</button>

In production, you can strip dusk attributes using a middleware or build step to keep your HTML clean.

Waiting Over Pausing

Never use pause() as a primary waiting strategy. It is slow and flaky. Always prefer Dusk's built-in waiting methods:

// Bad
$browser->press('Save')->pause(3000)->assertSee('Saved');

// Good
$browser->press('Save')->waitForText('Saved');

Keep Tests Independent

Each test should set up its own data and not depend on other tests. Use the DatabaseMigrations trait and model factories to ensure a clean state.

Limit Dusk Test Scope

Dusk tests are slower than unit or HTTP tests. Reserve them for scenarios that truly require a browser: JavaScript interactions, complex multi-step workflows, real-time features, and visual regressions. Cover business logic in faster unit and feature tests.

Conclusion

Laravel Dusk bridges the gap between unit testing and manual QA by providing automated end-to-end browser testing that integrates seamlessly with the Laravel ecosystem. Its fluent API, authentication helpers, and page objects make writing browser tests almost as pleasant as writing Laravel code itself.

By combining Dusk with your existing PHPUnit tests, you can build a comprehensive testing strategy that covers everything from individual functions to complete user journeys. With proper CI/CD integration, you can catch regressions before they reach production and ship with confidence.

Start with the critical user paths in your application, such as login, registration, checkout, and core workflows, then expand your Dusk test suite as your application grows.

Laravel Testing with Dusk: Complete PHP E2E Guide | QASkills.sh