Testing Web Components and Shadow DOM

Custom elements — whether hand-written, built with Lit, or compiled from Stencil — keep their markup inside a shadow root, register themselves globally, and communicate through attributes, properties and events rather than props and callbacks. Each of those differences breaks assumptions that framework-component tests rely on. Queries that search the document stop at the shadow boundary; a jsdom environment lacks parts of the layout and form-association behaviour that real components depend on; and registering the same tag twice throws. This guide tests web components in a real browser with Playwright, where locators pierce open shadow roots by default, and covers the attribute-versus-property distinction, custom events, slotted content, form-associated elements and the registration pitfalls that make these suites flaky. It belongs to Playwright component testing.

Root Cause Analysis

Shadow DOM encapsulation is the first obstacle. document.querySelector does not descend into shadow roots, and neither do many helpers built on it. A test written that way reports “element not found” for a button that is plainly on screen. Playwright’s CSS and role locators pierce open shadow roots automatically, which is why a real browser is the natural home for these tests; closed shadow roots are deliberately unreachable and should be tested only through their public surface.

The second is the attribute–property split. <price-tag amount="12"> sets an attribute, which is always a string; el.amount = 12 sets a property, which can be any type. Components often reflect one to the other, but not always, and frameworks differ in which they set. A component that works when used from HTML and breaks when used from React usually has a bug in this area, and tests that only use one path cannot see it.

The third is lifecycle. Custom elements upgrade asynchronously when their definition loads, render asynchronously when Lit batches updates, and fire events that must be composed: true to escape the shadow root. Tests that check the DOM immediately after setting a property, or listen for an event on the host that never crosses the boundary, fail intermittently or not at all.

What crosses the shadow boundary The light DOM holds the host element, its attributes and slotted children. The open shadow root holds internal markup, which Playwright locators pierce but document queries do not. Only composed events travel from the shadow root out to listeners on the page. light DOM <price-tag amount="12"> attributes and properties slotted children document queries reach here open shadow root internal markup and styles Playwright locators pierce it composed events escape it querySelector stops at the edge
Test the public surface — attributes, properties, slots and composed events — and use piercing locators only for what a user sees.

Reproducible Setup

A Lit quantity stepper that exposes a value property reflected to an attribute, a max attribute, and a composed change event.

// src/elements/qty-stepper.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('qty-stepper')
export class QtyStepper extends LitElement {
  @property({ type: Number, reflect: true }) value = 1;
  @property({ type: Number }) max = 10;

  private step(delta: number) {
    const next = Math.min(this.max, Math.max(1, this.value + delta));
    if (next === this.value) return;
    this.value = next;
    this.dispatchEvent(new CustomEvent('change', { detail: next, bubbles: true, composed: true }));
  }

  render() {
    return html`
      <button aria-label="Decrease" @click=${() => this.step(-1)}>−</button>
      <output aria-live="polite">${this.value}</output>
      <button aria-label="Increase" @click=${() => this.step(1)}>+</button>`;
  }
}

A fixture page loads the bundle once; each test sets up its own markup, so registration happens a single time per page.

// tests/elements/fixture.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
  page: async ({ page }, use) => {
    await page.goto('/elements-fixture.html'); // loads the compiled elements bundle
    await page.waitForFunction(() => customElements.get('qty-stepper'));
    await use(page);
  },
});

Implementation

Step 1 — Query through the shadow root with role locators. No special syntax is needed for open roots.

test('increments when the increase button is clicked', async ({ page }) => {
  await page.setContent('<qty-stepper value="2"></qty-stepper>', { waitUntil: 'load' });
  await page.addScriptTag({ url: '/elements.js', type: 'module' });
  const stepper = page.locator('qty-stepper');

  await stepper.getByRole('button', { name: 'Increase' }).click();
  await expect(stepper.locator('output')).toHaveText('3');
  await expect(stepper).toHaveAttribute('value', '3'); // reflected back to the attribute
});

Step 2 — Test the property path separately from the attribute path. Frameworks that set properties exercise different code than HTML does.

test('accepts a numeric value set as a property', async ({ page }) => {
  await page.setContent('<qty-stepper></qty-stepper>');
  await page.addScriptTag({ url: '/elements.js', type: 'module' });
  const stepper = page.locator('qty-stepper');

  await stepper.evaluate((el: any) => { el.value = 7; });
  await expect(stepper.locator('output')).toHaveText('7');
  await expect(stepper).toHaveAttribute('value', '7');
});

Step 3 — Assert composed events reach the page. Record events on the document, which only receives them if they are composed and bubbling.

test('emits a composed change event with the new value', async ({ page }) => {
  await page.setContent('<qty-stepper value="1"></qty-stepper>');
  await page.addScriptTag({ url: '/elements.js', type: 'module' });
  await page.evaluate(() => {
    (window as any).changes = [];
    document.addEventListener('change', (e) => (window as any).changes.push((e as CustomEvent).detail));
  });

  await page.getByRole('button', { name: 'Increase' }).click();
  await page.getByRole('button', { name: 'Increase' }).click();
  expect(await page.evaluate(() => (window as any).changes)).toEqual([2, 3]);
});
Three ways a consumer drives a custom element HTML sets string attributes, frameworks and scripts set typed properties, and user interaction inside the shadow root fires composed events outward. Each path needs its own test because each runs different code. attributes set from HTML always strings converted on the way in properties set by frameworks typed values maybe reflected events fired from inside must be composed to reach the page
A component that passes only its attribute tests may still break inside React or Vue, which set properties.

Step 4 — Test slotted content is projected. Slots are part of the public contract; check that light-DOM children appear where the component promises.

test('projects the label slot next to the controls', async ({ page }) => {
  await page.setContent('<qty-stepper><span slot="label">Mugs</span></qty-stepper>');
  await page.addScriptTag({ url: '/elements.js', type: 'module' });
  const assigned = await page.locator('qty-stepper').evaluate(
    (el) => el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name=label]')!.assignedElements().length,
  );
  expect(assigned).toBe(1);
  await expect(page.getByText('Mugs')).toBeVisible();
});

Step 5 — Test form-associated elements inside a real form. Elements using ElementInternals must contribute their value to FormData; only a browser implements this fully.

test('submits its value with the surrounding form', async ({ page }) => {
  await page.setContent('<form><qty-stepper name="qty" value="4"></qty-stepper></form>');
  await page.addScriptTag({ url: '/elements.js', type: 'module' });
  const data = await page.locator('form').evaluate((f: HTMLFormElement) => Object.fromEntries(new FormData(f)));
  expect(data).toEqual({ qty: '4' });
});

Step 6 — Guard against double registration. Load the bundle once per page, and make definitions idempotent so hot reload and repeated imports do not throw.

if (!customElements.get('qty-stepper')) customElements.define('qty-stepper', QtyStepper);

Step 7 — Test keyboard behaviour across the boundary. Focus inside a shadow root is reported on the host as far as the page is concerned, which confuses assertions that read document.activeElement. Playwright’s toBeFocused resolves the real focused element inside the root, so write keyboard tests the way a user drives the component: tab into it, press keys, and check which internal control holds focus and what value results. This also catches components that forget delegatesFocus, where tabbing to the host lands nowhere useful and a keyboard user cannot reach the buttons at all.

test('can be operated from the keyboard', async ({ page }) => {
  await page.setContent('<button>before</button><qty-stepper value="2"></qty-stepper>');
  await page.addScriptTag({ url: '/elements.js', type: 'module' });
  await page.getByRole('button', { name: 'before' }).focus();
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  await expect(page.getByRole('button', { name: 'Increase' })).toBeFocused();
  await page.keyboard.press('Enter');
  await expect(page.locator('qty-stepper output')).toHaveText('3');
});

Verification

Remove composed: true from the event in the component and rerun. The event test must fail with an empty array, because the event stops at the shadow root and never reaches the document listener. Then remove reflect: true and confirm the attribute assertions fail while the rendered output still updates — proving the tests distinguish reflection from rendering.

npx playwright test tests/elements --reporter=list
#   ✓ increments when the increase button is clicked
#   ✓ accepts a numeric value set as a property
#   ✓ emits a composed change event with the new value
#   ✓ projects the label slot next to the controls
#   ✓ submits its value with the surrounding form
Choosing an environment for custom element tests Pure logic extracted from a component runs well in Node. Rendering, shadow DOM, slots and form association belong in a real browser through Playwright or Vitest browser mode, since jsdom support for these is partial. Node or jsdom clamping, formatting, parsing logic moved out of the element real browser shadow DOM, slots, styles form association, focus
Move pure logic into plain functions to test it cheaply, and keep the element tests for behaviour only a browser provides.

Troubleshooting

Symptom: NotSupportedError: the name has already been used. Diagnosis: the element definition is loaded twice on the same page. Fix: load the bundle once in the fixture and guard customElements.define with customElements.get.

Symptom: the output still shows the old value right after setting a property. Diagnosis: Lit renders asynchronously. Fix: use Playwright’s auto-retrying toHaveText rather than reading textContent once, or await el.updateComplete inside evaluate.

Symptom: a locator cannot find an element inside the component. Diagnosis: the component uses a closed shadow root, or the locator uses XPath, which does not pierce shadow roots. Fix: use CSS or role locators; for closed roots, test through the public surface only.

Symptom: styles differ between the test and the application. Diagnosis: the fixture page is missing global CSS custom properties the component reads. Fix: include the design tokens stylesheet on the fixture page so visual and layout assertions reflect production.

FAQ

Can I test web components in Vitest with jsdom?

For simple rendering, partly — jsdom supports custom elements and open shadow roots. It lacks layout, parts of form association and some focus behaviour. Vitest browser mode or Playwright are safer defaults; see choosing between jsdom, happy-dom and browser mode.

Should tests reach into the shadow root at all?

Through role and text locators, yes — they describe what a user perceives. Avoid selectors tied to internal class names or structure, which change freely inside the component and are not part of its contract.

How do I test a web component used from React?

Add a test that renders it through React and sets properties the way React does, since older React versions set everything as attributes. This catches the most common integration bug: object or array values arriving as the string [object Object].

Do I need Playwright’s experimental component testing for this?

No. Custom elements need no framework adapter; a fixture page that loads the compiled bundle, plus setContent per test, is simpler and uses the stable test runner.

How many web component tests should run in a browser?

Enough to cover each public contract once: attribute input, property input, emitted events, slots and keyboard use. Everything else — the arithmetic, formatting and validation behind those contracts — belongs in fast unit tests of plain functions the element imports.