Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 064 / 100

End-to-end tests with Playwright: lessons from production

Confidence that the whole flow works.

Expona Admin9 min read

You've built a solid React frontend and a production-grade Next.js backend, but how confident are you that a real user can actually sign up, add items to their cart, and complete payment without hitting a wall? End-to-end tests with Playwright answer that question by automating the flows that matter most—and they do it fast enough to run in every CI pipeline. In this post, I'll walk you through testing critical user journeys, keeping your test selectors stable across redesigns, and running parallel test suites that catch regressions before they reach production.

Why end-to-end testing matters for production platforms

I've shipped enough e-commerce and SaaS platforms to know that unit tests and integration tests catch bugs, but they don't catch the cascade of problems that happen when frontend, backend, and payment gateway all talk at once. A form might submit, your API might accept it, but the redirect might fail. A checkout might render, but the payment token flow might hang. Those gaps are where production incidents live.

End-to-end tests fill that gap by running your entire application stack—from browser to database—and validating that the user experience works end-to-end. Playwright does this well: it's fast (runs headless by default), reliable (built-in retry logic), and integrates seamlessly into CI/CD pipelines. But the real win is selective coverage. You're not testing every button and every edge case—you're testing the critical paths that directly impact revenue and trust.

Critical paths only—Signup, checkout, payment. Not every button.

The biggest mistake teams make is writing end-to-end tests for everything. Your test suite balloons, it takes 30 minutes to run, and developers stop waiting for results. Instead, identify your critical paths: the flows that, if broken, cost you money or users.

For an e-commerce platform, that's usually:

  • Signup and email verification – can a new user create an account?
  • Browse and add to cart – does product discovery and cart persistence work?
  • Checkout and payment – does the order complete end-to-end?
  • Login and account access – can a returning user log in?

For a SaaS platform, swap checkout for subscription creation and focus on onboarding and core feature workflows.

Here's what a signup test looks like in Playwright:

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

test('User can sign up and verify email', async ({ page }) => {
  await page.goto('https://app.example.com/signup');
  
  await page.fill('input[name="email"]', 'newuser@example.com');
  await page.fill('input[name="password"]', 'SecurePass123!');
  await page.click('button:has-text("Sign Up")');
  
  // Wait for confirmation message
  await expect(page.locator('text=Check your email')).toBeVisible();
  
  // Simulate email verification (in real tests, fetch the link from your email service)
  const verifyCode = '123456'; // Get from email service API
  await page.goto(`https://app.example.com/verify?code=${verifyCode}`);
  
  // Confirm redirect to dashboard
  await expect(page).toHaveURL(/\/dashboard/);
  await expect(page.locator('text=Welcome')).toBeVisible();
});

Notice: we're testing one path from start to finish. No detours. The test is named after the user goal, not the implementation detail. It takes 10–15 seconds to run. If it breaks, the team knows signup is down. That's the power of critical-path testing.

Stable selectors—getByRole and data-testid survive redesigns

Your designer wants to rebrand the button. Your PM wants to move the login form above the fold. If your tests are brittle—full of CSS class selectors and nth-child queries—they'll break on every redesign. Stable selectors make tests resilient.

Playwright gives you several selector strategies. Prioritize them in this order:

  1. getByRole – queries by ARIA role, the most resilient. "Find me a button with text 'Sign Up'."
  2. getByLabel – for form inputs, queries by associated label text.
  3. getByPlaceholder – for inputs without labels, match placeholder text.
  4. data-testid – explicit, semantic attributes you add for testing.
  5. CSS selectors – last resort, the most fragile.

Here's a payment flow test with stable selectors:

tsx
test('User can complete checkout and payment', async ({ page }) => {
  // Navigate to checkout (assumes user is logged in)
  await page.goto('https://app.example.com/checkout');
  
  // Fill shipping address using labels
  await page.getByLabel('Street Address').fill('123 Main St');
  await page.getByLabel('City').fill('San Francisco');
  await page.getByLabel('Zip Code').fill('94105');
  
  // Select shipping method by role
  await page.getByRole('radio', { name: /Standard Shipping/ }).check();
  
  // Proceed to payment
  await page.getByRole('button', { name: /Continue to Payment/ }).click();
  
  // Fill card details in Stripe iframe (requires special handling)
  const stripeFrame = page.frameLocator('iframe[title="Secure payment input frame"]');
  await stripeFrame.locator('input[name="cardnumber"]').fill('4242424242424242');
  await stripeFrame.locator('input[name="exp-date"]').fill('12/25');
  await stripeFrame.locator('input[name="cvc"]').fill('123');
  
  // Submit payment
  await page.getByRole('button', { name: /Complete Purchase/ }).click();
  
  // Confirm order success
  await expect(page).toHaveURL(/\/order\/[a-zA-Z0-9]+/);
  await expect(page.getByRole('heading', { name: /Order Confirmed/ })).toBeVisible();
});

Notice the test uses getByRole and getByLabel everywhere. If the button text stays the same but the color changes, the test still passes. If the designer rearranges the form fields, the test still passes because it queries by label, not by DOM order.

For complex components where role-based queries aren't enough, add data-testid attributes sparingly:

tsx
// In your component
<div data-testid="product-card-{productId}">
  <img alt={product.name} src={product.image} />
  <h3>{product.name}</h3>
  <button>Add to Cart</button>
</div>

// In your test
await page.getByTestId('product-card-42').getByRole('button', { name: /Add to Cart/ }).click();

Auth once—Save logged-in state and reuse it across tests

Signing up or logging in before every test is slow. A typical auth flow (email, password validation, token generation) can take 5–10 seconds. Multiply that across 20 tests and you've added 2 minutes of waste. Instead, log in once, save the session state, and reuse it.

Playwright provides storageState for exactly this. Here's the pattern:

tsx
import { test as base } from '@playwright/test';

// Global setup: runs once per test run
test.beforeAll(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();
  
  // Log in
  await page.goto('https://app.example.com/login');
  await page.fill('input[type="email"]', 'test@example.com');
  await page.fill('input[type="password"]', 'TestPass123!');
  await page.click('button:has-text("Login")');
  await page.waitForURL(/\/dashboard/);
  
  // Save session (cookies, localStorage, sessionStorage)
  await context.storageState({ path: 'auth.json' });
  
  await context.close();
  await browser.close();
});

// Use saved auth in all tests
test('User can browse products', async ({ page }) => {
  // Page is already authenticated via storageState
  await page.goto('https://app.example.com/products');
  await expect(page.getByRole('heading', { name: /Products/ })).toBeVisible();
});

test('User can view order history', async ({ page }) => {
  // Same session, no login required
  await page.goto('https://app.example.com/orders');
  await expect(page.locator('[data-testid="order-list"]')).toBeVisible();
});

Configure Playwright to use the saved state in playwright.config.ts:

tsx
export default defineConfig({
  use: {
    storageState: 'auth.json', // Apply to all tests by default
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Now your tests run in 3–4 seconds instead of 13–14 seconds. For a suite of 20 tests, that's 3 minutes saved per run. Over hundreds of CI runs per week, that's measurable productivity gain.

Run in CI—Parallel workers, traces, and screenshots on failure

Local test runs are nice, but the real value is catching regressions in your pipeline. Playwright is built for CI: it runs headless by default, supports parallel execution, and records traces and videos on failure.

Here's a realistic playwright.config.ts for a production Next.js app:

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

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results/results.json' }],
    ['junit', { outputFile: 'test-results/junit.xml' }],
  ],
  
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  
  webServer: {
    command: 'npm run build && npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
  
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

And the CI job (GitHub Actions example):

tsx
name: Playwright Tests

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      
      - run: npm ci
      - run: npm run build
      - run: npx playwright install --with-deps
      - run: npm run test:e2e
      
      - uses: actions/upload-artifact@v3
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

Key settings explained:

  • fullyParallel: true – tests run in parallel, each in its own worker. Tests that share state should use separate fixtures.
  • retries: 2 – flaky tests (timeouts, network issues) are retried twice in CI. Helps catch real failures, not noise.
  • trace: 'on-first-retry' – if a test fails and is retried, record a trace (DOM state, network, console logs) for debugging.
  • screenshot: 'only-on-failure' – save visual proof of what went wrong.
  • workers: 1 – in CI, run tests serially to avoid resource contention. Adjust based on your runner's CPU/memory.

When a test fails in CI, Playwright generates an HTML report with screenshots, traces, and video. Developers click the report, see exactly what failed, and fix it in seconds instead of hunting for clues.

Common mistakes

1. Hard waits instead of smart waits. Bad: await page.waitForTimeout(3000). Your test becomes slow and fragile. Good: await expect(page.getByRole('button')).toBeEnabled(). Playwright waits up to 30 seconds by default.

2. Testing implementation details instead of user behavior. Bad: await expect(page.locator('.loading-spinner')).toBeHidden(). Good: await expect(page.getByRole('heading', { name: /Results/ })).toBeVisible(). Users don't care about spinner state; they care about the outcome.

3. Flaky auth persistence across test runs. Bad: store tokens in cookies without proper expiry handling. Good: use Playwright's storageState with a fresh login at test setup, not in shared state.

4. Testing the payment gateway directly in CI. Bad: actually charging test credit cards in every test run. Good: mock the payment response or use a dedicated sandbox account. Your CI should not hit production APIs.

5. No environment separation. Bad: all tests point to production. Good: use a staging environment and set BASE_URL via environment variables.

Key takeaways

  • Test critical paths only: signup, checkout, payment. Skip the UI polish. 80% of bugs live in 20% of flows.
  • Use stable selectors: getByRole and getByLabel first, data-testid second, CSS selectors last. Survive redesigns.
  • Save auth state once, reuse it across tests. Cuts test runtime by 70% compared to login-per-test.
  • Run in CI with parallel workers, retries, and traces. Screenshots and videos on failure make debugging instant.
  • Monitor test health: flaky tests are debt. If a test fails twice in a row, investigate root cause (not test cause).
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