Testing Playwright Components: From Unit to E2E Tests
Playwright has rapidly become one of the most popular browser automation frameworks, but its testing capabilities extend far beyond simple end-to-end (E2E) scenarios. With the introduction of @playwright/experimental-ct, developers can now test individual components in isolation, bridging the gap between pure unit tests and full E2E tests. This tutorial walks you through the entire testing spectrum with Playwright, from isolated component unit tests to comprehensive E2E flows.
What Is Playwright Testing?
Playwright is a Node.js library for automating Chromium, Firefox, and WebKit browsers. The @playwright/test package provides a full-featured test runner with fixtures, assertions, parallel execution, and built-in reporters. On top of that, @playwright/experimental-ct adds component testing support, allowing you to mount React, Vue, Svelte, or Solid components directly in a real browser and interact with them as a user would.
This means you get three layers of testing:
- Unit tests β pure logic testing of functions, hooks, or utilities without a browser.
- Component tests β mounting a single component in a real browser and asserting on its rendered output and interactions.
- E2E tests β driving a real browser against your running application to validate full user journeys.
Why It Matters
Each layer catches different classes of bugs at different costs. Unit tests are fast and pinpoint logic errors but miss DOM and integration issues. E2E tests catch real-world breakages but are slower and harder to debug. Component tests sit in the middle: they run in a real browser, render actual markup, and exercise user interactions, yet they remain focused on a single component. This combination gives you a fast feedback loop without sacrificing confidence.
Using Playwright for all three layers also means a single assertion library, a single fixture model, and a single reporter β reducing cognitive overhead and tooling sprawl across your team.
Setting Up Playwright
Start by installing the test runner and initializing the configuration. From your project root, run:
npm init playwright@latest
This scaffolds a playwright.config.ts file, a tests directory, and example specs. For component testing, install the component testing package for your framework. For React:
npm install --save-dev @playwright/experimental-ct-react
A typical playwright.config.ts for both E2E and component tests looks like this:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
reporter: [['html'], ['list']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Unit Testing Pure Logic
Playwright's test runner can be used for plain unit tests with no browser involved. This is ideal for utilities, reducers, and pure functions. Create a file tests/utils/formatPrice.spec.ts:
import { test, expect } from '@playwright/test';
import { formatPrice } from '../../src/utils/formatPrice';
test.describe('formatPrice', () => {
test('formats a number as USD currency', () => {
expect(formatPrice(1234.5)).toBe('$1,234.50');
});
test('handles zero', () => {
expect(formatPrice(0)).toBe('$0.00');
});
test('rounds to two decimals', () => {
expect(formatPrice(9.999)).toBe('$10.00');
});
});
The implementation might be:
// src/utils/formatPrice.ts
export function formatPrice(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(value);
}
These tests run in Node with no browser launch, making them extremely fast. Run them with:
npx playwright test tests/utils
Component Testing with Playwright
Component tests mount a single component in a real browser tab and let you interact with it using the same Playwright locators you use in E2E tests. This is where @playwright/experimental-ct-react shines.
Create a separate config for component tests, playwright-ct.config.ts:
import { defineConfig, devices } from '@playwright/experimental-ct-react';
export default defineConfig({
testDir: './src/components',
testMatch: /.*\.ct\.(ts|tsx)/,
use: {
ctPort: 3100,
trace: 'on-first-retry',
},
});
Now consider a Counter component:
// src/components/Counter.tsx
import { useState } from 'react';
export function Counter({ initial = 0, step = 1 }: { initial?: number; step?: number }) {
const [count, setCount] = useState(initial);
return (
<div>
<p data-testid="count">{count}</p>
<button onClick={() => setCount(c => c + step)}>Increment</button>
<button onClick={() => setCount(c => c - step)}>Decrement</button>
</div>
);
}
The component test mounts this component and exercises it like a user:
// src/components/Counter.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Counter } from './Counter';
test('increments and decrements the count', async ({ mount }) => {
const component = await mount(<Counter initial={5} step={2} />);
await expect(component.getByTestId('count')).toHaveText('5');
await component.getByText('Increment').click();
await expect(component.getByTestId('count')).toHaveText('7');
await component.getByText('Decrement').click();
await component.getByText('Decrement').click();
await expect(component.getByTestId('count')).toHaveText('3');
});
test('starts at zero by default', async ({ mount }) => {
const component = await mount(<Counter />);
await expect(component.getByTestId('count')).toHaveText('0');
});
Run component tests with:
npx playwright test --config=playwright-ct.config.ts
Mocking Dependencies in Component Tests
Components often depend on external data. You can mock network requests using page.route or pass mock props. For a component that fetches data, prefer injecting a data-fetching function as a prop to keep the test deterministic:
// src/components/UserProfile.tsx
export function UserProfile({ userId, fetchUser }: {
userId: string;
fetchUser: (id: string) => Promise<{ name: string }>;
}) {
const [user, setUser] = useState<{ name: string } | null>(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId, fetchUser]);
if (!user) return <p data-testid="loading">Loadingβ¦</p>;
return <h1 data-testid="name">{user.name}</h1>;
}
// src/components/UserProfile.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { UserProfile } from './UserProfile';
test('shows the user name after loading', async ({ mount }) => {
const fetchUser = async (id: string) => ({ name: `User ${id}` });
const component = await mount(
<UserProfile userId="42" fetchUser={fetchUser} />
);
await expect(component.getByTestId('loading')).toBeVisible();
await expect(component.getByTestId('name')).toHaveText('User 42');
});
End-to-End Testing
E2E tests run against your full application. They validate that routing, API calls, state management, and UI all work together. Place these in the tests/e2e directory.
Imagine a simple login flow. The spec might look like this:
// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login flow', () => {
test('redirects to dashboard on success', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('s3cret');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole('heading', { name: 'Welcome, Admin' })).toBeVisible();
});
test('shows an error on invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Invalid email or password')).toBeVisible();
await expect(page).toHaveURL(/\/login$/);
});
});
Using Page Object Models
For larger applications, the Page Object Model (POM) pattern keeps selectors and actions centralized. Create a LoginPage class:
// tests/e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByText('Invalid email or password');
}
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();
}
}
Then the spec becomes cleaner and more maintainable:
// tests/e2e/login.pom.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('redirects to dashboard on success', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('admin@example.com', 's3cret');
await expect(page).toHaveURL(/\/dashboard$/);
});
Authenticating Once with Storage State
Repeating login on every test slows your suite. Playwright lets you authenticate once and reuse the session via storageState:
// tests/e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
setup('authenticate', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('admin@example.com', 's3cret');
await expect(page).toHaveURL(/\/dashboard$/);
await page.context().storageState({ path: '.auth/user.json' });
});
Reference this setup in your config as a dependency:
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
},
],
Best Practices
- Prefer user-facing attributes. Use
getByRole,getByLabel, andgetByTextover brittle CSS selectors. This mirrors how real users interact and survives refactors. - Use
data-testidsparingly. Reserve test IDs for elements that have no accessible name, such as decorative containers used only for assertions. - Keep tests independent. Each test should set up and tear down its own state. Avoid relying on execution order or shared mutable fixtures.
- Mock at the network boundary. For E2E tests, intercept API calls with
page.routeto keep tests deterministic and fast, while still exercising the full UI stack. - Run tests in parallel. Set
fullyParallel: trueand avoid global state to maximize throughput in CI. - Capture traces on failure. Setting
trace: 'on-first-retry'gives you a full timeline of actions, screenshots, and DOM snapshots when a test flakes. - Layer your tests deliberately. Cover pure logic with unit tests, isolated UI with component tests, and critical journeys with E2E tests. Resist the urge to E2E-test everything.
- Tag and filter tests. Use
test.describe.configureor custom tags in titles to run smoke tests on every commit and full suites on nightly builds.
Running Tests in CI
Playwright ships a GitHub Action that installs browsers and caches them. A minimal workflow:
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report
retention-days: 30
Conclusion
Playwright gives you a unified toolkit to test your application at every level β from pure functions in Node, to isolated components in a real browser, to complete user journeys across multiple pages. By combining unit tests for speed, component tests for focused UI confidence, and E2E tests for end-to-end assurance, you build a testing pyramid that catches bugs early without slowing down your team. Start with the layer that addresses your current pain point, adopt the Page Object Model as your suite grows, and lean on traces and storage state to keep tests reliable in CI. With these patterns in place, you can ship features faster while trusting that your application behaves exactly as your users expect.