Resource library

QA How-To

Playwright component testing react: Examples and Best Practices

Explore Playwright component testing React examples for forms, callbacks, APIs, routers, responsive UI, accessibility, visuals, and reliable test design.

23 min read | 2,182 words

TL;DR

The most useful Playwright component testing React examples cover a component's state transitions: input and validation, callbacks, prop updates, routed data, network failures, responsive behavior, keyboard interaction, and cleanup. Mount inside the test, use accessible locators, and assert the result a user or parent component can observe.

Key Takeaways

  • Write component tests around observable states and user actions, not React implementation details.
  • Use callback capture, component.update, and component.unmount for public input, output, and lifecycle contracts.
  • Register network routes before mounting components that fetch during an effect.
  • Use typed browser-side hooks for routers, themes, and stores instead of repeating wrapper trees.
  • Assert responsive and keyboard behavior directly, and reserve screenshots for stable visual risk.
  • Keep each example independent, deterministic, and easy to diagnose in CI.

Playwright component testing React examples are most valuable when they demonstrate test design, not just the syntax of mount. A production suite needs patterns for controlled inputs, callbacks, asynchronous requests, providers, responsive layouts, accessibility, visuals, and cleanup, all while respecting the boundary between the Node test worker and the browser-rendered React component.

This guide is an example catalog for SDETs and frontend engineers who already have, or are starting, a Playwright component-test project. Each pattern names the risk it proves, includes current component-testing APIs, and explains when the same behavior belongs in a unit, API, or end-to-end test instead.

TL;DR

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

test('submits a valid email', async ({ mount }) => {
  const component = await mount(<NewsletterForm />);

  await component.getByLabel('Work email').fill('qa@example.com');
  await component.getByRole('button', { name: 'Subscribe' }).click();

  await expect(component.getByRole('status'))
    .toHaveText('Check your inbox');
});
Pattern API or technique Public contract
Initial render mount(<Component />) Visible and accessible output
Parent changes props component.update(...) Correct state transition
Callback Inline function plus expect.poll Emitted value or action
HTTP dependency page.route() Loading, success, and error UI
Provider dependency beforeMount and hooksConfig Behavior under explicit context
Cleanup component.unmount() No leaked effects
Visual state toHaveScreenshot() Stable rendered appearance

1. Playwright Component Testing React Examples: First Render and Interaction

The first example for a component should prove its core behavioral contract with the least setup. A Disclosure receives a title and children, begins collapsed, exposes the correct expanded state, and reveals content after a user click. The test does not assert class names, hook state, or DOM nesting.

// Disclosure.tsx
import { useId, useState } from 'react';

type DisclosureProps = {
  title: string;
  children: React.ReactNode;
};

export function Disclosure({ title, children }: DisclosureProps) {
  const [open, setOpen] = useState(false);
  const panelId = useId();

  return (
    <section>
      <button
        type='button'
        aria-expanded={open}
        aria-controls={panelId}
        onClick={() => setOpen(value => !value)}
      >
        {title}
      </button>
      <div id={panelId} hidden={!open}>{children}</div>
    </section>
  );
}
// Disclosure.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Disclosure } from './Disclosure';

test('reveals help content when expanded', async ({ mount }) => {
  const component = await mount(
    <Disclosure title='Why was my payment declined?'>
      Contact your bank or try another card.
    </Disclosure>,
  );

  const trigger = component.getByRole('button', {
    name: 'Why was my payment declined?',
  });
  const answer = component.getByText('Contact your bank or try another card.');

  await expect(trigger).toHaveAttribute('aria-expanded', 'false');
  await expect(answer).toBeHidden();

  await trigger.click();

  await expect(trigger).toHaveAttribute('aria-expanded', 'true');
  await expect(answer).toBeVisible();
});

This compact test catches rendering, event wiring, accessible state, and visibility. It is stronger than a broad snapshot because every assertion has behavioral meaning.

2. Example: Form Validation and Submission Callbacks

Form components deserve tests for state transitions, not a permutation of every string. Cover required fields, a representative invalid value, a valid submission, and any boundary with product impact. Use native labels and roles so the test also detects accessibility regressions.

// ProfileForm.tsx
import { FormEvent, useState } from 'react';

type ProfileFormProps = {
  onSave: (profile: { name: string; email: string }) => void;
};

export function ProfileForm({ onSave }: ProfileFormProps) {
  const [error, setError] = useState('');

  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const name = String(data.get('name') ?? '').trim();
    const email = String(data.get('email') ?? '').trim();

    if (!email.includes('@')) {
      setError('Enter a valid email address');
      return;
    }

    setError('');
    onSave({ name, email });
  }

  return (
    <form onSubmit={submit}>
      <label>Name <input name='name' required /></label>
      <label>Email <input name='email' type='email' required /></label>
      {error && <p role='alert'>{error}</p>}
      <button type='submit'>Save profile</button>
    </form>
  );
}
import { test, expect } from '@playwright/experimental-ct-react';
import { ProfileForm } from './ProfileForm';

test('emits normalized profile data after valid submission', async ({ mount }) => {
  let saved: { name: string; email: string } | undefined;
  const component = await mount(
    <ProfileForm onSave={profile => { saved = profile; }} />,
  );

  await component.getByLabel('Name').fill('  Morgan Lee  ');
  await component.getByLabel('Email').fill('morgan@example.com');
  await component.getByRole('button', { name: 'Save profile' }).click();

  await expect(component.getByRole('alert')).toHaveCount(0);
  await expect.poll(() => saved).toEqual({
    name: 'Morgan Lee',
    email: 'morgan@example.com',
  });
});

The callback crosses from the browser component to the Node worker. expect.poll accounts for that asynchronous communication without a sleep. A separate test can fill an invalid email and assert the alert plus the absence of a callback.

3. Example: Loading, Success, Empty, and Error API States

A data component often has four distinct contracts: loading, content, empty, and failure. Route the exact request before mount because a React effect may send it immediately. Use a deferred route when the loading state must be observed, then fulfill it from the test.

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

test('moves from loading to returned notifications', async ({ page, mount }) => {
  let releaseResponse!: () => void;
  const released = new Promise<void>(resolve => { releaseResponse = resolve; });

  await page.route('**/api/notifications', async route => {
    await released;
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 'n1', message: 'Build completed' },
        { id: 'n2', message: 'Review requested' },
      ]),
    });
  });

  const component = await mount(<NotificationList />);
  await expect(component.getByRole('status')).toHaveText('Loading notifications');

  releaseResponse();

  await expect(component.getByRole('listitem')).toHaveCount(2);
  await expect(component.getByText('Build completed')).toBeVisible();
});

For an error example, return a meaningful status and assert the recovery UI:

test('offers retry after a service failure', async ({ page, mount }) => {
  await page.route('**/api/notifications', async route => {
    await route.fulfill({
      status: 503,
      contentType: 'application/json',
      body: JSON.stringify({ code: 'TEMPORARILY_UNAVAILABLE' }),
    });
  });

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

  await expect(component.getByRole('alert'))
    .toHaveText('Notifications are unavailable');
  await expect(component.getByRole('button', { name: 'Try again' }))
    .toBeVisible();
});

Do not over-specify internal request timing. Assert the request payload only when it is part of the component contract. Integration tests still need to verify the real endpoint, as explained in API pagination testing patterns for list-oriented services.

4. Example: Prop Updates Without Remounting

A parent can change props while React preserves the child instance. Remounting in a new test proves each static state but misses transition bugs such as stale effects, retained error messages, or incorrect focus. Use the component handle's update operation to model the parent rerender.

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

test('recalculates display when currency changes', async ({ mount }) => {
  const component = await mount(
    <PriceQuote amount={1250} currency='USD' locale='en-US' />,
  );

  await expect(component.getByTestId('formatted-price')).toHaveText('$1,250.00');

  await component.update(
    <PriceQuote amount={1250} currency='EUR' locale='de-DE' />,
  );

  await expect(component.getByTestId('formatted-price')).toHaveText(/1[.]250,00/);
  await expect(component.getByTestId('formatted-price')).toContainText('€');
});

The example avoids a whitespace-sensitive exact string for a locale that may use a nonbreaking space. It still proves grouping, decimals, and currency. If the product intentionally controls an exact formatted string through a polyfill, an exact assertion can be appropriate.

Updates are also useful for controlled inputs, feature state, permissions, and asynchronous parent results. Avoid using them to mutate private implementation state. The new JSX should represent an input a real parent could provide.

If a prop change starts a new request, route both endpoints or inspect the expected URL. Assert that stale content disappears only if that is the designed behavior. A well-named transition test often finds defects that separate render snapshots miss because React effects and retained state run only across the update.

For locator stability during rerenders, the Playwright locator strategy guide explains why live Locators are safer than cached element handles.

5. Example: Router, Theme, and Store Providers

Application context is necessary for many components, but repeating provider boilerplate in every spec hides the behavior under test. A typed beforeMount hook can construct providers in the browser bundle. The test passes a plain configuration object through hooksConfig.

// playwright/index.tsx
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import { MemoryRouter } from 'react-router-dom';
import { SessionProvider } from '../src/session/SessionProvider';
import { ThemeProvider } from '../src/theme/ThemeProvider';
import '../src/index.css';

export type HooksConfig = {
  path?: string;
  user?: { id: string; name: string; role: 'member' | 'admin' } | null;
  theme?: 'light' | 'dark';
};

beforeMount<HooksConfig>(async ({ App, hooksConfig }) => (
  <MemoryRouter initialEntries={[hooksConfig?.path ?? '/']}>
    <SessionProvider initialUser={hooksConfig?.user ?? null}>
      <ThemeProvider initialTheme={hooksConfig?.theme ?? 'light'}>
        {App}
      </ThemeProvider>
    </SessionProvider>
  </MemoryRouter>
));
// AdminLink.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import type { HooksConfig } from '../../playwright';
import { AccountMenu } from './AccountMenu';

test('shows admin navigation only to an administrator', async ({ mount }) => {
  const component = await mount<HooksConfig>(<AccountMenu />, {
    hooksConfig: {
      path: '/account',
      user: { id: 'u-7', name: 'Rina', role: 'admin' },
      theme: 'dark',
    },
  });

  await expect(component.getByRole('link', { name: 'Admin console' }))
    .toBeVisible();
});

Keep the configuration serializable and minimal. A real database session, store object, or router instance should be created browser-side, not passed from Node. If a provider needs extensive mocking, reconsider the component boundary or create a purpose-built story wrapper that exposes small test inputs.

6. Example: Responsive and Touch-Oriented UI

Responsive tests should prove behavior at meaningful breakpoints rather than create screenshots at every width. If a toolbar collapses at 768 pixels, test a representative width on each side and assert the accessible controls that appear. For touch-specific behavior, use a device project or context with touch enabled rather than assuming a narrow viewport equals a phone.

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

test.describe('compact toolbar', () => {
  test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });

  test('moves filters into a dialog', async ({ mount }) => {
    const component = await mount(<ProductToolbar resultCount={42} />);

    await expect(component.getByRole('button', { name: 'Filters' }))
      .toBeVisible();
    await component.getByRole('button', { name: 'Filters' }).tap();

    const dialog = component.getByRole('dialog', { name: 'Product filters' });
    await expect(dialog).toBeVisible();
    await expect(dialog.getByRole('checkbox', { name: 'In stock' }))
      .toBeVisible();
  });
});

tap() requires touch to be enabled. If the product behavior is identical for mouse and touch, click() is usually sufficient and more portable. Device emulation is broader than viewport sizing because a descriptor can also set user agent, device scale factor, touch, screen, and mobile behavior.

Do not infer physical hardware performance or native operating-system UI from this test. It validates the web component under an emulated browser context. Keep a small real-device layer for risks such as virtual keyboards, hardware-specific rendering, or installed-browser integration. The Playwright device emulation guide covers that distinction in depth.

7. Example: Keyboard, Focus, and Accessible State

Interactive components should be tested with the keyboard pattern their role promises. A modal dialog needs an accessible name, focus movement into the dialog, a working Escape path, and sensible focus restoration. Component tests are ideal because they run actual focus behavior in a browser without requiring a full user journey.

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

test('manages focus while confirming deletion', async ({ page, mount }) => {
  const component = await mount(<DeleteAccount onConfirm={() => {}} />);
  const openButton = component.getByRole('button', { name: 'Delete account' });

  await openButton.focus();
  await page.keyboard.press('Enter');

  const dialog = component.getByRole('dialog', { name: 'Confirm account deletion' });
  await expect(dialog).toBeVisible();
  await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();

  await page.keyboard.press('Escape');

  await expect(dialog).toBeHidden();
  await expect(openButton).toBeFocused();
});

The expected initial focus depends on the product's danger-dialog design, so encode the reviewed accessibility decision instead of copying this choice blindly. Add tab-order assertions only for meaningful boundaries. A long list of press('Tab') steps becomes fragile if it asserts every harmless insertion.

Role locators make accessibility regressions visible, but they are not a complete audit. Combine targeted behavior with an accessibility scanner, semantic review, and manual assistive-technology testing for high-risk components. The Playwright accessibility testing guide describes that layered approach.

8. Example: Visual State, Animation, and Deterministic Time

Visual assertions are useful when layout, clipping, typography, canvas output, or design-system consistency is the risk. Keep the screenshot target at the component root, use fixed data, disable animation, and stabilize any time-dependent content. Text and role assertions should still cover the main behavior because they yield more focused failure messages.

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

test('renders the delayed delivery state', async ({ page, mount }) => {
  await page.clock.setFixedTime(new Date('2026-07-13T10:00:00Z'));

  const component = await mount(
    <DeliveryTimeline
      estimatedAt='2026-07-13T09:30:00Z'
      checkpoints={[
        { label: 'Packed', complete: true },
        { label: 'Dispatched', complete: true },
        { label: 'Delivered', complete: false },
      ]}
    />,
  );

  await expect(component.getByRole('status')).toHaveText('Delivery delayed');
  await expect(component).toHaveScreenshot('delivery-timeline-delayed.png', {
    animations: 'disabled',
  });
});

page.clock.setFixedTime changes what the page sees as the current time without inventing a component API. Install any required clock behavior before code that depends on it. If a component starts timers during initial import or mount, choose the relevant clock installation method and sequence based on the current Playwright clock documentation.

Do not hide nondeterminism with a large screenshot tolerance. Fix missing fonts, random IDs, animation, live data, and environment differences. Review changed pixels against the product request. Snapshot updates are code changes with user impact, not maintenance noise.

9. Example: Cleanup with Unmount and Global Events

Memory leaks and duplicate global listeners often appear only after navigation or repeated mounting. The supported unmount() operation lets a test remove the component and verify that its external effects stop. This is more useful than inspecting whether a particular useEffect cleanup function ran.

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

test('stops reacting to online events after unmount', async ({ page, mount }) => {
  let changes = 0;
  const component = await mount(
    <NetworkStatusBanner onStatusChange={() => { changes += 1; }} />,
  );

  await page.evaluate(() => window.dispatchEvent(new Event('offline')));
  await expect.poll(() => changes).toBe(1);
  await expect(component.getByRole('status')).toHaveText('You are offline');

  await component.unmount();
  await page.evaluate(() => window.dispatchEvent(new Event('online')));

  await expect.poll(() => changes).toBe(1);
});

The component itself may also read navigator.onLine, so production code and tests should agree on the event model. This example focuses on listener removal. Similar patterns work for resize, storage, media-query listeners, observers, and keyboard shortcuts.

Use cleanup tests selectively. React and libraries already cover their own lifecycle machinery. Add a component test when your code owns an external subscription or a past defect shows the risk. Ensure the event is dispatched in the browser, while callback observation occurs in the worker through a marshalled function.

If a component registers through an application singleton, create a fresh singleton in the browser-side provider. Shared registries can contaminate later component tests even if the DOM root is reset.

10. Best Practices for Playwright Component Testing React Examples

A good example should be copyable in principle but specific in purpose. Name the user condition, keep the mount beside the behavior, and expose only the fixture data needed to understand the case. Helper functions should reduce mechanical repetition, not hide the component, route, action, or assertion that explains the scenario.

Use accessible locators first. A component test is close enough to the implementation to tempt authors into CSS selectors, yet it is most valuable when it fails because a button lost its accessible name or a field lost its label. Use test IDs for stable nonsemantic surfaces, then assert visible outcomes.

Control external inputs. Route requests precisely, fix time when time matters, use deterministic IDs, load production CSS, and make provider state fresh. Keep each test independent under parallel execution. Avoid waitForTimeout; wait for a role, text, count, request, or callback state that the test actually needs.

Select examples by risk. A form needs validation and callback cases. A data widget needs loading and error cases. A responsive toolbar needs breakpoint and input-modality cases. A modal needs focus behavior. A subscription-owning component needs cleanup. Do not force every pattern onto every component.

Finally, preserve layer boundaries. A mocked component test cannot prove the backend, authentication, routing server, or deployment works. An end-to-end test should not repeat every component edge case. Review the portfolio as a set of complementary signals, not as a competition for the largest test count.

Interview Questions and Answers

Q: What is the first component test you would write for a new React component?

I identify its most important public state transition and write one test that mounts explicit inputs, performs a user action, and asserts the visible or emitted result. I avoid snapshots and internal state. That first test becomes a readable contract for later edge cases.

Q: How do you verify a callback passed to a mounted component?

I pass an inline callback that records the value in the Node test scope, perform the browser action, and use expect.poll to observe the recorded value. This respects the asynchronous boundary between browser and worker. For simple boolean callbacks, the same approach works with a counter or flag.

Q: Why must an API route be installed before mount?

A component may fetch during its initial effect, immediately after rendering. If the route is registered later, the real request can escape or fail before the mock exists. Installing the route first makes the network precondition deterministic.

Q: When should you use component.update?

Use it when a real parent can change inputs while preserving the mounted component and the transition itself carries risk. Examples include changing locale, permission, record ID, or controlled value. Separate static mounts can miss stale effects or retained UI state.

Q: How do component tests support accessibility testing?

They can exercise real focus, keyboard input, roles, names, states, and visibility in a browser. Role-based locators also fail when important semantics disappear. I combine these focused checks with automated scanning and manual assistive-technology testing for critical workflows.

Q: What makes a visual component test reliable?

Deterministic data, fonts, viewport, browser project, time, and animation are essential. I target the smallest meaningful locator and keep semantic assertions beside the image. Every baseline change receives visual review rather than an automatic update.

Q: How do you prevent provider-heavy tests from becoming unreadable?

I configure reusable providers in a browser-side beforeMount hook and define a typed, serializable hooksConfig. Each test supplies only relevant route, user, theme, or store seed values. Provider instances and mutable state are created fresh for the mount.

Common Mistakes

  • Showing only mount syntax without stating the behavior the example proves.
  • Asserting React hook state, private methods, or framework-generated class names.
  • Registering API mocks after the component's request has already started.
  • Using separate mounts when the defect risk is a prop transition on one instance.
  • Treating a narrow viewport as complete mobile or touch emulation.
  • Capturing huge page screenshots for a small component state.
  • Passing complex nonserializable services through hooksConfig.
  • Sharing store, router, clock, or singleton state across parallel tests.
  • Adding fixed delays instead of waiting for observable state.
  • Repeating the same mocked scenario as an end-to-end test without an integration reason.

Conclusion

Strong Playwright component testing React examples connect one production risk to one observable browser contract. Use mount for the initial tree, update for parent-driven transitions, unmount for cleanup, routes for controlled HTTP states, hooks for providers, and locators for accessible user behavior.

Choose the next example from a real component's hardest state, not from a framework feature checklist. Implement it with explicit data and diagnostics, then place the remaining risks at the unit, API, component, or end-to-end layer where each failure will be easiest to trust and repair.

Interview Questions and Answers

Give an example of a high-value React component test.

For a server-backed form, I mount it, enter valid data, submit, and route the request to a controlled response. I assert disabled or pending UI while saving and the visible success result afterward. Separate cases cover validation and service failure without duplicating the full application journey.

How do callbacks work across the Playwright component-test boundary?

The component executes in the browser while the test callback is represented across the connection to the Node worker. I record callback arguments in test scope and use a retrying assertion such as `expect.poll`. I do not assume the value is updated synchronously on the same JavaScript stack.

How would you test loading UI without using a timeout?

I create a promise controlled by the test and await it inside a `page.route` handler. After mounting, I assert the loading state, resolve the promise, fulfill the response, and assert final content. The test waits on meaningful states rather than elapsed time.

What problem does `component.update` solve?

It models a parent changing the rendered React tree while the component remains mounted. This reveals stale effect, state retention, focus, and rerender defects that independent initial renders may miss. I use it only for valid public input transitions.

How do you test responsive components correctly?

I select widths around an actual product breakpoint and assert the behavior that changes, such as navigation moving behind a button. If touch or mobile browser behavior matters, I configure touch or a device descriptor instead of changing only viewport width. Physical-device risks remain in a smaller real-device suite.

What should be in a reusable component-test hook?

Stable browser-side setup such as global providers, fresh stores, themes, and in-memory routing belongs there. The hook accepts a small typed configuration from each test. Scenario actions, assertions, and unusually important setup stay visible in the spec.

Why avoid large component snapshots?

Large snapshots often fail on harmless markup changes while providing weak guidance about user behavior. I prefer role, text, state, and callback assertions. I add focused screenshots only when appearance itself is the contract.

Frequently Asked Questions

What are good Playwright component testing React examples to start with?

Start with an interactive state transition such as opening a disclosure, submitting a form, or handling a failed request. Then add prop updates, responsive behavior, keyboard focus, or cleanup only where the component owns those risks.

How do I test React callback props with Playwright?

Pass an inline callback during `mount`, record its argument in the test scope, perform the browser action, and observe the result with `expect.poll`. This handles the callback communication between the browser and Node worker reliably.

Can a Playwright component test change React props after mounting?

Yes. Call `component.update` with the new React tree. This is useful for testing a parent-driven transition without destroying the existing component instance.

How do I test loading state in a React component?

Register `page.route` before mount and hold the route handler on a test-controlled promise. Assert the loading UI, release the response, then assert the final content with web-first assertions.

Can Playwright component tests test keyboard focus?

Yes. Mount the component, use locator focus or keyboard actions, and assert `toBeFocused` on the expected control. This is particularly useful for dialogs, menus, listboxes, and composite widgets.

Should every React component have a screenshot test?

No. Use screenshot assertions where visual layout or rendering is a meaningful risk and can be stabilized. Behavioral text, role, value, and state assertions usually provide a clearer signal for ordinary components.

How do I test effect cleanup in Playwright component testing?

Mount the component, prove the external subscription responds, call `component.unmount`, then trigger the external event again. Assert that no additional callback or browser-visible effect occurs.

Related Guides