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 testing — MailSlurp or similar, 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 page: Page) {}
async fillEmail(email: string) {
await this.page.getByLabel('Email').fill(email);
}
async fillPassword(password: string) {
await this.page.getByLabel('Password').fill(password);
}
async submit() {
await this.page.getByRole('button', { name: 'Sign in' }).click();
}
async getErrorMessage() {
return this.page.getByRole('alert').textContent();
}
}
Tests use the page object’s interface:
test('shows error for invalid credentials', async ({ page }) => {
const loginForm = new LoginFormPage(page);
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.
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)
// 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 MailSlurp or a similar service to test email flows. 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.
Building Up Tests
Start with component tests, then compose them into flows:
- Component level — verify individual pieces work in Storybook
- Page level — combine components into page objects
- Flow level — test multi-page interactions using page objects
This mirrors how you build the UI. Small, tested pieces combine into larger, reliable systems.