E2E Testing with Playwright — Your First Real End-to-End Test
Day 35 of 40 — React System Design Series

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! 👋
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 assertionsDay 33 — Testing custom hooks:
renderHook,act(), fake timers, context wrappersDay 34 — Async testing: MSW, loading/error/success states,
findBy,waitForDay 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.
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.





