diff --git a/docs/knowledge-base/Testing.mdx b/docs/knowledge-base/Testing.mdx
new file mode 100644
index 00000000000..01982f02d8d
--- /dev/null
+++ b/docs/knowledge-base/Testing.mdx
@@ -0,0 +1,169 @@
+import { Footer, TableOfContent } from '@sb/components';
+import { Meta } from '@storybook/addon-docs/blocks';
+
+
+
+# Testing
+
+
+
+This guide covers the particularities of testing UI5 Web Components for React — the things that
+behave differently from plain React components because these are custom elements with Shadow DOM.
+It is **not tied to a specific test runner**: the advice applies to any tool that drives a
+**real browser** (for example Cypress, Playwright, or WebdriverIO).
+
+> **Use a real browser, not JSDOM.**
+> UI5 Web Components are custom elements that rely on the real DOM — Shadow DOM, custom-element
+> upgrades, slots, and CSS. Virtual-DOM environments such as JSDOM (the default for Jest and
+> Vitest) do not implement these, so components will not render or behave correctly. Choose a
+> runner that executes in an actual browser.
+
+## Component test setup
+
+When you mount a component in isolation (component testing), you have to provide the same runtime
+context the real app would. Two things are required:
+
+1. **Wrap the mounted component in `ThemeProvider`, as the outermost element.** Components read
+ theming and configuration from it, so it must sit above whatever you render.
+
+ ```tsx
+ mount(
+
+
+ ,
+ );
+ ```
+
+ Set this up once in your runner's mount wrapper (e.g. a custom Cypress `mount` command, or
+ Playwright CT's `beforeMount` hook) so every test gets it automatically.
+
+2. **Import the assets** once in your test setup if the test depends on translations (i18n),
+ theming, or locale-specific (CLDR) formatting — otherwise components render with English text
+ only and theme switching won't work:
+
+ ```tsx
+ import '@ui5/webcomponents-react/dist/Assets.js';
+ ```
+
+> This setup applies to **component testing**. In **end-to-end** tests you drive the real
+> application, which already renders `ThemeProvider` and imports assets — so you don't add either
+> in the test itself.
+
+## Selecting elements
+
+Select the host element by its **attribute selector** — the tag name written as an attribute in
+square brackets, e.g. `[ui5-button]` for `` — rather than by the tag name itself.
+This keeps selectors working when custom-element [scoping](https://ui5.github.io/webcomponents/docs/advanced/scoping/)
+adds a suffix to the tag name (e.g. ``).
+
+```js
+// ✅ attribute selector — robust against scoping
+get('[ui5-button]')
+
+// ❌ tag-name selector — breaks when scoping is enabled
+get('ui5-button')
+```
+
+Component internals live in the Shadow DOM. Real-browser runners can pierce it, so if a test
+genuinely needs an internal element you can query and assert on it — for example Cypress with
+`includeShadowDom: true`, or Playwright, whose locators pierce shadow roots by default. Prefer
+asserting on the component's public surface (props, attributes, emitted events) where you can;
+reach into the shadow tree only when there's no equivalent public signal, and expect such
+assertions to be more brittle since internal structure can change between versions.
+
+To identify which item fired a collection event (e.g. from `e.detail`), see
+[IDs via dataset](https://ui5.github.io/webcomponents-react/?path=/docs/knowledge-base-ids-via-dataset--docs).
+
+## Web components are asynchronous
+
+This is the pitfall behind most flaky UI5 tests. A UI5 Web Component is not fully usable the
+moment it appears in the DOM — it becomes ready in **two asynchronous stages**:
+
+1. **Custom element definition** — the browser has to register (define + upgrade) the custom
+ element before the host is anything more than an inert placeholder. This happens
+ asynchronously, especially when component modules are loaded on demand.
+2. **Internal rendering** — even after definition, the component renders its own Shadow DOM (and
+ sometimes nested sub-components) asynchronously. So the shadow parts, inner elements, and
+ final layout appear a tick _after_ the host exists.
+
+A test that queries an internal node, reads a computed style, or clicks a shadow element **too
+early** will intermittently fail — the target may not exist or may not be positioned yet.
+
+**Wait by asserting on the element, not with a fixed delay.** The reliable, framework-agnostic
+approach is to let the runner's retrying assertions wait for the actual state you care about —
+they keep re-checking until the async definition and rendering have completed:
+
+- Wait for the host to exist / be visible via its attribute selector (e.g. `[ui5-button]`).
+- For interactions with internals, assert on the specific target (a shadow part, an option, a
+ menu item) — prefer a visibility assertion over a mere existence check, since a node can be
+ present in the DOM before it is actually shown (see overlays below).
+- For overlays, wait on the state that indicates readiness (e.g. a popover's `open` attribute —
+ see the "Popovers, menus, and dialogs" section below).
+
+Avoid fixed timeouts (`wait(500)`) to "let the component settle" — they are both flaky and slow.
+Gate on a retrying assertion of the element or its state instead.
+
+## Abstract components render into their parent
+
+Some components are **abstract** (marked `@abstract`). Unlike a normal component, an abstract
+component does **not** render its own top-level shadow root — it renders into the DOM of its
+**parent**. Typical examples are slotted item components such as `SuggestionItem`, `Tab`,
+`ToolbarButton`, `WizardStep`, and `SideNavigationItem`.
+
+Abstract components are marked as such in their API documentation.
+
+This matters for testing because the host placeholder element you write in JSX is not the node
+that actually renders or carries the interaction handlers. To reach the real node, use the
+component's DOM utility methods:
+
+- **`getDomRef()`** — returns the DOM element that represents the component. For an abstract
+ component this is the **parent-owned** node that renders it, not a placeholder.
+- **`getFocusDomRef()`** — returns the element marked `data-sap-focus-ref` (the node that
+ receives focus by default).
+
+```js
+// For an abstract component, reach the rendered node via its instance handle,
+// by evaluating in the browser: element.getDomRef()
+rendered = get('[ui5-tab]').evaluate((el) => el.getDomRef())
+```
+
+For non-abstract components this is rarely necessary — the host element renders its own shadow
+DOM, and interacting with the host works directly (see the next section).
+
+## Popovers, menus, and dialogs: wait until open
+
+Overlay content — popovers, menus, dialogs, dropdowns — is only **visible and interactable while
+it is open**, and when open it is displayed in a top-layer/overlay container rather than inline in
+the page flow.
+
+Note that "in the DOM" and "interactable" are not the same thing here. Slotted content (for
+example a `Menu`'s `MenuItem`s) is authored by you as light-DOM children, so unless you render it
+conditionally it is **always** in the DOM — even while the overlay is closed. Querying for its
+existence therefore succeeds regardless of open state, yet the content is not visible or clickable
+until the overlay opens.
+
+Two consequences for tests:
+
+1. **Wait for the open state (or visibility), not just existence, before interacting.** Asserting
+ the overlay's `open` attribute — or that the target is _visible_ — waits for the state that
+ actually makes it interactable, whereas an existence check can pass while the item is still
+ hidden.
+
+ ```js
+ // open the menu, then wait for it to be open before interacting
+ click(opener)
+ expect('[ui5-menu]').toHaveAttribute('open')
+ click('[ui5-menu-item][text="New File"]')
+ ```
+
+2. **Prefer a real, coordinate-based click over a synthetic one.** A real click (what
+ browser-driving runners perform) hit-tests through the Shadow DOM and lands on the node that
+ actually carries the handler. A **synthetic** `element.click()` dispatched on the host element
+ fires on an ancestor of the handler node and may never reach it — so it can silently do
+ nothing. Let your runner perform its normal click on the element rather than calling
+ `element.click()` yourself in page-evaluated code.
+
+Because overlays open and animate asynchronously, always gate interactions on the open state (as
+above) rather than on fixed delays.
+
+
diff --git a/packages/mcp-server/scripts/utils/cem.ts b/packages/mcp-server/scripts/utils/cem.ts
index 1c883ee4b82..fe1aadfdd64 100644
--- a/packages/mcp-server/scripts/utils/cem.ts
+++ b/packages/mcp-server/scripts/utils/cem.ts
@@ -99,6 +99,10 @@ export function loadCemData(monorepoRoot: string): {
* - cssParts on component root
*/
export function enrichWithCem(apiData: ComponentApiData, cemDecl: CemDeclaration): void {
+ if (cemDecl._ui5abstract) {
+ apiData.isAbstract = true;
+ }
+
if (cemDecl.events) {
for (const event of cemDecl.events) {
if (event._ui5privacy && event._ui5privacy !== 'public') continue;
diff --git a/packages/mcp-server/src/tools/get_component_api/get_component_api.test.ts b/packages/mcp-server/src/tools/get_component_api/get_component_api.test.ts
index a4e6686c7be..804972ddaab 100644
--- a/packages/mcp-server/src/tools/get_component_api/get_component_api.test.ts
+++ b/packages/mcp-server/src/tools/get_component_api/get_component_api.test.ts
@@ -70,3 +70,15 @@ test('handler: Dialog has applyFocus method extracted from DomRef', (t) => {
const applyFocus = result.methods.find((m: { name: string }) => m.name === 'applyFocus');
t.truthy(applyFocus, 'Dialog should have applyFocus method');
});
+
+test('handler: abstract component is flagged with isAbstract', (t) => {
+ // Tab is marked @abstract (_ui5abstract in the CEM) -> isAbstract set in enrichWithCem
+ const result = getStructured(handler({ componentName: 'Tab' }));
+ t.true(result.isAbstract, 'Tab should be flagged isAbstract: true');
+});
+
+test('handler: non-abstract component does not set isAbstract', (t) => {
+ // Button is a regular self-rendering component -> flag should be absent/falsy
+ const result = getStructured(handler({ componentName: 'Button' }));
+ t.falsy(result.isAbstract, 'Button should not be flagged as abstract');
+});
diff --git a/packages/mcp-server/src/tools/get_component_api/get_component_api.ts b/packages/mcp-server/src/tools/get_component_api/get_component_api.ts
index 8e672ae43f6..f3f2827f080 100644
--- a/packages/mcp-server/src/tools/get_component_api/get_component_api.ts
+++ b/packages/mcp-server/src/tools/get_component_api/get_component_api.ts
@@ -112,6 +112,7 @@ EXAMPLE INPUT: { "componentName": "Dialog" }
name: string, // Use as: componentSelector::part(name) { ... }
description: string
}>,
+ isAbstract?: boolean, // true = abstract component: renders into its PARENT's DOM, not its own shadow root. For testing, target the node from getDomRef() (parent-owned), not the host placeholder. See the "Testing" knowledge-base section (get_documentation).
subTypeDocs?: string // Markdown docs for complex prop types (e.g. column definition properties)
docUrl?: string // Upstream docs link for complex behavioral concepts
}
@@ -137,6 +138,12 @@ EXAMPLE INPUT: { "componentName": "Dialog" }
.array(z.object({ name: z.string(), description: z.string() }))
.optional()
.describe('CSS ::part() selectors for shadow DOM styling'),
+ isAbstract: z
+ .boolean()
+ .optional()
+ .describe(
+ 'True for abstract components (marked @abstract): they render into a parent’s DOM, not their own shadow root. When interacting in a real browser (tests), target the node returned by getDomRef() rather than the host placeholder. See the "Testing" knowledge-base section.',
+ ),
subTypeDocs: z
.string()
.optional()
diff --git a/packages/mcp-server/src/tools/get_documentation/documentation_sections.json b/packages/mcp-server/src/tools/get_documentation/documentation_sections.json
index 5543b768a15..903e06d3f7c 100644
--- a/packages/mcp-server/src/tools/get_documentation/documentation_sections.json
+++ b/packages/mcp-server/src/tools/get_documentation/documentation_sections.json
@@ -203,6 +203,42 @@
],
"localPath": "docs--knowledge-base--Styling.mdx"
},
+ {
+ "name": "Testing",
+ "description": "Particularities of testing UI5 Web Components for React in a real browser (Cypress, Playwright, etc. — not JSDOM): setup, selectors, async readiness, abstract components, and overlays.",
+ "link": "https://ui5.github.io/webcomponents-react/v2/?path=/docs/knowledge-base-testing--docs",
+ "sourcePath": "docs/knowledge-base/Testing.mdx",
+ "content": [
+ "Real browser required (not JSDOM/virtual DOM)",
+ "Component-test setup: wrap in ThemeProvider (outermost), import Assets for i18n/theming",
+ "Setup applies to component testing, not E2E (app already provides it)",
+ "Attribute selectors [ui5-*] over tag names (scoping-safe)",
+ "Shadow DOM is pierceable (Cypress includeShadowDom, Playwright by default); prefer public surface",
+ "Web components are async: custom element definition + internal rendering",
+ "Wait via retrying assertions on the element/state, not fixed delays",
+ "Abstract components render into their parent (isAbstract flag)",
+ "getDomRef() / getFocusDomRef() for reaching rendered nodes",
+ "Popovers/menus/dialogs: wait for open/visible state (slotted items may exist in DOM while hidden)",
+ "Real coordinate-based click vs synthetic element.click()"
+ ],
+ "tags": [
+ "testing",
+ "cypress",
+ "playwright",
+ "e2e",
+ "component-testing",
+ "themeprovider",
+ "assets",
+ "i18n",
+ "async",
+ "flaky-tests",
+ "shadow-dom",
+ "abstract-components",
+ "getdomref",
+ "popovers"
+ ],
+ "localPath": "docs--knowledge-base--Testing.mdx"
+ },
{
"name": "Accessibility",
"description": "How to make UI5 Web Components applications accessible. Covers accessibility APIs (accessibleName, accessibleNameRef, accessibleDescription, accessibleRole, accessibilityAttributes), label-input relationships, invisible messaging, keyboard handling, and high contrast themes.",
diff --git a/packages/mcp-server/src/tools/get_documentation/get_documentation.test.ts b/packages/mcp-server/src/tools/get_documentation/get_documentation.test.ts
index 1fd8b05e397..ec659a81a11 100644
--- a/packages/mcp-server/src/tools/get_documentation/get_documentation.test.ts
+++ b/packages/mcp-server/src/tools/get_documentation/get_documentation.test.ts
@@ -54,3 +54,15 @@ test('handler: section + query together returns error', (t) => {
const result = getText(handler({ section: 'Getting Started', query: 'styling' }));
t.true(result.includes('either'));
});
+
+test('handler: search finds the Testing section', (t) => {
+ const result = getText(handler({ query: 'testing' }));
+ t.true(result.includes('Testing'));
+});
+
+test('handler: Testing section lookup with fetchContent loads bundled content', (t) => {
+ // docs/*.mdx files are gitignored (generated by bundle:docs)
+ const result = getText(handler({ section: 'Knowledge Base > Testing', fetchContent: true }));
+ t.true(result.includes('Testing'));
+ t.false(result.includes('bundled docs may need to be regenerated'));
+});
diff --git a/packages/mcp-server/src/types/cem.ts b/packages/mcp-server/src/types/cem.ts
index 730359ed27e..688610d4874 100644
--- a/packages/mcp-server/src/types/cem.ts
+++ b/packages/mcp-server/src/types/cem.ts
@@ -41,6 +41,8 @@ export interface CemDeclaration {
members?: unknown[];
slots?: unknown[];
superclass?: { name: string; package: string; module: string } | null;
+ /** True when the component is marked `@abstract` (renders into a parent's DOM, not its own shadow root). */
+ _ui5abstract?: boolean;
}
export interface CemModule {
diff --git a/packages/mcp-server/src/types/component-api.ts b/packages/mcp-server/src/types/component-api.ts
index ebbabb9d2c2..64b20c780c3 100644
--- a/packages/mcp-server/src/types/component-api.ts
+++ b/packages/mcp-server/src/types/component-api.ts
@@ -57,6 +57,12 @@ export interface ComponentApiData {
props: Record;
methods: MethodInfo[];
cssParts?: CssPart[];
+ /**
+ * True when the component is abstract (marked `@abstract`): it renders into a parent's DOM
+ * rather than its own shadow root. Present (and `true`) only for abstract components.
+ * See the "Testing" knowledge-base section for how this affects interacting with it.
+ */
+ isAbstract?: boolean;
/** Additional documentation for complex prop types (e.g. column definition shape). */
subTypeDocs?: string;
/** Link to upstream UI5 Web Components documentation for complex behavioral concepts. */