Playwright's @playwright/experimental-ct-react package mounts individual React components in a real browser using a mount() fixture: you can test a dropdown's Escape-key behavior or a form's error state without running the full app stack. It uses the same locators and assertions as your regular Playwright tests, configured through a separate playwright-ct.config.ts. This guide covers setup, testing props and event handlers, re-rendering with updated props mid-test, visual regression at the component level, and when component tests replace E2E versus when they don't.

When Component Testing Makes Sense

Use component testing for:
  • UI components with complex interaction logic (dropdowns, modals, date pickers)
  • Components that are hard to reach via E2E (edge states, error states)
  • Design system components that need visual consistency verification
  • Testing event handling and props without the full app
Don't replace E2E with component tests:

Component tests don't cover routing, authentication, API integration, or the interaction between components. You need both.

Setup

npm init playwright@latest -- --ct
# Or add to existing project:
npm install --save-dev @playwright/experimental-ct-react

This creates a playwright-ct.config.ts separate from your regular Playwright config:

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

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.ct.spec.{ts,tsx}',
  
  use: {
    ctPort: 3100,  // Port for the component test server
    viewport: { width: 1280, height: 720 },
  },
  
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

Your First Component Test

// components/Button.tsx
interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary' | 'danger';
  disabled?: boolean;
  onClick?: () => void;
}

export function Button({ label, variant = 'primary', disabled, onClick }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant}`}
      disabled={disabled}
      onClick={onClick}
      data-testid="button"
    >
      {label}
    </button>
  );
}

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

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

test('applies variant class', async ({ mount }) => {
  const component = await mount(<Button label="Danger" variant="danger" />);
  
  await expect(component).toHaveClass(/btn-danger/);
});

test('disabled button cannot be clicked', async ({ mount }) => {
  const component = await mount(<Button label="Disabled" disabled />);
  
  await expect(component.getByTestId('button')).toBeDisabled();
});

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

Run component tests:

npx playwright test --config playwright-ct.config.ts

Testing a Form Component

// components/LoginForm.tsx
interface LoginFormProps {
  onSubmit: (email: string, password: string) => void;
  error?: string;
}

export function LoginForm({ onSubmit, error }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      onSubmit(email, password);
    }}>
      <input
        data-testid="email"
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        data-testid="password"
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
        placeholder="Password"
      />
      {error && <p data-testid="error">{error}</p>}
      <button type="submit" data-testid="submit">Sign In</button>
    </form>
  );
}

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

test('submits with correct credentials', async ({ mount }) => {
  let submittedEmail = '';
  let submittedPassword = '';
  
  const component = await mount(
    <LoginForm
      onSubmit={(email, password) => {
        submittedEmail = email;
        submittedPassword = password;
      }}
    />
  );
  
  await component.getByTestId('email').fill('user@test.com');
  await component.getByTestId('password').fill('ValidPass1');
  await component.getByTestId('submit').click();
  
  expect(submittedEmail).toBe('user@test.com');
  expect(submittedPassword).toBe('ValidPass1');
});

test('shows error message when provided', async ({ mount }) => {
  const component = await mount(
    <LoginForm
      onSubmit={() => {}}
      error="Invalid credentials"
    />
  );
  
  await expect(component.getByTestId('error')).toHaveText('Invalid credentials');
});

test('does not show error when error is undefined', async ({ mount }) => {
  const component = await mount(
    <LoginForm onSubmit={() => {}} />
  );
  
  await expect(component.getByTestId('error')).not.toBeVisible();
});

Testing a Dropdown Component

Complex components with state are where component tests shine:

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

const options = [
  { value: 'chrome', label: 'Chrome' },
  { value: 'firefox', label: 'Firefox' },
  { value: 'safari', label: 'Safari' },
];

test('dropdown shows options when clicked', async ({ mount }) => {
  const component = await mount(
    <Dropdown options={options} placeholder="Select browser" />
  );
  
  // Initially closed
  await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
  
  // Click to open
  await component.getByTestId('dropdown-trigger').click();
  
  // Now visible with all options
  await expect(component.getByTestId('dropdown-list')).toBeVisible();
  
  const items = component.getByTestId('dropdown-option');
  await expect(items).toHaveCount(3);
  await expect(items.nth(0)).toHaveText('Chrome');
});

test('closes after selecting an option', async ({ mount }) => {
  let selected = '';
  
  const component = await mount(
    <Dropdown
      options={options}
      placeholder="Select browser"
      onSelect={(value) => { selected = value; }}
    />
  );
  
  await component.getByTestId('dropdown-trigger').click();
  await component.getByText('Firefox').click();
  
  expect(selected).toBe('firefox');
  await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
  await expect(component.getByTestId('dropdown-trigger')).toHaveText('Firefox');
});

test('closes on Escape key', async ({ mount }) => {
  const component = await mount(
    <Dropdown options={options} placeholder="Select" />
  );
  
  await component.getByTestId('dropdown-trigger').click();
  await expect(component.getByTestId('dropdown-list')).toBeVisible();
  
  await component.press('Escape');
  await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
});

Updating Components Mid-Test

You can re-render a component with different props:

test('error message updates when props change', async ({ mount }) => {
  const component = await mount(
    <LoginForm onSubmit={() => {}} />
  );
  
  // Initially no error
  await expect(component.getByTestId('error')).not.toBeVisible();
  
  // Update props to add error
  await component.update(
    <LoginForm onSubmit={() => {}} error="Session expired" />
  );
  
  await expect(component.getByTestId('error')).toHaveText('Session expired');
  
  // Clear error
  await component.update(
    <LoginForm onSubmit={() => {}} />
  );
  
  await expect(component.getByTestId('error')).not.toBeVisible();
});

Hooks in Component Tests

Playwright component tests support the same hooks as regular Playwright tests:

import { test, expect, beforeEach } from '@playwright/experimental-ct-react';

let clickCount = 0;

test.beforeEach(() => {
  clickCount = 0;
});

test('counts clicks correctly', async ({ mount }) => {
  const component = await mount(
    <Button label="Click" onClick={() => { clickCount++; }} />
  );
  
  await component.click();
  await component.click();
  await component.click();
  
  expect(clickCount).toBe(3);
});

Visual Testing with Components

Component tests work well with visual regression:

test('button variants look correct', async ({ mount }) => {
  for (const variant of ['primary', 'secondary', 'danger'] as const) {
    const component = await mount(<Button label={variant} variant={variant} />);
    await expect(component).toHaveScreenshot(`button-${variant}.png`);
  }
});

This is more reliable than full-page screenshots — fewer moving parts, easier to isolate what changed.

Vue and Svelte

The same concepts apply to Vue and Svelte:

# Vue
npm install --save-dev @playwright/experimental-ct-vue

# Svelte
npm install --save-dev @playwright/experimental-ct-svelte

<!-- Vue test -->
<script setup>
import { test, expect } from '@playwright/experimental-ct-vue'
import MyComponent from './MyComponent.vue'
</script>

Component vs E2E: When to Use Which

| Scenario | Component Test | E2E Test |

|----------|---------------|---------|

| Button click handler | ✅ | ❌ |

| Dropdown opens/closes | ✅ | ❌ |

| Form validation messages | ✅ | ❌ |

| Login flow end-to-end | ❌ | ✅ |

| Navigation between pages | ❌ | ✅ |

| API integration | ❌ | ✅ |

| Component visual regression | ✅ | Sometimes |

Summary

# Setup
npm install --save-dev @playwright/experimental-ct-react

# Config: playwright-ct.config.ts
# Tests: ComponentName.ct.spec.tsx

# Run
npx playwright test --config playwright-ct.config.ts

Key operations:

  • mount() — render a component in a real browser
  • component.getByTestId(...) — standard Playwright locators
  • component.click() — standard Playwright actions
  • component.update() — re-render with new props
  • expect(component).toHaveScreenshot() — visual regression

Component testing fills the gap between unit tests (too isolated) and E2E tests (too slow). For UI-heavy applications with complex components, it's worth adding to your test strategy.

→ See also: The Test Pyramid Explained for QA Engineers | Playwright Assertions: The Complete Guide | Visual Regression Testing with Playwright: toHaveScreenshot Without Applitools