Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 042 / 100

Testing React apps with Playwright: a step-by-step guide

End-to-end coverage for SPAs.

Expona Admin7 min read

React applications have become the standard for modern web development, but testing them end-to-end can feel like navigating a minefield of flaky tests and false positives. Testing React apps with Playwright: a step-by-step guide will help you build reliable end-to-end coverage for your single-page applications without the headaches. We'll walk through practical techniques that teams across e-commerce and SaaS use to validate real user journeys rather than implementation details.

Test user flows, not components

The biggest mistake we see is teams writing tests that couple to React's internal state or component structure. When you test components directly—reaching into Redux stores, checking component props, or verifying that a specific hook was called—your tests become brittle. They break every time you refactor, even when the user experience doesn't change.

Instead, think about what users actually do. They click buttons, fill forms, navigate between pages, and see results. Your Playwright tests should mirror those flows exactly. This approach gives you confidence that your application works end-to-end, and it survives internal refactoring without modification.

Let's say you're building a checkout flow. A poor test checks that a Redux action dispatched correctly. A good test checks that a user can enter their shipping address, select a payment method, and see an order confirmation.

typescript
// ❌ Don't do this - testing implementation details
test('dispatches setShippingAddress action', async ({ page }) => {
  // This breaks when you move to a different state manager
  const state = await page.evaluate(() => window.__REDUX_DEVTOOLS_EXTENSION__.getState());
  expect(state.shipping.address).toBeDefined();
});

// ✅ Do this - test the user flow
test('user can complete checkout', async ({ page }) => {
  await page.goto('https://app.example.com/checkout');
  
  // Fill shipping address
  await page.fill('[placeholder="Street Address"]', '123 Main St');
  await page.fill('[placeholder="City"]', 'Austin');
  await page.fill('[placeholder="ZIP Code"]', '78701');
  
  // Select payment method
  await page.click('text=Credit Card');
  await page.fill('[placeholder="Card Number"]', '4242424242424242');
  
  // Submit
  await page.click('button:has-text("Place Order")');
  
  // Verify success
  await expect(page.locator('text=Order Confirmed')).toBeVisible();
  await expect(page.url()).toContain('/order-confirmation');
});

This test survives component rewrites, state manager migrations, and CSS refactors. It only breaks when the actual user experience breaks—exactly what you want.

Use role locators

Playwright's role locators are a game-changer for finding elements in React apps. Instead of reaching for CSS selectors or data attributes, role locators use the accessibility tree—the same interface assistive technologies use. This means your tests are closer to how real users interact with your app.

When you use role locators, you automatically encourage semantic HTML. Your developers write better markup, your app becomes more accessible, and your tests become more resilient. Win-win-win.

typescript
// Find elements by their ARIA role
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('link', { name: 'View Profile' }).click();
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');

// Chain with other locators for more specific queries
const form = page.getByRole('form', { name: 'Contact Us' });
await form.getByRole('textbox', { name: 'Name' }).fill('Alice');
await form.getByRole('textbox', { name: 'Message' }).fill('Hello');
await form.getByRole('button', { name: 'Send' }).click();

If you absolutely need a selector that doesn't have a clear role, use test IDs as a fallback. Add data-testid attributes to your components for situations where accessibility roles don't fit naturally.

typescript
// Fallback: use test IDs for complex scenarios
await page.locator('[data-testid="dynamic-price-calculator"]').click();

// In your React component:
// <div data-testid="dynamic-price-calculator">...</div>

Mock slow APIs

Real API calls are the enemy of fast, reliable tests. Network latency is unpredictable, third-party services go down, and you end up with flaky tests that pass locally but fail in CI. The solution is to intercept network requests and return controlled responses.

Playwright's route interception lets you mock API responses without touching your application code. You control the response time, the data, and error conditions. Tests run in milliseconds instead of seconds, and you can test error states that are hard to trigger in the real world.

typescript
test('displays loading state while fetching user profile', async ({ page }) => {
  // Delay the API response by 2 seconds
  await page.route('**/api/user/profile', async (route) => {
    await new Promise(resolve => setTimeout(resolve, 2000));
    await route.continue();
  });
  
  await page.goto('https://app.example.com/profile');
  
  // Verify loading state appears
  await expect(page.locator('[role="status"]')).toContainText('Loading');
  
  // Wait for data to load
  await expect(page.locator('text=John Doe')).toBeVisible({ timeout: 5000 });
});

test('handles API errors gracefully', async ({ page }) => {
  // Return a 500 error for the API call
  await page.route('**/api/products', async (route) => {
    await route.abort('failed');
  });
  
  await page.goto('https://app.example.com/products');
  
  // Verify error message is shown
  await expect(page.locator('text=Failed to load products')).toBeVisible();
});

test('filters products and displays results', async ({ page }) => {
  // Mock successful API response
  await page.route('**/api/products?category=electronics', async (route) => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify([
        { id: 1, name: 'Laptop', price: 999 },
        { id: 2, name: 'Monitor', price: 299 }
      ])
    });
  });
  
  await page.goto('https://app.example.com/products');
  await page.getByRole('button', { name: 'Electronics' }).click();
  
  await expect(page.locator('text=Laptop')).toBeVisible();
  await expect(page.locator('text=Monitor')).toBeVisible();
});

Mocking APIs also lets you test your retry logic, timeout handling, and pagination without waiting for real responses. This is where a lot of the value in end-to-end testing comes from—you test the full application state machine, not just the happy path.

Run against the production build

Testing against your development server feels fast and convenient, but it won't catch everything. Your development build includes source maps, hot-reload code, and development-only features. Minification, tree-shaking, and bundle splitting can introduce bugs that only appear in production.

Build your app for production, serve it locally, and run your full test suite against it. This is the only way to catch build-time issues or subtle bugs that only manifest in optimized code. Many teams run this as a pre-deploy step in CI/CD.

typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  webServer: {
    command: 'npm run build && npm run serve:prod',
    url: 'http://localhost:3000',
    reuseExistingServer: process.env.CI ? false : true,
  },
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

In your package.json, ensure you have a production build command and a serve command that serves the built files:

typescript
// package.json
{
  "scripts": {
    "build": "react-scripts build",
    "serve:prod": "serve -s build -l 3000"
  }
}

Now when you run Playwright, it builds your app in production mode and tests against the actual optimized bundle. This catches issues that would otherwise only appear after deployment.

Common mistakes

  • Waiting with arbitrary timeouts. Don't use page.waitForTimeout(5000). Instead, wait for the specific element or condition you expect: await expect(page.locator('text=Data loaded')).toBeVisible(). Playwright's auto-waits handle the rest.
  • Testing implementation instead of behavior. If you find yourself checking Redux state, component props, or function calls, step back and ask "what does the user actually see?" Test that instead.
  • Neglecting error scenarios. Happy-path tests feel satisfying, but error handling is where real users suffer. Use route mocking to test network failures, timeouts, and validation errors.
  • Running tests only in headless mode. Headless is faster, but headed mode (with the browser window visible) is invaluable for debugging. Use headed mode locally when tests fail.
  • Forgetting to test against production builds. Development servers mask issues. Always run a test suite against a production build before deploying.

Key takeaways

  • Test user flows and outcomes, not React component internals or state management implementation.
  • Use Playwright's role locators to find elements semantically and encourage better accessibility in your app.
  • Mock API responses to control test speed, reliability, and edge cases without waiting for real network calls.
  • Always run critical tests against your production build to catch minification, tree-shaking, and bundling issues before they reach users.
  • Avoid arbitrary timeouts and implementation-detail assertions; let Playwright's auto-wait mechanism and role-based locators handle reliability.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles