Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 030 / 100

Component testing with Playwright: a step-by-step guide

Test components in a real browser.

Expona Admin7 min read

Component testing with Playwright represents a shift in how we validate UI behavior without spinning up a full application. Instead of testing through the entire stack, we mount components directly in a real browser and verify their rendering and interactions. This Day 30 guide walks you through setting up and writing component tests that catch bugs at the component level.

Experimental component testing for React, Vue and Svelte

Playwright's component testing feature lets you mount and test individual components in isolation using a real browser context. This is different from unit tests that might use shallow rendering or snapshots—here, we get genuine DOM rendering, event handling, and browser APIs firing exactly as they would in production. The feature remains experimental, but teams in production have found it invaluable for catching integration issues early.

Component testing bridges the gap between unit and end-to-end testing. A component might pass unit tests with mocked dependencies, but fail when its actual DOM interacts with real CSS, animations, or browser timing. By rendering in a real browser, we catch these gaps.

Playwright supports React, Vue, and Svelte out of the box. Each framework has a corresponding adapter that handles mounting and lifecycle management. To get started, install the component testing dependencies:

typescript
npm install --save-dev @playwright/experimental-ct-react @playwright/test

Then configure playwright.config.ts:

typescript
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './src/components',
  testMatch: '**/*.spec.ts',
  use: {
    ctPort: 3100,
  },
  webServer: {
    command: 'npm run dev',
    port: 3100,
    reuseExistingServer: !process.env.CI,
  },
});

The key difference from standard Playwright tests is the ctPort (component testing port) and the absence of a full baseURL. Playwright will spawn a lightweight dev server just for your components.

Mount components with props

Mounting a component is straightforward—you use the mount function provided by Playwright's test runner. This function accepts your component and its props, then renders it in the test browser.

Consider a real-world scenario: testing a checkout form component that accepts products, a callback for order submission, and a disabled state. Here's what a test looks like:

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

test('should render checkout form with products', async ({ mount }) => {
  const products = [
    { id: 1, name: 'Widget', price: 29.99 },
    { id: 2, name: 'Gadget', price: 49.99 },
  ];

  const component = await mount(
     {}}
      disabled={false}
    />
  );

  await expect(component.locator('text=Widget')).toBeVisible();
  await expect(component.locator('text=Gadget')).toBeVisible();
});

The mount function returns a locator-like object you can query just like in regular Playwright tests. You pass JSX directly; no need to render to a string or register a global component library. This keeps tests focused and reduces boilerplate.

Props can be dynamic. In one test, you might mount with empty state; in another, with a large dataset:

typescript
test('should handle empty product list', async ({ mount }) => {
  const component = await mount(
     {}}
      disabled={false}
    />
  );

  await expect(component.locator('text=No products available')).toBeVisible();
});

Callbacks passed as props work too. You can track whether they were called and with what arguments:

typescript
test('should call onSubmit with form data', async ({ mount }) => {
  let submittedData;
  const component = await mount(
     {
        submittedData = data;
      }}
      disabled={false}
    />
  );

  await component.locator('button:has-text("Submit Order")').click();
  
  expect(submittedData).toEqual({
    products: [{ id: 1, name: 'Widget', price: 29.99 }],
    total: 29.99,
  });
});

Real browser rendering

The magic of Playwright's component testing is that components render in a real browser, not a headless JSDOM or shallow renderer. This means CSS, animations, and timing behave exactly as they will in production.

Say your CheckoutForm has a button that shows a loading spinner while the order submits. A unit test might mock the request, but in a real browser, CSS animations trigger and state changes propagate through React's reconciliation engine. Component tests catch these nuances:

typescript
test('should show loading state during submission', async ({ mount }) => {
  let resolveSubmit;
  const submitPromise = new Promise((resolve) => {
    resolveSubmit = resolve;
  });

  const component = await mount(
     {
        await submitPromise;
      }}
      disabled={false}
    />
  );

  await component.locator('button:has-text("Submit Order")').click();
  
  // In a real browser, the loading spinner appears immediately
  await expect(component.locator('[data-testid="loading-spinner"]')).toBeVisible();
  
  // Spinner is still there while the promise hasn't resolved
  await expect(component.locator('button:has-text("Submit Order")')).toBeDisabled();
  
  // Resolve the submission
  resolveSubmit();
  
  // Spinner disappears
  await expect(component.locator('[data-testid="loading-spinner"]')).not.toBeVisible();
  await expect(component.locator('button:has-text("Submit Order")')).toBeEnabled();
});

Real browser rendering also means hover states, focus management, and keyboard events work naturally. No mocking required:

typescript
test('should apply hover styles to product card', async ({ mount }) => {
  const component = await mount(
     {}}
      disabled={false}
    />
  );

  const productCard = component.locator('[data-testid="product-card"]').first();
  
  // Hover the card
  await productCard.hover();
  
  // Verify computed style (this works in real browsers)
  const bgColor = await productCard.evaluate((el) =>
    window.getComputedStyle(el).backgroundColor
  );
  expect(bgColor).toBe('rgb(240, 240, 240)'); // example hover color
});

Use for complex interactive components

Component tests shine when your component has complex interactions, conditional rendering, or multiple state changes. Modal dialogs, dropdowns, multi-step forms, and data tables all benefit from component testing because you can simulate real user flows.

Imagine a product selector component that filters, sorts, and allows multi-select. Building a component test forces you to think through every interaction path:

typescript
test('should filter and multi-select products', async ({ mount }) => {
  const allProducts = [
    { id: 1, name: 'Widget A', category: 'widgets', price: 29.99 },
    { id: 2, name: 'Widget B', category: 'widgets', price: 39.99 },
    { id: 3, name: 'Gadget X', category: 'gadgets', price: 49.99 },
  ];

  const selected = [];
  const component = await mount(
     selected.push(product)}
    />
  );

  // Filter by category
  await component.locator('select[name="category"]').selectOption('widgets');
  
  // Should now show only 2 widgets
  const rows = component.locator('tbody tr');
  await expect(rows).toHaveCount(2);
  
  // Select both widgets
  await component.locator('input[type="checkbox"]').first().check();
  await component.locator('input[type="checkbox"]').nth(1).check();
  
  // Verify selections
  expect(selected).toHaveLength(2);
  expect(selected[0].id).toBe(1);
  expect(selected[1].id).toBe(2);
});

Component tests also excel at verifying accessibility. You can navigate with keyboard, check focus order, and validate ARIA attributes—all in a real browser:

typescript
test('should be keyboard accessible', async ({ mount }) => {
  const component = await mount(
     {}}
      disabled={false}
    />
  );

  // Tab to the submit button
  await component.locator('body').press('Tab');
  await component.locator('body').press('Tab');
  
  // Verify it's focused
  await expect(component.locator('button:has-text("Submit Order")')).toBeFocused();
  
  // Press Enter to submit
  await component.locator('button:has-text("Submit Order")').press('Enter');
  
  // Verify form was submitted
  await expect(component.locator('text=Order received')).toBeVisible();
});

Common mistakes

  • Forgetting to await mount: Always await the mount call. Component tests are async by nature, and not awaiting leads to timing issues.
  • Testing component internals instead of behavior: Don't test implementation details like local state or internal function calls. Test what the user sees and does.
  • Not cleaning up side effects in callbacks: If a callback mutates external state (like a global store), reset it between tests to avoid flaky tests.
  • Ignoring timeouts for async operations: When your component awaits an API call or timer, increase the test timeout or explicitly wait with waitFor.
  • Over-testing with component tests: Not every component needs a component test. Simple presentational components that don't change behavior based on interactions are fine with unit tests or visual regression.

Key takeaways

  • Component testing mounts individual components in a real browser, catching rendering and interaction bugs that unit tests miss.
  • Use the mount function to render components with props and callbacks, exactly as you'd use them in your app.
  • Real browser rendering means CSS, animations, and timing work naturally—no mocks or shallow rendering required.
  • Component tests are ideal for complex interactive components like forms, modals, and multi-step wizards.
  • Focus on behavior, not implementation. Test what users see and do, not internal state or function calls.
  • Keep component tests lightweight by testing a single component in isolation; use end-to-end tests for cross-component flows.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles