E2E Testing with Playwright and Storybook


A good E2E testing strategy should mirror how you build your UI: broken down into composable elements with clearly defined interfaces. Components have props; page objects have functions. Both allow you to change implementation without breaking everything downstream.

Combining a component library with your E2E tool makes this easier. Storybook gives you isolated, interactive examples of every component. Playwright lets you test those examples directly, building up page objects that stay in sync with your components.

Overview

  • Playwright + Storybook — test components in isolation, build selectors interactively
  • Page objects — encapsulate component interactions, isolate changes
  • Component tests — catch selector drift early, verify individual components work
  • E2E package — dedicated workspace with environment-specific configs
  • Email testingMail.tm for free disposable inboxes, single inbox with user cleanup

Playwright + Storybook

Storybook provides interactive examples of every component. This is useful for development, but also creates a testing playground. You can:

  • Build and verify selectors against isolated components
  • Test component behavior without spinning up the full app
  • Catch regressions at the component level before they surface in full flows

Use the direct component URL to avoid the Storybook UI wrapper. The ?path=/story/ URL renders your component inside an iframe within the Storybook interface. This means your page object selectors need to target elements inside that iframe, which adds complexity and makes the selectors work differently than they would in your actual application. The iframe.html URL gives you direct access to the component without any wrapper, keeping your selectors simple and consistent:

// Good: direct component without Storybook UI
await page.goto('http://localhost:6006/iframe.html?id=button--primary');

// Avoid: includes Storybook UI with component in iframe
await page.goto('http://localhost:6006/?path=/story/button--primary');

Page Objects

Page objects encapsulate how you interact with a component or page. When a component changes, you update the page object — not every test that uses it.

// components/LoginForm.page.ts
export class LoginFormPage {
  constructor(private root: Locator) {}

  async fillEmail(email: string) {
    await this.root.getByTestId('email').fill(email);
  }

  async fillPassword(password: string) {
    await this.root.getByTestId('password').fill(password);
  }

  async submit() {
    await this.root.getByTestId('submit').click();
  }

  async getErrorMessage() {
    return this.root.getByTestId('error-message').textContent();
  }
}

Tests use the page object’s interface:

test('shows error for invalid credentials', async ({ page }) => {
  const loginForm = new LoginFormPage(page.getByTestId('login-form'));

  await loginForm.fillEmail('user@example.com');
  await loginForm.fillPassword('wrong-password');
  await loginForm.submit();

  expect(await loginForm.getErrorMessage()).toContain('Invalid credentials');
});

If the component’s DOM structure changes, you update LoginFormPage. The test stays the same.

Selector Strategy

Use data-testid attributes to give elements a clear, stable selector that is decoupled from styling and structure. Avoid relying on generic selectors like a[href], button:first-child, or getByRole('link') — these assumptions break easily when a component is placed in a different context or another element is added to the page.

// Good — explicit, stable selector
<button data-testid="submit">Sign in</button>

// Avoid — breaks if another button is added or the label changes
<button>Sign in</button>

Scoped Page Objects

Page objects should accept a Locator rather than the full Page. This scopes all queries to a specific subtree, so a data-testid="submit" on a login form and a registration form can coexist on the same page without conflict.

// Scoped to a specific form — no ambiguity
const loginForm = new LoginFormPage(page.getByTestId('login-form'));
const registerForm = new RegisterFormPage(page.getByTestId('register-form'));

Both forms can use data-testid="submit" internally because each page object only searches within its own root. This mirrors how React components compose — each owns its subtree and doesn’t need to know about the wider page.

Component Tests

Create tests for individual components in Storybook. These verify:

  • Components render correctly in isolation
  • Interactive elements respond as expected
  • Selectors still work (catches drift early)

The Storybook story for each component should exercise every function on its page object. For a custom select component, the page object might expose pickByName, pickDefault, pickFirst, pickLast, pickNth, and pickRandom — each of which needs a story that lets you verify it works. This is especially important for custom components where the underlying DOM is non-trivial and selectors can easily break.

// components/LoginForm.spec.ts
test('login form renders and accepts input', async ({ page }) => {
  await page.goto('http://localhost:6006/?path=/story/loginform--default');

  const loginForm = new LoginFormPage(page);

  await loginForm.fillEmail('test@example.com');
  await loginForm.fillPassword('password123');

  expect(await page.getByLabel('Email').inputValue()).toBe('test@example.com');
});

When selectors break, component tests fail first. You know exactly which component needs updating, not just that some flow broke.

E2E Package

In a monorepo, keep E2E tests in their own package:

packages/
├── web/
├── api/
└── e2e/
    ├── tests/
    ├── pages/
    ├── .env.local
    ├── .env.dev
    └── .env.prod

Environment files hold test-specific config:

# e2e/.env.local
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=password123
APP_URL=http://localhost:3000
STORYBOOK_URL=http://localhost:6006
# e2e/.env.dev
TEST_USER_EMAIL=e2e-user@example.com
TEST_USER_PASSWORD=<secure-password>
APP_URL=https://dev.yourapp.com
STORYBOOK_URL=https://dev-storybook.yourapp.com

For more on organizing environment files, see Organising Environment Files.

Email Testing

Use Mail.tm to test email flows—it’s free and requires no API key. For longer message retention or higher reliability, Apify’s Disposable Email API is a good alternative with pay-per-use pricing. In either case, I like to avoid services that introduce a monthly cost, as these can be quickly forgotten and add up. Due to inbox limits, consider using a single shared inbox.

This requires cleaning up test users between runs. For Auth0:

// utils/auth0-cleanup.ts
import { ManagementClient } from 'auth0';

const auth0 = new ManagementClient({
  domain: process.env.AUTH0_DOMAIN!,
  clientId: process.env.AUTH0_M2M_CLIENT_ID!,
  clientSecret: process.env.AUTH0_M2M_CLIENT_SECRET!,
});

export async function deleteTestUser(email: string) {
  const users = await auth0.getUsersByEmail(email);

  if (users.length > 0) {
    await auth0.deleteUser({ id: users[0].user_id! });
  }
}

Run cleanup in a beforeEach or test setup:

test.beforeEach(async () => {
  await deleteTestUser(process.env.TEST_USER_EMAIL!);
});

This ensures a clean state for each test run.

Testing Auth Flows Locally

Auth providers like Auth0 require a public callback URL, which breaks local testing. Use ngrok to expose your local dev server:

ngrok http 3000

Add the ngrok URL to your Auth0 allowed callbacks, then run tests against it. This lets you test full login flows without deploying.

Building Up Tests

Start with component tests, then compose them into flows:

  1. Component level — verify individual pieces work in Storybook
  2. Page level — combine components into page objects
  3. Flow level — test multi-page interactions using page objects

This mirrors how you build the UI. Small, tested pieces combine into larger, reliable systems.