← 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

The vast majority of design systems attach their shadow roots in open mode:

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

Open means the shadow root is exposed on the host element as element.shadowRoot. Anything with a reference to the host — the browser's own accessibility tree, and by extension a well-behaved automation driver — can see inside. Playwright's locator engine pierces open shadow roots automatically for role, label, text, and test-id queries. It walks the composed tree the way a screen reader does, not the flat light-DOM tree that a raw CSS selector is stuck in.

That's the whole trick, and it's why the kind of locator you reach for matters more here than anywhere else. Two locators that look interchangeable behave completely differently at a shadow boundary:

// Raw CSS: stops at the shadow boundary. Times out even though
// the button is visibly on the page.
await page.locator('design-button .native-button').click();

// Role + accessible name: crosses open shadow roots, finds the
// button wherever it's rendered.
await page.getByRole('button', { name: 'Save changes' }).click();

The CSS descendant combinator can't reach through design-button's shadow root to the .native-button inside it. The role locator can, because it's querying the composed accessibility tree, not matching a selector string against one document scope.

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 — a raw CSS path that reaches into a component's internals is frequently impossible, not merely fragile. 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 page.getByTestId('checkout-total').isVisible();

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, the reason is usually that the component never exposed a role or an accessible name — which means a screen-reader user can't find it either. 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 internals are deliberately sealed off from all outside JavaScript — including the automation driver. This isn't a locator-strategy problem you can out-clever; there is no supported handle to the content from outside the component. The most prominent example teams hit is Salesforce's Lightning Web Security, which runs components inside closed roots specifically to isolate them.

The right behavior from a testing tool here is to tell you the truth fast. A closed shadow root should not produce a mysterious 30-second timeout that sends you hunting through a trace wondering what you typed wrong. It should fail immediately with a message that names the actual cause: this content lives in a closed shadow root and cannot be reached from outside. That's the honest failure you want — a clear "this isn't automatable from here," not a slow, ambiguous red that costs you an afternoon before you realize the tool was never going to reach inside.

TestVibe treats these two worlds exactly the way the platform does. Generated Playwright tests pierce open shadow roots and default to boundary-safe locators — getByRole, getByLabel, getByTestId — rather than raw CSS that collides with light-DOM twins or dead-ends at a boundary. When a flow genuinely depends on content behind a closed shadow root, generation surfaces a clear, named failure instead of an inscrutable timeout. Knowing where the wall actually is beats pretending there's a way through it.

The short version

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 you see them? Get early access.

Early access

Ready for tests that write themselves?