|
| 1 | +import { Footer, TableOfContent } from '@sb/components'; |
| 2 | +import { Meta } from '@storybook/addon-docs/blocks'; |
| 3 | + |
| 4 | +<Meta title="Testing" /> |
| 5 | + |
| 6 | +# Testing |
| 7 | + |
| 8 | +<TableOfContent /> |
| 9 | + |
| 10 | +This guide covers the particularities of testing UI5 Web Components for React — the things that |
| 11 | +behave differently from plain React components because these are custom elements with Shadow DOM. |
| 12 | +It is **not tied to a specific test runner**: the advice applies to any tool that drives a |
| 13 | +**real browser** (for example Cypress, Playwright, or WebdriverIO). |
| 14 | + |
| 15 | +> **Use a real browser, not JSDOM.** |
| 16 | +> UI5 Web Components are custom elements that rely on the real DOM — Shadow DOM, custom-element |
| 17 | +> upgrades, slots, and CSS. Virtual-DOM environments such as JSDOM (the default for Jest and |
| 18 | +> Vitest) do not implement these, so components will not render or behave correctly. Choose a |
| 19 | +> runner that executes in an actual browser. |
| 20 | +
|
| 21 | +## Component test setup |
| 22 | + |
| 23 | +When you mount a component in isolation (component testing), you have to provide the same runtime |
| 24 | +context the real app would. Two things are required: |
| 25 | + |
| 26 | +1. **Wrap the mounted component in `ThemeProvider`, as the outermost element.** Components read |
| 27 | + theming and configuration from it, so it must sit above whatever you render. |
| 28 | + |
| 29 | + ```tsx |
| 30 | + mount( |
| 31 | + <ThemeProvider> |
| 32 | + <YourComponentUnderTest /> |
| 33 | + </ThemeProvider>, |
| 34 | + ); |
| 35 | + ``` |
| 36 | + |
| 37 | + Set this up once in your runner's mount wrapper (e.g. a custom Cypress `mount` command, or |
| 38 | + Playwright CT's `beforeMount` hook) so every test gets it automatically. |
| 39 | + |
| 40 | +2. **Import the assets** once in your test setup if the test depends on translations (i18n), |
| 41 | + theming, or locale-specific (CLDR) formatting — otherwise components render with English text |
| 42 | + only and theme switching won't work: |
| 43 | + |
| 44 | + ```tsx |
| 45 | + import '@ui5/webcomponents-react/dist/Assets.js'; |
| 46 | + ``` |
| 47 | + |
| 48 | +> This setup applies to **component testing**. In **end-to-end** tests you drive the real |
| 49 | +> application, which already renders `ThemeProvider` and imports assets — so you don't add either |
| 50 | +> in the test itself. |
| 51 | +
|
| 52 | +## Selecting elements |
| 53 | + |
| 54 | +Select the host element by its **attribute selector** — the tag name written as an attribute in |
| 55 | +square brackets, e.g. `[ui5-button]` for `<ui5-button>` — rather than by the tag name itself. |
| 56 | +This keeps selectors working when custom-element [scoping](https://ui5.github.io/webcomponents/docs/advanced/scoping/) |
| 57 | +adds a suffix to the tag name (e.g. `<ui5-button-a1b2c3>`). |
| 58 | + |
| 59 | +```js |
| 60 | +// ✅ attribute selector — robust against scoping |
| 61 | +get('[ui5-button]') |
| 62 | + |
| 63 | +// ❌ tag-name selector — breaks when scoping is enabled |
| 64 | +get('ui5-button') |
| 65 | +``` |
| 66 | + |
| 67 | +Component internals live in the Shadow DOM. Real-browser runners can pierce it, so if a test |
| 68 | +genuinely needs an internal element you can query and assert on it — for example Cypress with |
| 69 | +`includeShadowDom: true`, or Playwright, whose locators pierce shadow roots by default. Prefer |
| 70 | +asserting on the component's public surface (props, attributes, emitted events) where you can; |
| 71 | +reach into the shadow tree only when there's no equivalent public signal, and expect such |
| 72 | +assertions to be more brittle since internal structure can change between versions. |
| 73 | + |
| 74 | +To identify which item fired a collection event (e.g. from `e.detail`), see |
| 75 | +[IDs via dataset](https://ui5.github.io/webcomponents-react/?path=/docs/knowledge-base-ids-via-dataset--docs). |
| 76 | + |
| 77 | +## Web components are asynchronous |
| 78 | + |
| 79 | +This is the pitfall behind most flaky UI5 tests. A UI5 Web Component is not fully usable the |
| 80 | +moment it appears in the DOM — it becomes ready in **two asynchronous stages**: |
| 81 | + |
| 82 | +1. **Custom element definition** — the browser has to register (define + upgrade) the custom |
| 83 | + element before the host is anything more than an inert placeholder. This happens |
| 84 | + asynchronously, especially when component modules are loaded on demand. |
| 85 | +2. **Internal rendering** — even after definition, the component renders its own Shadow DOM (and |
| 86 | + sometimes nested sub-components) asynchronously. So the shadow parts, inner elements, and |
| 87 | + final layout appear a tick _after_ the host exists. |
| 88 | + |
| 89 | +A test that queries an internal node, reads a computed style, or clicks a shadow element **too |
| 90 | +early** will intermittently fail — the target may not exist or may not be positioned yet. |
| 91 | + |
| 92 | +**Wait by asserting on the element, not with a fixed delay.** The reliable, framework-agnostic |
| 93 | +approach is to let the runner's retrying assertions wait for the actual state you care about — |
| 94 | +they keep re-checking until the async definition and rendering have completed: |
| 95 | + |
| 96 | +- Wait for the host to exist / be visible via its attribute selector (e.g. `[ui5-button]`). |
| 97 | +- For interactions with internals, assert on the specific target (a shadow part, an option, a |
| 98 | + menu item) — prefer a visibility assertion over a mere existence check, since a node can be |
| 99 | + present in the DOM before it is actually shown (see overlays below). |
| 100 | +- For overlays, wait on the state that indicates readiness (e.g. a popover's `open` attribute — |
| 101 | + see the "Popovers, menus, and dialogs" section below). |
| 102 | + |
| 103 | +Avoid fixed timeouts (`wait(500)`) to "let the component settle" — they are both flaky and slow. |
| 104 | +Gate on a retrying assertion of the element or its state instead. |
| 105 | + |
| 106 | +## Abstract components render into their parent |
| 107 | + |
| 108 | +Some components are **abstract** (marked `@abstract`). Unlike a normal component, an abstract |
| 109 | +component does **not** render its own top-level shadow root — it renders into the DOM of its |
| 110 | +**parent**. Typical examples are slotted item components such as `SuggestionItem`, `Tab`, |
| 111 | +`ToolbarButton`, `WizardStep`, and `SideNavigationItem`. |
| 112 | + |
| 113 | +Abstract components are marked as such in their API documentation. |
| 114 | + |
| 115 | +This matters for testing because the host placeholder element you write in JSX is not the node |
| 116 | +that actually renders or carries the interaction handlers. To reach the real node, use the |
| 117 | +component's DOM utility methods: |
| 118 | + |
| 119 | +- **`getDomRef()`** — returns the DOM element that represents the component. For an abstract |
| 120 | + component this is the **parent-owned** node that renders it, not a placeholder. |
| 121 | +- **`getFocusDomRef()`** — returns the element marked `data-sap-focus-ref` (the node that |
| 122 | + receives focus by default). |
| 123 | + |
| 124 | +```js |
| 125 | +// For an abstract component, reach the rendered node via its instance handle, |
| 126 | +// by evaluating in the browser: element.getDomRef() |
| 127 | +rendered = get('[ui5-tab]').evaluate((el) => el.getDomRef()) |
| 128 | +``` |
| 129 | + |
| 130 | +For non-abstract components this is rarely necessary — the host element renders its own shadow |
| 131 | +DOM, and interacting with the host works directly (see the next section). |
| 132 | + |
| 133 | +## Popovers, menus, and dialogs: wait until open |
| 134 | + |
| 135 | +Overlay content — popovers, menus, dialogs, dropdowns — is only **visible and interactable while |
| 136 | +it is open**, and when open it is displayed in a top-layer/overlay container rather than inline in |
| 137 | +the page flow. |
| 138 | + |
| 139 | +Note that "in the DOM" and "interactable" are not the same thing here. Slotted content (for |
| 140 | +example a `Menu`'s `MenuItem`s) is authored by you as light-DOM children, so unless you render it |
| 141 | +conditionally it is **always** in the DOM — even while the overlay is closed. Querying for its |
| 142 | +existence therefore succeeds regardless of open state, yet the content is not visible or clickable |
| 143 | +until the overlay opens. |
| 144 | + |
| 145 | +Two consequences for tests: |
| 146 | + |
| 147 | +1. **Wait for the open state (or visibility), not just existence, before interacting.** Asserting |
| 148 | + the overlay's `open` attribute — or that the target is _visible_ — waits for the state that |
| 149 | + actually makes it interactable, whereas an existence check can pass while the item is still |
| 150 | + hidden. |
| 151 | + |
| 152 | + ```js |
| 153 | + // open the menu, then wait for it to be open before interacting |
| 154 | + click(opener) |
| 155 | + expect('[ui5-menu]').toHaveAttribute('open') |
| 156 | + click('[ui5-menu-item][text="New File"]') |
| 157 | + ``` |
| 158 | + |
| 159 | +2. **Prefer a real, coordinate-based click over a synthetic one.** A real click (what |
| 160 | + browser-driving runners perform) hit-tests through the Shadow DOM and lands on the node that |
| 161 | + actually carries the handler. A **synthetic** `element.click()` dispatched on the host element |
| 162 | + fires on an ancestor of the handler node and may never reach it — so it can silently do |
| 163 | + nothing. Let your runner perform its normal click on the element rather than calling |
| 164 | + `element.click()` yourself in page-evaluated code. |
| 165 | + |
| 166 | +Because overlays open and animate asynchronously, always gate interactions on the open state (as |
| 167 | +above) rather than on fixed delays. |
| 168 | + |
| 169 | +<Footer /> |
0 commit comments