Skip to main content

Command Palette

Search for a command to run...

E2E Testing with Playwright — Your First Real End-to-End Test

Day 35 of 40 — React System Design Series

Updated
7 min read
E2E Testing with Playwright — Your First Real End-to-End Test
R

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.


Hey everyone, Richa here! 👋

playwright gif

Day 35. Last day of Week 7.

Unit tests test logic. Integration tests test component behaviour. But neither of them tests what actually happens when a real browser visits your app, a real user types in a real input, and a real HTTP request goes to a real (or mock) server.

That's E2E testing. And Playwright is — without a doubt — the best tool for it right now.


What E2E tests are for

E2E tests are expensive (slow to run, brittle if overused). Use them sparingly — for critical user journeys only:

  • Login flow — user can log in, land on the right page

  • Checkout — user can add to cart and complete payment

  • Core create flow — user can create the primary resource of your app

  • Auth gates — unauthenticated user is redirected to login

Don't E2E test every feature. Use integration tests (RTL) for most things. Reserve E2E for the flows where a regression would be catastrophic.


Playwright setup

npm init playwright@latest

This scaffolds the config, creates an example test, and optionally installs the browsers.

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

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,   // fail CI if test.only is left in
  retries: process.env.CI ? 2 : 0, // retry failed tests on CI
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:5173',  // your dev server
    trace: 'on-first-retry',          // record traces for failed tests
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'Mobile Safari', use: { ...devices['iPhone 14'] } },
  ],

  // Start the dev server automatically before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

Your first test — login flow

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {

  test('user can log in and see dashboard', async ({ page }) => {
    await page.goto('/login');

    // Fill the form
    await page.getByLabel('Email address').fill('richa@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: /sign in/i }).click();

    // Should redirect to dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByRole('heading', { name: /welcome back/i })).toBeVisible();
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email address').fill('wrong@example.com');
    await page.getByLabel('Password').fill('wrongpassword');
    await page.getByRole('button', { name: /sign in/i }).click();

    await expect(page.getByRole('alert')).toContainText('Invalid email or password');
    await expect(page).toHaveURL('/login'); // still on login page
  });

  test('unauthenticated user is redirected to login', async ({ page }) => {
    await page.goto('/dashboard');

    // Should be redirected
    await expect(page).toHaveURL('/login');
  });

});

Playwright queries match RTL's style — getByRole, getByLabel, getByText. If you've been writing RTL tests, Playwright's API will feel very familiar.


Page Object Model — keeping E2E tests maintainable

As your test suite grows, raw selectors scattered across tests become a maintenance nightmare. The Page Object Model (POM) encapsulates page interactions into reusable classes:

// e2e/pages/LoginPage.ts
import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;
  private readonly errorAlert: Locator;

  constructor(private page: Page) {
    this.emailInput    = page.getByLabel('Email address');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton  = page.getByRole('button', { name: /sign in/i });
    this.errorAlert    = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorMessage() {
    return this.errorAlert.textContent();
  }
}
// e2e/pages/DashboardPage.ts
import { type Page } from '@playwright/test';

export class DashboardPage {
  constructor(private page: Page) {}

  async isVisible() {
    await this.page.waitForURL('/dashboard');
    return this.page.getByRole('heading', { name: /welcome back/i }).isVisible();
  }
}
// e2e/auth.spec.ts — much cleaner with POM
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

test('user can log in', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboardPage = new DashboardPage(page);

  await loginPage.goto();
  await loginPage.login('richa@example.com', 'password123');

  expect(await dashboardPage.isVisible()).toBe(true);
});

When the selector changes (e.g., "Sign in" → "Log in"), you update it in one place (the Page Object), not across every test.


Test fixtures — shared authenticated state

Logging in before every test is slow and repetitive. Use Playwright fixtures to share auth state:

// e2e/fixtures/auth.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

// Extend the base test with an authenticated fixture
export const test = base.extend<{
  authenticatedPage: { page: typeof base.info['config'] };
}>({
  // This runs before each test that uses 'authenticatedPage'
  page: async ({ page }, use) => {
    // Log in once
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('richa@example.com', 'password123');
    await page.waitForURL('/dashboard');

    // Pass the authenticated page to the test
    await use(page);
  },
});

export { expect };
// e2e/dashboard.spec.ts — page is already authenticated
import { test, expect } from '../fixtures/auth';

test('dashboard shows user stats', async ({ page }) => {
  // No login needed — already authenticated via fixture
  await expect(page.getByText('Total Orders')).toBeVisible();
});

test('user can navigate to profile', async ({ page }) => {
  await page.getByRole('link', { name: /profile/i }).click();
  await expect(page).toHaveURL('/profile');
});

Alternatively, save auth state to disk and reuse it across test files — even faster:

// e2e/global-setup.ts
import { chromium } from '@playwright/test';

export default async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto('http://localhost:5173/login');
  await page.getByLabel('Email address').fill('richa@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: /sign in/i }).click();
  await page.waitForURL('/dashboard');

  // Save auth cookies/localStorage to disk
  await page.context().storageState({ path: 'e2e/.auth/user.json' });
  await browser.close();
}
// playwright.config.ts
export default defineConfig({
  globalSetup: './e2e/global-setup.ts',

  projects: [
    {
      name: 'authenticated',
      use: {
        storageState: 'e2e/.auth/user.json',  // reuse saved auth
      },
    },
    {
      name: 'unauthenticated',
      // no storageState — tests run without auth
    },
  ],
});

Debugging failing tests

Playwright has excellent debugging tools built in:

# Run tests with a visible browser (not headless)
npx playwright test --headed

# Debug mode — pauses at each step, shows the browser
npx playwright test --debug

# Run a specific test file
npx playwright test e2e/auth.spec.ts

# View the HTML report of last run
npx playwright show-report

When a test fails on CI, Playwright saves traces automatically (with trace: 'on-first-retry'). Download the trace and open it with:

npx playwright show-trace trace.zip

The trace viewer shows a timeline of every action, screenshot at each step, network requests, and console logs. Invaluable for debugging CI failures you can't reproduce locally.


Week 7 wrap-up

This week we covered the full testing stack:

  • Day 31 — Testing philosophy: behaviour over implementation, the testing trophy

  • Day 32 — React Testing Library: queries, userEvent, providers, common assertions

  • Day 33 — Testing custom hooks: renderHook, act(), fake timers, context wrappers

  • Day 34 — Async testing: MSW, loading/error/success states, findBy, waitFor

  • Day 35 — Playwright E2E: first tests, Page Object Model, auth fixtures, tracing

Next week is the final week — Interview Prep. Everything we've built across 8 weeks, turned into interview ammunition.

week done gif

See you Monday for Day 36 — How senior React interviews actually work — what they're really testing.


Day 34 — Testing Async Code Day 36 — How Senior Interviews Work


Part of the React System Design Series — 40 days, one topic per day.

React System Design — Learning in Public

Part 36 of 36

I'm a React developer with 5.5 years of experience who spent 2 years in Lit.js — and slowly forgot how React really works at a senior level. This series is my public journey back. Every day I cover one topic: state management, authentication, performance, system design interviews, testing — the real stuff that separates a mid-level dev from a senior. Written honestly, with real code, for developers at every level.

Start from the beginning

How React Actually Works — And Why I Never Bothered to Learn It (Until Now)

Day 1 of 40 — React System Design Series