Skip to content

Best Practices

Follow these best practices to write maintainable, reliable browser tests.

Use Data Test IDs

Don't rely on CSS classes or element structure:

php
// ❌ Bad - brittle, breaks when styling changes
$this->bridge('/login')
    ->fill('.form-control.email-field', 'user@example.com')
    ->click('.btn.btn-primary.submit-btn');

// ❌ Bad - relies on DOM structure
$this->bridge('/login')
    ->fill('form > div:nth-child(2) > input', 'user@example.com');

Do use data-testid attributes:

php
// ✅ Good - explicit test selectors
$this->bridge('/login')
    ->fill('[data-testid="email-input"]', 'user@example.com')
    ->click('[data-testid="login-button"]');

Add these attributes in your frontend:

html
<input data-testid="email-input" type="email" class="form-control" />
<button data-testid="login-button" type="submit">Login</button>

Wait Strategically

After Form Submissions

php
test('login redirects to dashboard', function () {
    $this->bridge('/login')
        ->fill('[data-testid="email"]', 'user@example.com')
        ->fill('[data-testid="password"]', 'password')
        ->click('[data-testid="login-button"]')
        ->wait(2) // Wait for API call + redirect
        ->assertPathContains('/dashboard');
});

Between Sequential Fills

For apps that validate on blur:

php
test('form validation', function () {
    $this->bridge('/register')
        ->fill('[data-testid="email"]', 'user@example.com')
        ->wait(0.3) // Allow validation to trigger
        ->fill('[data-testid="password"]', 'password')
        ->wait(0.3)
        ->click('[data-testid="submit"]');
});

Use assertVisible for Dynamic Content

php
// ✅ assertVisible waits for element to appear
$this->bridge('/dashboard')
    ->assertVisible('[data-testid="user-data"]');

// Instead of:
// ❌ wait(2)->assertSee('...')

Test Isolation

Automatic Cleanup

The plugin automatically resets Bridge configuration when tests complete. No manual cleanup needed!

Clear Browser State

Each test gets a fresh browser context, but you may need to clear app state:

php
beforeEach(function () {
    // Clear database
    $this->artisan('migrate:fresh');

    // Seed test data
    $this->seed(TestSeeder::class);
});

Organize Tests

php
describe('Authentication', function () {
    test('user can login', function () { /* ... */ });
    test('user can logout', function () { /* ... */ });
    test('user can reset password', function () { /* ... */ });
});

describe('Shopping Cart', function () {
    test('user can add item', function () { /* ... */ });
    test('user can remove item', function () { /* ... */ });
    test('user can checkout', function () { /* ... */ });
});

Use Test Groups

php
test('admin can manage users', function () {
    // ...
})->group('admin', 'browser');

// Run specific groups
// ./vendor/bin/pest --group=admin

Debug Effectively

Use Headed Mode

bash
./vendor/bin/pest tests/Browser --headed

Use debug()

php
test('debugging', function () {
    $this->bridge('/complex-page')
        ->debug() // Pauses for inspection
        ->click('[data-testid="submit"]');
});

Check Screenshots

Failed tests create screenshots in Tests/Browser/Screenshots/.

CI/CD Considerations

GitHub Actions Example

yaml
name: Browser Tests

on: [push, pull_request]

jobs:
  browser-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install PHP dependencies
        run: composer install

      - name: Install Playwright
        run: |
          npm install playwright
          npx playwright install chromium
          npx playwright install-deps chromium

      - name: Install frontend dependencies
        run: cd frontend && npm install

      - name: Run tests
        run: ./vendor/bin/pest --group=browser

Automatic Server Management

With ->serve() configuration, you don't need to manually start the frontend in CI. The plugin handles it automatically!

Headless by Default

Tests run headless in CI. Only use --headed locally:

bash
# Local development
./vendor/bin/pest tests/Browser --headed

# CI (default headless)
./vendor/bin/pest tests/Browser

Performance Tips

Minimize Waits

php
// ❌ Arbitrary waits
->wait(5)

// ✅ Wait for specific element
->assertVisible('[data-testid="content"]')

Parallel Test Execution

bash
./vendor/bin/pest --parallel

Skip Slow Tests Locally

php
test('slow integration test', function () {
    // ...
})->skip(fn () => !env('CI'), 'Skipping slow test locally');

Error Handling

Test Error States

php
test('shows validation errors', function () {
    $this->bridge('/register')
        ->fill('[data-testid="email"]', 'invalid-email')
        ->click('[data-testid="submit"]')
        ->assertVisible('[data-testid="email-error"]')
        ->assertSee('Please enter a valid email');
});

Test Loading States

php
test('shows loading indicator', function () {
    $this->bridge('/dashboard')
        ->click('[data-testid="refresh"]')
        ->assertVisible('[data-testid="loading-spinner"]')
        ->wait(2)
        ->assertNotVisible('[data-testid="loading-spinner"]');
});

Vue/Nuxt Framework-Specific Best Practices

When testing Vue, Nuxt, or other reactive frontends, follow these additional guidelines.

Understanding the fill() vs typeSlowly() Problem

Why This Matters

This is not a bug — it's a fundamental difference in how Playwright methods interact with JavaScript frameworks.

Playwright has two approaches for entering text:

MethodPlaywright EquivalentWhat It DoesFramework Reactivity
fill()locator.fill()Sets DOM value property directly❌ No events fired
typeSlowly()locator.pressSequentially()Simulates keydown → input → keyup for each character✅ Full event chain

Vue's v-model listens for input events to update its reactive state. When fill() sets the DOM value directly, no input event fires, so Vue never sees the change.

fill('test')           → DOM: value="test"  → Vue state: ""     ❌
typeSlowly('test', 20) → DOM: value="test"  → Vue state: "test" ✅
                          ↑ keydown/input/keyup events fired

This applies to all reactive frameworks: Vue, React, Angular, Svelte — any framework that relies on input events for data binding.

Use typeSlowly() Instead of fill() for Reactive Forms

Vue's v-model directive doesn't sync with Playwright's fill() method because it sets the DOM value directly without triggering proper input events.

php
// ❌ Bad - Vue v-model won't see the value
$this->bridge('/login')
    ->fill('[data-testid="email"]', 'user@example.com')
    ->click('[data-testid="login-button"]');

// ✅ Good - Triggers proper input events for Vue reactivity
$this->bridge('/login')
    ->typeSlowly('[data-testid="email"]', 'user@example.com', 20)
    ->typeSlowly('[data-testid="password"]', 'password', 20)
    ->click('[data-testid="login-button"]');

Click First Input Before Typing

There's a timing issue where the first few characters can get lost when typing immediately after page load:

php
// ❌ Bad - First characters may be lost
$this->bridge('/register')
    ->typeSlowly('input#name', 'TestUser', 30);  // Might become "User"

// ✅ Good - Click focuses the input and ensures readiness
$this->bridge('/register')
    ->waitForEvent('networkidle')
    ->click('input#name')           // Focus first
    ->typeSlowly('input#name', 'TestUser', 30);  // Full "TestUser"

Use waitForEvent('networkidle') for API Calls

Instead of arbitrary waits, use network idle to wait for API calls to complete:

php
// ❌ Bad - Arbitrary wait may be too short or too long
->click('[data-testid="submit"]')
->wait(3)
->assertPathContains('/dashboard');

// ✅ Good - Waits until network is idle
->click('[data-testid="submit"]')
->waitForEvent('networkidle')
->assertPathContains('/dashboard');

Don't Use RefreshDatabase Trait

The RefreshDatabase trait wraps tests in a database transaction, which creates isolation that prevents seeing changes made by the external server:

php
// tests/Pest.php

// ❌ Bad - External server writes won't be visible
pest()->extends(TestCase::class)
    ->use(RefreshDatabase::class)
    ->in('Browser');

// ✅ Good - No transaction isolation
pest()->extends(TestCase::class)
    ->in('Browser');

Verify Results Via UI Instead of Database

Since database isolation doesn't work with external servers, verify results through UI assertions:

php
// ❌ Bad - Database check won't see external server's writes
expect(User::where('email', $email)->exists())->toBeTrue();

// ✅ Good - Verify via UI
->assertPathContains('/dashboard')
->assertSee('Welcome')
->assertSee($email);

Use Unique Test Data

Without database refresh, use timestamps or unique IDs to avoid conflicts:

php
// ❌ Bad - May conflict with previous test runs
$email = 'test@example.com';

// ✅ Good - Unique for each test run
$email = 'test'.time().'@example.com';

Complete Working Pattern for Vue/Nuxt

php
it('can register a new user', function () {
    $email = 'register'.time().'@example.com';

    $this->bridge('/register')
        ->waitForEvent('networkidle')     // Wait for page load
        ->click('input#name')              // Focus first input
        ->typeSlowly('input#name', 'NewUser', 30)
        ->typeSlowly('input#email', $email, 20)
        ->typeSlowly('input#password', 'password123', 20)
        ->typeSlowly('input#password_confirmation', 'password123', 20)
        ->click('button[type="submit"]')
        ->waitForEvent('networkidle')     // Wait for API call
        ->assertPathContains('/dashboard')
        ->assertSee('Welcome');
});

Troubleshooting Common Issues

Form Submits Empty Values

  • Cause: Vue's v-model not syncing with fill()
  • Solution: Use typeSlowly() instead

First Characters Lost When Typing

  • Cause: Page not fully ready for input
  • Solution: Add ->click('input#field') before first typeSlowly()

Database Assertions Fail But UI Shows Success

  • Cause: RefreshDatabase transaction isolation
  • Solution: Don't use RefreshDatabase; use UI assertions

CSRF Token Mismatch (419 Error)

  • Cause: Laravel's statefulApi() middleware
  • Solution: Remove from bootstrap/app.php for token-based auth

Tests Hang or Timeout

  • Cause: Waiting for something that never happens
  • Solution: Use waitForEvent('networkidle') instead of fixed wait()

Summary

PracticeWhy
Use data-testidStable selectors that don't break with UI changes
Wait strategicallyAvoid flaky tests from race conditions
Reset stateEnsure test isolation
Group testsBetter organization and selective running
Debug with headed modeSee what's happening
Consider CIEnsure tests work in headless mode
Use typeSlowly() for VueVue's v-model needs proper input events
Click before first typePrevents character loss on page load
Use waitForEvent('networkidle')Better than arbitrary waits for API calls
Skip RefreshDatabaseTransaction isolation breaks external server tests

Released under the MIT License.