← All posts
Guide

Testing web components and shadow DOM without the mystery timeouts

Design systems moved into the browser's own component model, and a lot of test suites didn't notice until the selectors started timing out. If your team ships a <design-button> or a <data-table> built on Lit, Stencil, or vanilla custom elements, the markup your users interact with lives behind a shadow root — and a shadow root is a wall that most CSS selectors can't climb over.

The failure mode is nasty because it doesn't look like a shadow-DOM problem. You write page.click('.submit-btn'), the button is right there on screen, and the test times out saying the element doesn't exist. It doesn't exist — not in the light DOM your selector is searching. It exists one boundary down, and your query never crossed it.

There are two worlds here, and the line between them decides what you can automate at all. Get the distinction straight and most of the pain goes away.

Open shadow roots: reachable, if you locate the right way

An open shadow root exposes its root through the host API:

this.attachShadow({ mode: 'open' });

Open mode exposes the root through element.shadowRoot. Playwright locators, including CSS, traverse open roots by default. A raw light-document DOM query does not automatically do so. Accessibility-tree exposure depends on component semantics and is a separate mechanism.

That's the whole trick, and it's why the kind of locator you reach for matters more here than anywhere else. Playwright’s locator documentation states that its locators work through open shadow roots by default, including CSS locators. XPath does not pierce shadow roots. This differs from a raw DOM querySelector on the light document.

// Playwright CSS can pierce an open shadow root.
await page.locator("design-button .native-button").click();

// Prefer a user-facing contract when appropriate.
await page.getByRole("button", { name: "Save changes" }).click();

So the rule for shadow DOM is the same rule that keeps any browser test from going brittle, just with sharper teeth: prefer role, label, and test-id locators over structural CSS. In a shadow-heavy app it's not a style preference — structural paths still couple to component internals, even when Playwright can traverse them. This is the same locator discipline that pays off across every stack; we go deeper on it in resilient locators, and it's a recurring theme in why tests flake.

// getByLabel crosses the boundary and reads the control's
// accessible label, wherever the component wires it up.
await page.getByLabel('Email address').fill('user@example.com');

// A test id the component author exposes on its host or a
// slotted part is an explicit, boundary-safe contract.
await expect(page.getByTestId('checkout-total')).toBeVisible();

There's a second payoff that isn't obvious until you feel it. Locating by role and accessible name is exactly the pressure a design system needs anyway. If getByRole('button', { name: 'Save changes' }) can't find your custom button, inspect semantics, scope, visibility and matching; a missing locator alone does not prove what assistive technology exposes. The locator that makes your test robust is the same locator that surfaces an accessibility gap. Two problems, one fix. That coupling shows up in any component-driven UI; it's the same thread that runs through testing React SPAs, where role-based locators sidestep portals and virtualization for the same underlying reason.

Duplicate instances: scope through a unique ancestor

Component libraries exist so you can drop the same thing on a page five times. A pricing page with three <plan-card> elements has three "Choose plan" buttons, all with the identical accessible name. A bare getByRole('button', { name: 'Choose plan' }) matches all three and Playwright refuses to guess which one you meant — strict-mode violation, and rightly so.

Don't reach back for a fragile nth() index that reorders the moment marketing shuffles the plans. Scope through an ancestor that identifies the instance, then locate the control inside it:

// Anchor on the card that has a stable, meaningful landmark,
// then find the button within that scope.
const proPlan = page.getByRole('region', { name: 'Pro' });
await proPlan.getByRole('button', { name: 'Choose plan' }).click();

Now the locator says what a human would say — "the Choose plan button in the Pro card" — and it survives reordering, restyling, and a fourth plan being added next quarter. Scoping through a uniquely identifiable ancestor is the general answer to "which of these identical widgets do I mean," and it composes cleanly with shadow-piercing role queries.

Closed shadow roots: honestly, you can't

Then there's closed mode:

this.attachShadow({ mode: 'closed' });

Closed shadow roots return null from element.shadowRoot. The DOM API returns null from a closed host’s shadowRoot, and Playwright’s ordinary locators do not support closed-mode internals. Closed mode is an API encapsulation choice, not a universal security boundary or proof the component is absent from accessibility APIs. Test an exposed host/public contract where possible; otherwise a deliberate test hook or supported component-specific strategy may be needed.

Do not assume TestVibe will always identify a closed root immediately with a particular error. Inspect the generated code and actual failure evidence on your component.

The short version

  • Open shadow roots are reachable. Use role, label, and test-id locators, which cross the boundary; CSS locators also traverse open roots; XPath does not.
  • Closed shadow roots are sealed by design. Playwright’s ordinary locators do not support their internals; use an exposed contract or deliberate test strategy.
  • Duplicate component instances get disambiguated by scoping through a uniquely named ancestor, not by brittle positional indexes.
  • The locators that make shadow-DOM tests robust are the same ones that keep your components accessible. Lean into that.

Want Playwright tests that pierce open shadow roots, prefer boundary-safe locators by default, and fail honestly on the walls they can't cross — verified against a live run of your app before generated status is reported; drafts can be visible earlier? Get early access.

Coming soon

Turn a described flow into a reviewable test.

Describe a flow in plain English. Generate a Playwright test, run it on your schedule, and review proposed fixes when your UI changes. Join the waitlist for early access.

  • Plain English in, real Playwright out
  • Runs in your CI, on your schedule
  • No credit card, unsubscribe anytime