|

Playwright Component Testing: Test React and Vue Components in Real Browsers

Unit tests are fast but fake. End-to-end tests are real but slow. Playwright component testing sits in the sweet spot between the two, letting you mount individual React and Vue components inside a real Chromium, Firefox, or WebKit browser and interact with them using the same Playwright API you already know. In this comprehensive guide you will learn how to set up @playwright/experimental-ct-react, write your first component test, handle props and events, mock network calls, run visual regression checks on isolated components, and integrate everything into your CI pipeline. By the end you will have a complete testing strategy that catches real browser bugs without the overhead of a full E2E suite.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

What Is Component Testing and Why It Matters in 2026

Component testing occupies a distinct position in the testing pyramid. Unit tests validate individual functions and hooks in a simulated DOM environment like JSDOM. End-to-end tests validate the entire application by navigating real pages, authenticating users, and interacting with live backends. Component tests fill the gap by rendering a single component inside a real browser engine but without booting the full application server, router, or authentication layer.

This approach solves a fundamental problem that has plagued frontend testing for years. JSDOM-based test runners like Jest and Vitest do not implement the full browser specification. They lack real CSS rendering, real layout calculation, real event bubbling through a visual viewport, and real accessibility tree construction. When a test passes in JSDOM but fails in the browser, you discover the gap during QA or in production. Component testing eliminates that gap by using a real browser engine from the start.

The performance profile is compelling. A typical E2E test that navigates to a page, authenticates, and validates a form might take ten to thirty seconds. The same validation as a component test, where you mount just the form component with stubbed props, runs in one to three seconds. Across a suite of five hundred tests the difference between a ten-minute CI run and a two-hour CI run can determine whether your team commits to testing or abandons it.

The modern frontend landscape makes component testing increasingly critical. Design systems with dozens of shared components, micro-frontends that compose independently deployed modules, and server components that blur the rendering boundary all benefit from a testing approach that validates real browser behavior at the component level. Teams shipping component libraries to multiple consuming applications especially need this verification layer to prevent breaking changes from propagating silently.

Setting Up Playwright Component Testing for React

The setup process requires a few packages and a dedicated configuration file. Playwright component testing uses Vite under the hood to bundle your components, so your existing Vite or Webpack configuration is respected through the adapter layer. The setup takes approximately five minutes for a standard React project.

Step 1: Install the Required Packages

npm init playwright@latest -- --ct
# Or manually:
npm install -D @playwright/experimental-ct-react @playwright/test

# Install browser binaries
npx playwright install --with-deps chromium firefox webkit

The --ct flag during initialization scaffolds the configuration file and example tests automatically. If you already have a Playwright E2E setup, you can install the packages manually and create the config file yourself. The two configurations live side by side without conflict because they use different config file names. For Vue projects, replace @playwright/experimental-ct-react with @playwright/experimental-ct-vue and the API remains nearly identical.

Step 2: Configure playwright-ct.config.ts

// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.ct.{ts,tsx}',
  snapshotDir: './src/__snapshots__',
  timeout: 10_000,
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html', { open: 'never' }],
    ['json', { outputFile: 'ct-results.json' }],
  ],
  use: {
    trace: 'on-first-retry',
    ctPort: 3100,
    ctViteConfig: {
      resolve: {
        alias: {
          '@': '/src',
          '@components': '/src/components',
          '@hooks': '/src/hooks',
        },
      },
    },
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

The key settings here are testMatch which uses the .ct.ts or .ct.tsx suffix to distinguish component tests from E2E tests, ctPort which sets the development server port for the component preview, and ctViteConfig which passes Vite configuration like path aliases that your components depend on. The snapshotDir setting controls where visual regression baseline images are stored.

Step 3: Create the Playwright Component Index File

// playwright/index.tsx
import '../src/index.css';
import '../src/styles/globals.css';

// This file is the entry point for component tests.
// Import global styles, providers, and theme setup here.
// Each component test will mount inside this context.

This index file is critical. Any global CSS, theme providers, or context providers that your components depend on should be imported here. If you skip this step, your components will mount without styles and context, leading to confusing test failures that do not reproduce when you view the component in the application. You can also wrap your components in global providers like Redux stores or React Query clients by exporting a decorator function from this file.

Writing Your First Component Test: Mount, Interact, Assert

The fundamental workflow in Playwright component testing follows three steps: mount the component with specific props, interact with it using Playwright locators, and assert the expected outcome. This pattern is consistent across all component types and frameworks, making it easy to learn once and apply everywhere.

Example 1: Button Component Test

// src/components/Button/Button.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test.describe('Button Component', () => {
  test('renders with correct text', async ({ mount }) => {
    const component = await mount(<Button label="Click Me" />);
    await expect(component).toContainText('Click Me');
  });

  test('calls onClick handler when clicked', async ({ mount }) => {
    let clicked = false;
    const component = await mount(
      <Button label="Submit" onClick={() => { clicked = true; }} />
    );
    await component.click();
    expect(clicked).toBe(true);
  });

  test('applies disabled state correctly', async ({ mount }) => {
    const component = await mount(
      <Button label="Disabled" disabled={true} />
    );
    await expect(component.getByRole('button')).toBeDisabled();
  });

  test('renders primary variant with correct styles', async ({ mount }) => {
    const component = await mount(
      <Button label="Primary" variant="primary" />
    );
    await expect(component.getByRole('button')).toHaveCSS(
      'background-color', 'rgb(37, 99, 235)'
    );
  });

  test('shows loading spinner when isLoading is true', async ({ mount }) => {
    const component = await mount(
      <Button label="Save" isLoading={true} />
    );
    await expect(component.getByRole('progressbar')).toBeVisible();
    await expect(component.getByRole('button')).toBeDisabled();
  });
});

Notice that the mount function is provided as a fixture, just like page in E2E tests. It returns a locator pointing to the mounted component, and from there you use the standard Playwright locator API including getByRole, getByText, and getByTestId. The callback-based event handler testing pattern works because component tests run in the same Node.js process as your test code, giving you direct access to closures.

Example 2: Form Component with Validation

// src/components/ContactForm/ContactForm.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { ContactForm } from './ContactForm';

test.describe('ContactForm Component', () => {
  test('shows validation errors for empty required fields', async ({ mount }) => {
    const component = await mount(<ContactForm />);
    await component.getByRole('button', { name: 'Submit' }).click();
    await expect(component.getByText('Name is required')).toBeVisible();
    await expect(component.getByText('Email is required')).toBeVisible();
  });

  test('validates email format', async ({ mount }) => {
    const component = await mount(<ContactForm />);
    await component.getByLabel('Email').fill('not-an-email');
    await component.getByRole('button', { name: 'Submit' }).click();
    await expect(component.getByText('Invalid email format')).toBeVisible();
  });

  test('submits form data when all fields are valid', async ({ mount }) => {
    let submittedData = null;
    const component = await mount(
      <ContactForm onSubmit={(data) => { submittedData = data; }} />
    );
    await component.getByLabel('Name').fill('Jane Doe');
    await component.getByLabel('Email').fill('jane@example.com');
    await component.getByLabel('Message').fill('Hello from component test');
    await component.getByRole('button', { name: 'Submit' }).click();
    expect(submittedData).toEqual({
      name: 'Jane Doe',
      email: 'jane@example.com',
      message: 'Hello from component test',
    });
  });

  test('disables submit button while submitting', async ({ mount }) => {
    const component = await mount(
      <ContactForm onSubmit={() => new Promise((r) => setTimeout(r, 5000))} />
    );
    await component.getByLabel('Name').fill('Test User');
    await component.getByLabel('Email').fill('test@test.com');
    await component.getByRole('button', { name: 'Submit' }).click();
    await expect(component.getByRole('button', { name: 'Submitting...' })).toBeDisabled();
  });
});

Example 3: Modal Component with Portal and Focus Trap

// src/components/Modal/Modal.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Modal } from './Modal';

test.describe('Modal Component', () => {
  test('renders when open prop is true', async ({ mount, page }) => {
    await mount(<Modal open={true} title="Confirm Action">Are you sure?</Modal>);
    await expect(page.getByRole('dialog')).toBeVisible();
    await expect(page.getByText('Confirm Action')).toBeVisible();
  });

  test('does not render when open is false', async ({ mount, page }) => {
    await mount(<Modal open={false} title="Hidden">Content</Modal>);
    await expect(page.getByRole('dialog')).not.toBeVisible();
  });

  test('calls onClose when Escape key is pressed', async ({ mount, page }) => {
    let closed = false;
    await mount(
      <Modal open={true} title="Test" onClose={() => { closed = true; }}>
        Content
      </Modal>
    );
    await page.keyboard.press('Escape');
    expect(closed).toBe(true);
  });

  test('traps focus inside the modal', async ({ mount, page }) => {
    await mount(
      <Modal open={true} title="Focus Trap">
        <button>First</button>
        <button>Second</button>
      </Modal>
    );
    await page.keyboard.press('Tab');
    await expect(page.getByRole('button', { name: 'First' })).toBeFocused();
    await page.keyboard.press('Tab');
    await expect(page.getByRole('button', { name: 'Second' })).toBeFocused();
  });
});

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Example 4: DataTable Component with Sorting and Pagination

// src/components/DataTable/DataTable.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { DataTable } from './DataTable';

const mockData = [
  { id: 1, name: 'Alice', role: 'Engineer', salary: 120000 },
  { id: 2, name: 'Bob', role: 'Designer', salary: 95000 },
  { id: 3, name: 'Charlie', role: 'Manager', salary: 140000 },
  { id: 4, name: 'Diana', role: 'Engineer', salary: 130000 },
  { id: 5, name: 'Eve', role: 'QA Lead', salary: 110000 },
];

test.describe('DataTable Component', () => {
  test('renders all rows with header', async ({ mount }) => {
    const component = await mount(
      <DataTable data={mockData} columns={['name', 'role', 'salary']} />
    );
    await expect(component.getByRole('row')).toHaveCount(6);
  });

  test('sorts ascending by column when header clicked', async ({ mount }) => {
    const component = await mount(
      <DataTable data={mockData} columns={['name', 'role', 'salary']} />
    );
    await component.getByRole('columnheader', { name: 'salary' }).click();
    const firstDataRow = component.getByRole('row').nth(1);
    await expect(firstDataRow.getByRole('cell').nth(2)).toContainText('95000');
  });

  test('paginates when data exceeds page size', async ({ mount }) => {
    const largeData = Array.from({ length: 50 }, (_, i) => ({
      id: i, name: `User ${i}`, role: 'Test', salary: 100000,
    }));
    const component = await mount(
      <DataTable data={largeData} columns={['name']} pageSize={10} />
    );
    await expect(component.getByRole('row')).toHaveCount(11);
    await component.getByRole('button', { name: 'Next' }).click();
    await expect(component.getByText('User 10')).toBeVisible();
  });

  test('filters data when search input is used', async ({ mount }) => {
    const component = await mount(
      <DataTable data={mockData} columns={['name', 'role']} searchable />
    );
    await component.getByPlaceholder('Search...').fill('Engineer');
    await expect(component.getByRole('row')).toHaveCount(3);
  });
});

Example 5: AuthProvider Component with Context

// src/components/AuthProvider/AuthProvider.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { AuthProvider } from './AuthProvider';
import { useAuth } from '../../hooks/useAuth';

function AuthConsumer() {
  const { user, login, logout, isAuthenticated } = useAuth();
  return (
    <div>
      <span data-testid="auth-status">
        {isAuthenticated ? 'authenticated' : 'anonymous'}
      </span>
      {user && <span data-testid="user-name">{user.name}</span>}
      <button onClick={() => login('admin', 'password')}>Login</button>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

test.describe('AuthProvider Component', () => {
  test('starts in anonymous state', async ({ mount }) => {
    const component = await mount(
      <AuthProvider><AuthConsumer /></AuthProvider>
    );
    await expect(component.getByTestId('auth-status')).toHaveText('anonymous');
  });

  test('authenticates user on login', async ({ mount }) => {
    const component = await mount(
      <AuthProvider><AuthConsumer /></AuthProvider>
    );
    await component.getByRole('button', { name: 'Login' }).click();
    await expect(component.getByTestId('auth-status')).toHaveText('authenticated');
    await expect(component.getByTestId('user-name')).toHaveText('admin');
  });

  test('returns to anonymous on logout', async ({ mount }) => {
    const component = await mount(
      <AuthProvider><AuthConsumer /></AuthProvider>
    );
    await component.getByRole('button', { name: 'Login' }).click();
    await expect(component.getByTestId('auth-status')).toHaveText('authenticated');
    await component.getByRole('button', { name: 'Logout' }).click();
    await expect(component.getByTestId('auth-status')).toHaveText('anonymous');
  });
});

Testing with Props, Events, and Slots

Component testing shines when you need to verify how a component responds to different prop combinations. Unlike E2E tests where you have limited control over application state, component tests let you pass any combination of props directly to the mounted component. This is particularly valuable for testing edge cases like empty arrays, null values, extremely long strings, and invalid data types that would be difficult to reproduce through the application UI.

For React components, you pass props directly to the JSX element inside the mount call. For event handlers, you pass callback functions that capture the emitted values into local variables for assertion. The pattern is always the same: mount with specific props, interact with the component, and assert the outcome. For Vue components using slots, Playwright provides a slots option in the mount configuration that accepts named slot content as JSX or HTML strings. This approach makes it straightforward to verify that slot content renders correctly and responds to parent state changes.

A common pattern for testing multiple prop combinations is to use Playwright’s test.describe blocks with parameterized data. You can loop through an array of prop objects and generate tests dynamically, ensuring comprehensive coverage of all component states without repetitive test code. This approach scales well as your component’s prop interface grows.

Network Mocking in Component Tests

Many components fetch data on mount or in response to user actions. In component tests you use the same page.route API from Playwright E2E testing to intercept and mock network requests. Because the component runs in a real browser, the fetch calls go through the browser’s network layer and can be intercepted precisely. This is more reliable than module-level mocking because it tests the actual HTTP integration path.

test('loads and displays user data from API', async ({ mount, page }) => {
  await page.route('**/api/users/1', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ id: 1, name: 'Mock User', email: 'mock@test.com' }),
    });
  });

  const component = await mount(<UserProfile userId={1} />);
  await expect(component.getByText('Mock User')).toBeVisible();
  await expect(component.getByText('mock@test.com')).toBeVisible();
});

test('shows error state when API returns 500', async ({ mount, page }) => {
  await page.route('**/api/users/1', async (route) => {
    await route.fulfill({ status: 500, body: 'Internal Server Error' });
  });

  const component = await mount(<UserProfile userId={1} />);
  await expect(component.getByText('Failed to load user')).toBeVisible();
  await expect(component.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('shows loading skeleton while fetching', async ({ mount, page }) => {
  await page.route('**/api/users/1', async (route) => {
    await new Promise(r => setTimeout(r, 3000));
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ id: 1, name: 'Delayed User' }),
    });
  });

  const component = await mount(<UserProfile userId={1} />);
  await expect(component.getByTestId('skeleton-loader')).toBeVisible();
});

Visual Regression for Individual Components

One of the most powerful features of Playwright component testing is built-in visual regression. Since components render in a real browser, you can take pixel-perfect screenshots and compare them against baseline images. This catches CSS regressions, layout shifts, and styling bugs that are invisible to assertion-based tests.

test('Button matches visual snapshot in all variants', async ({ mount }) => {
  const primary = await mount(<Button variant="primary" label="Primary" />);
  await expect(primary).toHaveScreenshot('button-primary.png');

  const secondary = await mount(<Button variant="secondary" label="Secondary" />);
  await expect(secondary).toHaveScreenshot('button-secondary.png');

  const danger = await mount(<Button variant="danger" label="Delete" />);
  await expect(danger).toHaveScreenshot('button-danger.png');
});

test('Modal matches visual snapshot with content', async ({ mount, page }) => {
  await mount(
    <Modal open={true} title="Confirm Delete">
      This action cannot be undone. Are you sure you want to proceed?
    </Modal>
  );
  await expect(page.getByRole('dialog')).toHaveScreenshot('modal-confirm.png', {
    maxDiffPixelRatio: 0.01,
  });
});

Visual regression tests generate baseline images on the first run and compare subsequent runs against those baselines. You can configure the comparison threshold using the maxDiffPixelRatio option, and update baselines by running npx playwright test --update-snapshots. Store baseline images in your version control system so that the entire team shares the same visual expectations. Different operating systems render fonts differently, so generate baselines on the same OS used in CI, typically Linux.

Comparison Table: Component Testing vs Unit Testing vs E2E

AspectUnit Testing (Jest/Vitest)Component Testing (Playwright CT)E2E Testing (Playwright)
EnvironmentJSDOM (simulated)Real browser engineReal browser + real server
Speed per test1-50ms500ms-3s5-30s
CSS TestingNo real CSS renderingFull CSS supportFull CSS support
NetworkMocked at module levelMocked at browser levelReal or mocked at browser level
AccessibilityLimited aria-* checksReal accessibility treeReal accessibility tree
Visual RegressionNot possibleBuilt-in screenshot comparisonBuilt-in screenshot comparison
Setup ComplexityLowMediumHigh
FlakinessVery lowLowMedium to high
CI CostMinimalModerate (browser needed)High (full stack needed)
Best ForLogic, hooks, utilitiesComponent behavior and visualFull user journeys

When to Use Component Tests vs Storybook and Chromatic

Storybook with Chromatic provides a visual review workflow where designers and product managers can inspect components in a gallery. It excels at documentation and visual approval workflows. Playwright component testing excels at automated verification, interaction testing, and CI integration. The two tools serve different purposes and work well together in a complementary setup.

Use Storybook when you need a visual catalog of component states for team review, design system documentation, or handoff between designers and developers. Use Playwright component testing when you need automated regression detection, interaction testing with keyboard and mouse simulation, accessibility verification, or network mocking. Many teams maintain both: Storybook for the visual catalog and Playwright CT for the automated test suite.

You can even write Playwright tests that run against your Storybook instances using the @storybook/test-runner package, giving you the best of both worlds. This approach lets Storybook serve as the rendering host while Playwright handles the assertions and interactions, combining the rich component showcase with robust automated verification.

Running Component Tests in CI

# .github/workflows/component-tests.yml
name: Component Tests
on: [push, pull_request]

jobs:
  component-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test -c playwright-ct.config.ts --reporter=html
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: component-test-report
          path: playwright-report/
          retention-days: 14
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: component-test-traces
          path: test-results/
          retention-days: 7

Advanced Patterns and Best Practices

As your component test suite grows, you will want to adopt patterns that keep tests maintainable and fast. Create custom mount helpers that wrap components in common providers like theme context, internationalization, and state management. This reduces boilerplate across test files and ensures consistent test environments. Use Playwright test fixtures to set up shared state like mock data or router configuration.

Organize tests by component using the .ct.tsx suffix convention, keeping them co-located with the component source code for easy discovery. This makes it natural to update tests when you modify a component and helps new team members find relevant tests quickly. Avoid testing implementation details and focus on what the user sees and does rather than internal state or DOM structure.

Use role-based locators like getByRole and getByLabel instead of CSS selectors or test IDs wherever possible. This makes your tests resilient to refactoring and ensures they validate the same experience that real users have. When role-based locators are not sufficient, prefer data-testid attributes over CSS class selectors which are fragile and often change during styling updates.

Monitor test execution time and split slow suites across parallel workers. Playwright component tests support the same sharding and parallelism features as E2E tests, so you can distribute them across multiple CI machines as your suite scales. Set appropriate timeouts for different test categories and use the trace viewer to debug failures efficiently.

Conclusion

Playwright component testing bridges the gap between fast but fake unit tests and slow but real E2E tests. By mounting individual components in real browsers, you catch CSS bugs, accessibility issues, and interaction failures that JSDOM-based runners miss, while maintaining execution speeds that keep your CI pipeline fast. Start with your most complex and interaction-heavy components, establish the configuration and patterns described in this guide, and gradually expand coverage as your team gains confidence. The combination of real browser rendering, network mocking, visual regression, and the familiar Playwright API makes component testing a powerful addition to any React or Vue project quality assurance strategy.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.