Milind Daraniya

Advanced E2E Testing for Laravel + React Apps with Playwright

Published August 18th, 2036 6 min read

Unit tests and integration tests verify your backend controllers and individual React components in isolation. But when a real customer uses your application, bugs often hide in the seams between client-side state management, network timing, dynamic rendering, and backend database state.

Traditional browser automation tools like Cypress or Selenium are frequently bogged down by slow execution speeds, complex iframe handling, and flaky test selectors. Playwright has rapidly become the modern standard for End-to-End (E2E) testing due to its native multi-tab support, parallel worker execution, reliable auto-waiting mechanisms, and built-in API testing capabilities.

In this guide, we will implement an advanced E2E testing architecture for full-stack Laravel + React applications, covering authenticated state re-use, dynamic database resets, and simulating end-to-end checkout flows.


1. The Problem with Naive E2E Testing

Most basic E2E testing suites fail in production CI pipelines due to three common bottlenecks:

  1. UI-Based Login on Every Test: Filling out the email/password form in the browser before every single test case wastes dozens of minutes in CI.
  2. Database Contamination: Leftover records from previous test runs cause unexpected assertion failures.
  3. Arbitrary Sleep Timers: Using sleep(3000) or manual timeouts leads to race conditions and flakiness.

We will solve these issues by pre-authenticating via direct API calls, managing database state via testing endpoints/artisan commands, and relying on Playwright's web-first locator assertions.


2. Installing and Configuring Playwright

Install Playwright in your React frontend directory (or root repository):

npm init playwright@latest

Configure your playwright.config.ts with a global setup file to handle shared authentication state:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: 'html',
  use: {
    baseURL: process.env.APP_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    // Setup project for shared authentication
    {
      name: 'setup',
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // Re-use saved authentication storage state
        storageState: 'e2e/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

3. Fast Authentication via Storage State

Instead of typing login credentials via the UI for every test, log in once during the setup phase and store the browser session (cookies, local storage, CSRF tokens) into a reusable JSON state file.

Create e2e/auth.setup.ts:

import { test as setup, expect } from '@playwright/test';
import fs from 'fs';

const authFile = 'e2e/.auth/user.json';

setup('authenticate as customer', async ({ page, request }) => {
  // Option A: Perform quick UI login once
  await page.goto('/login');
  await page.getByLabel('Email Address').fill('testuser@example.com');
  await page.getByLabel('Password').fill('secret123');
  await page.getByRole('button', { name: 'Sign In' }).click();

  // Wait for redirect to dashboard
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Welcome Back' })).toBeVisible();

  // Save session state to disk
  await page.context().storageState({ path: authFile });
});

4. Managing Laravel Database State for Tests

To ensure tests remain isolated and deterministic, your Laravel application should expose a secure testing endpoint (restricted strictly to local and testing environments) to seed and reset data between test runs.

Add a dedicated route in routes/api.php:

<?php

use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Route;
use App\Models\User;
use App\Models\Product;

if (app()->environment('local', 'testing')) {
    Route::prefix('testing')->group(function () {
        Route::post('/reset-db', function () {
            Artisan::call('migrate:fresh --seed');
            return response()->json(['status' => 'Database reset complete']);
        });

        Route::post('/create-product', function () {
            $product = Product::factory()->create([
                'name'  => 'Wireless Mechanical Keyboard',
                'price' => 99.99,
                'stock' => 10,
            ]);

            return response()->json($product);
        });
    });
}

5. Writing a Resilient Full-Stack E2E Test Case

Now, let's write a complete E2E scenario in e2e/checkout-flow.spec.ts. This test resets backend state via API, navigates to the React product catalog as an already-authenticated user, adds an item to the cart, and verifies the order creation in the UI.

import { test, expect } from '@playwright/test';

test.describe('E-Commerce Checkout Flow', () => {
  test.beforeEach(async ({ request }) => {
    // Reset Laravel testing database before test execution
    const response = await request.post('http://localhost:8000/api/testing/reset-db');
    expect(response.ok()).toBeTruthy();

    // Create a predictable product via API
    await request.post('http://localhost:8000/api/testing/create-product');
  });

  test('user can browse product, add to cart, and complete checkout', async ({ page }) => {
    // 1. Visit Product Catalog (already logged in via storageState)
    await page.goto('/products');

    // 2. Locate product card and verify stock badge
    const productCard = page.locator('article', { hasText: 'Wireless Mechanical Keyboard' });
    await expect(productCard).toBeVisible();
    await expect(productCard.getByText('$99.99')).toBeVisible();

    // 3. Add to cart
    await productCard.getByRole('button', { name: 'Add to Cart' }).click();

    // 4. Verify slide-over cart modal updates without page reload
    const cartDrawer = page.getByRole('dialog', { name: 'Shopping Cart' });
    await expect(cartDrawer).toBeVisible();
    await expect(cartDrawer.getByText('Wireless Mechanical Keyboard')).toBeVisible();

    // 5. Proceed to checkout
    await cartDrawer.getByRole('button', { name: 'Proceed to Checkout' }).click();
    await expect(page).toHaveURL('/checkout');

    // 6. Fill checkout details
    await page.getByLabel('Shipping Address').fill('100 Technology Blvd');
    await page.getByLabel('City').fill('San Francisco');
    await page.getByRole('button', { name: 'Place Order' }).click();

    // 7. Assert success state and generated order ID
    await expect(page).toHaveURL(/\/orders\/\d+/);
    await expect(page.getByRole('heading', { name: 'Order Confirmed!' })).toBeVisible();
    await expect(page.getByText('Status: Processing')).toBeVisible();
  });
});

6. Key Best Practices for High-Velocity Teams

  • Prefer Accessibility Selectors: Always reach for getByRole(), getByLabel(), and getByText() rather than fragile CSS classes or XPath strings.
  • Avoid Arbitrary Delays: Let Playwright's automatic waiting handle state transitions. If you need to wait for a network response, use page.waitForResponse() rather than fixed timers.
  • Run Tests in Headless Mode on CI: Execute tests in parallel with GitHub Actions using the official Playwright Docker container for fast, deterministic builds.

Conclusion

End-to-End testing does not have to be slow or fragile. By leveraging Playwright's shared authentication states, seeding data directly through dedicated Laravel testing routes, and targeting semantic DOM locators, you can ship modern React frontends backed by Laravel APIs with total confidence on every deployment.