Playwright Component Testing: Test React and Vue in Isolation

Playwright Component Testing: Test React and Vue in Isolation

End-to-end tests are great for validating full user flows, but they're slow to write and slow to run when you're testing a single component. Playwright Component Testing lets you mount individual React, Vue, or Svelte components in a real browser — without standing up your full app — and test them in isolation.

This guide covers setup, mounting components, mocking, event testing, and when to use component tests versus E2E tests.

What Is Playwright Component Testing?

Playwright Component Testing (@playwright/experimental-ct-*) runs your component in a browser using Vite as a dev server. You get:

  • Real browser rendering (not jsdom)
  • Full CSS support, including animations and media queries
  • Playwright's full locator and assertion API
  • Multi-browser testing (Chromium, Firefox, WebKit)

Unlike unit tests with jsdom (e.g. Vitest/Jest + React Testing Library), component tests run in an actual browser environment. This catches rendering bugs that jsdom silently ignores.

Note: Playwright Component Testing is currently experimental. The API is stable for React and Vue, but expect minor changes between major versions.

Installation

For React:

npm install --save-dev @playwright/experimental-ct-react
npx playwright install

For Vue:

npm install --save-dev @playwright/experimental-ct-vue
npx playwright install

Configuration

Create playwright-ct.config.ts in your project root:

import { defineConfig } from '@playwright/experimental-ct-react';

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.ct.{ts,tsx}',
  use: {
    ctPort: 3100,
    ctViteConfig: {
      // Override Vite config for tests if needed
    },
  },
});

For Vue, import from @playwright/experimental-ct-vue instead.

Mounting Your First Component

Let's test a simple Button component:

// src/Button.tsx
type Props = {
  label: string;
  onClick: () => void;
  disabled?: boolean;
};

export function Button({ label, onClick, disabled }: Props) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {label}
    </button>
  );
}

Test file:

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

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

test('calls onClick 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('is disabled when disabled prop is true', async ({ mount }) => {
  const component = await mount(
    <Button label="Delete" onClick={() => {}} disabled={true} />
  );

  await expect(component).toBeDisabled();
});

Run component tests:

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

Testing a Form Component

// src/LoginForm.tsx
export function LoginForm({ onSubmit }: { onSubmit: (data: { email: string; password: string }) => void }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  return (
    <form onSubmit={(e) => { e.preventDefault(); onSubmit({ email, password }); }}>
      <label>
        Email
        <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
      </label>
      <label>
        Password
        <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      </label>
      <button type="submit">Log in</button>
    </form>
  );
}

Test:

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

test('submits email and password', async ({ mount }) => {
  let submitted: { email: string; password: string } | null = null;

  const component = await mount(
    <LoginForm onSubmit={(data) => { submitted = data; }} />
  );

  await component.getByLabel('Email').fill('user@example.com');
  await component.getByLabel('Password').fill('hunter2');
  await component.getByRole('button', { name: 'Log in' }).click();

  expect(submitted).toEqual({
    email: 'user@example.com',
    password: 'hunter2',
  });
});

test('shows validation error for empty fields', async ({ mount }) => {
  const component = await mount(<LoginForm onSubmit={() => {}} />);
  await component.getByRole('button', { name: 'Log in' }).click();

  await expect(component.getByText('Email is required')).toBeVisible();
});

Mocking Context and Providers

Most real components depend on context (auth, theme, routing). Wrap them in the same providers you use in production:

// src/UserCard.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { UserCard } from './UserCard';
import { AuthProvider } from './context/AuthContext';

test('shows user name from context', async ({ mount }) => {
  const component = await mount(
    <AuthProvider value={{ user: { name: 'Alice', role: 'admin' } }}>
      <UserCard />
    </AuthProvider>
  );

  await expect(component.getByText('Alice')).toBeVisible();
  await expect(component.getByText('admin')).toBeVisible();
});

Mocking API Calls

Components that fetch data need their network calls intercepted. Use Playwright's route API inside component tests:

test('displays fetched posts', async ({ mount, page }) => {
  await page.route('/api/posts', async (route) => {
    await route.fulfill({
      json: [
        { id: 1, title: 'First Post' },
        { id: 2, title: 'Second Post' },
      ],
    });
  });

  const component = await mount(<PostList />);

  await expect(component.getByText('First Post')).toBeVisible();
  await expect(component.getByText('Second Post')).toBeVisible();
});

You can also mock error states:

test('shows error when API fails', async ({ mount, page }) => {
  await page.route('/api/posts', async (route) => {
    await route.fulfill({ status: 500, json: { message: 'Server error' } });
  });

  const component = await mount(<PostList />);
  await expect(component.getByText('Failed to load posts')).toBeVisible();
});

Vue Component Testing

Vue setup is nearly identical. Import from @playwright/experimental-ct-vue:

<!-- src/Counter.vue -->
<template>
  <div>
    <span data-testid="count">{{ count }}</span>
    <button @click="count++">Increment</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
// src/Counter.ct.ts
import { test, expect } from '@playwright/experimental-ct-vue';
import Counter from './Counter.vue';

test('increments counter on click', async ({ mount }) => {
  const component = await mount(Counter);

  await expect(component.getByTestId('count')).toHaveText('0');
  await component.getByRole('button', { name: 'Increment' }).click();
  await expect(component.getByTestId('count')).toHaveText('1');
});

Passing props and emitting events:

test('emits update event', async ({ mount }) => {
  let emittedValue: number | null = null;

  const component = await mount(Counter, {
    props: { initialCount: 5 },
    on: {
      update: (val: number) => { emittedValue = val; },
    },
  });

  await component.getByRole('button', { name: 'Increment' }).click();
  expect(emittedValue).toBe(6);
});

Visual Testing with Screenshots

Playwright supports visual snapshot testing in component tests too:

test('matches visual snapshot', async ({ mount }) => {
  const component = await mount(
    <Button label="Primary" variant="primary" onClick={() => {}} />
  );

  await expect(component).toHaveScreenshot('button-primary.png');
});

On first run, Playwright creates a baseline screenshot. On subsequent runs, it diffs against the baseline and fails if pixels change.

When to Use Component Tests vs E2E Tests

Scenario Component Test E2E Test
Single component logic ❌ Too slow
Component + context/providers
Network-dependent rendering ✅ (mock API) ✅ (real API)
Multi-page flows
Third-party integrations
Visual regression (component)
Authentication flows

Component tests are fast (milliseconds per test vs. seconds for E2E) and deterministic. Use them liberally for component logic. Reserve E2E tests for critical user flows that cross multiple pages or involve real backend calls.

Structuring Your Test Suite

A common structure that scales well:

src/
  components/
    Button/
      Button.tsx
      Button.ct.tsx        ← component test
      Button.test.ts       ← unit test (pure logic)
  pages/
    LoginPage/
      LoginPage.tsx
      LoginPage.ct.tsx     ← component test for page layout
tests/
  e2e/
    auth.spec.ts           ← E2E test for full login flow

Running in CI

Add a separate CI step for component tests:

# .github/workflows/test.yml
- name: Run component tests
  run: npx playwright test --config playwright-ct.config.ts

- name: Run E2E tests
  run: npx playwright test --config playwright.config.ts

Component tests run without a real server, so they complete faster and require less infrastructure.

Summary

Playwright Component Testing bridges the gap between unit tests and E2E tests:

  • Mount real components in a real browser — not jsdom
  • Test interaction, rendering, props, events, and slots
  • Mock API calls and context providers
  • Run visual snapshot tests
  • Cross-browser (Chromium, Firefox, WebKit)

It's fast, accurate, and uses the same assertion API as your E2E tests — making it a natural addition to any Playwright-based test suite.

Read more