Guides

Testing

Ouro UI's tests install beside each component and run in your app, in real browsers, against your theme.tests-in-app They need Vitest 4.1.11 or later in browser mode, Playwright 1.58.1 or later, axe-core 4.13.0 or later and, for the stories and specs, Storybook 10.2.10 or later; no item sets a version, so your pins stay.minimums This page covers what the test kit needs, what to do on Vitest 4, and how to find Ouro UI's fields in your own tests.vitest-4-section

What the tests need

vitest, @vitest/browser-playwright
Needs: 4.1.11 or later, in browser modeminimums
5.0.3ci-versions
playwright, @playwright/test
Needs: 1.58.1 or later, with Chromium and WebKit installed (npx playwright install chromium webkit)minimums
1.63.0ci-versions
storybook, @storybook/react-vite
Needs: 10.2.10 or later, only for the stories and the specs in e2e/ourominimums
10.6.1ci-versions
axe-core
Needs: 4.13.0 or later: audit and the census throw on an older one, naming the version it foundaxe-minimum
4.13.0ci-axe

The -tests items list these as devDependencies without a version. The shadcn CLI installs a package only when your package.json has no entry for it, so an add never upgrades or downgrades a tool you already have.test-versions v0.7.0's items still carry versions: after add, restore every test-tool version you had pinned (git diff package.json).v0.7.0 Each item's meta.requires in the registry holds the same minimums for tools that read it.test-versions The CLI does not check them, so an older tool your app already has stays; audit is why axe-core's minimum matters, and it checks it each time it runs.axe-minimum

The tests do not run in jsdom or happy-dom. They drag, flick and measure, so they need a browser.

Install and run

npx shadcn@latest add ourostack/ouro-ui/dialog#v1.2.13 ourostack/ouro-ui/dialog-tests#v1.2.13

A <name>-tests item installs the component's tests, type tests, stories and spec, the tests of every component and helper that came with it, and ouro-testing, the test kit.tests-closure CI verifies 100% line and branch coverage of the installed runtime files for five sets: button, dialog, sheet, the forms set and their union.subsets The forms set is field, fieldset, text-field, text-area, checkbox, checkbox-group, radio-group, switch, form, select, native-select and combobox. Other items receive their full test dependency closure but are not measured individually. This closure guarantee does not establish execution coverage for every item alone.subsets ouro-tests adds every component with all of its tests.ouro-tests

Then add the config files the ouro-testing install message prints: vitest.config.ts, a Storybook and a Playwright config if your app has none, and e2e/ouro-fixtures.ts. The Vitest config is:

import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config.ts";
import { ouroTestProjects } from "./src/lib/ouro/testing/vitest.ts";

export default mergeConfig(viteConfig, defineConfig({ test: { projects: ouroTestProjects({ css: "./src/index.css" }) } }));

ouroTestProjects returns three projects: ouro-chromium, ouro-webkit and ouro-touch (Chromium as a 390x844 touch phone, for files named *.touch.test.tsx).projects Run them with npx vitest run, and the specs with npx playwright test e2e/ouro.

To measure coverage of the installed files, use the same settings as Ouro UI's repository:

coverage: {
  provider: "istanbul",
  include: ["src/components/ouro/**/*.{ts,tsx}", "src/lib/ouro/**/*.{ts,tsx}"],
  exclude: ["**/*.stories.tsx", "**/*.test.{ts,tsx}", "**/*.test-d.ts", "**/*.d.ts", "src/lib/ouro/testing/**"],
  thresholds: { lines: 100, branches: 100, functions: 100, statements: 100 },
},

What the test kit enforces

  • A test fails when it writes anything to the console: a React act() warning, a development message from Ouro UI or Base UI, a stray console.log. A test that expects a message mocks the method and asserts on the mock: vi.spyOn(console, "error").mockImplementation(() => {}). The rule applies to your own tests too if you run them in these projects.
  • In ouro-touch, a test that leaves the page zoomed fails.
  • The specs in e2e/ouro import test and expect from e2e/ouro-fixtures.ts. That file is yours and no item ships it, so no add overwrites it.fixtures-yours Export your own test from it to run the specs with your fixtures.

Vitest 4minimums

The tests are written for Vitest 5 and run on Vitest 4.1.11 or later with two changes.ci-versionsminimums An app that must stay on Vitest 4, for example for @cloudflare/vitest-pool-workers, makes them once.minimums

1. Pass page to ouroTestProjects:

ouroTestProjects({ css: "./src/index.css", page: { width: 1600, height: 1200 } })

Vitest 4 scales the test frame down when the Playwright host page is smaller than the viewport a test asks for, and the tests ask for up to 1280x844.touch-host A scaled frame moves native mouse and touch input off its targets and makes measured sizes fractional, so the drag, sheet, sizing and landscape touch tests fail without this. Choose a host page large enough for every requested viewport. Vitest 5 does not scale the frame and needs no page.touch-host

page sizes the host of all three projects.touch-host The touch test frame still starts at 390x844 and rotates to 844x390; hasTouch, mobile mode and the coarse pointer remain enabled.touch-host With the example above, the host's outer window and screen are 1600x1200 and report landscape orientation even while the test frame is portrait.touch-host The frame's layout/media-query viewport is the phone-sized one. These are native input tests in a logical phone frame, not measurements of a physical phone's screen, orientation or safe-area insets. Without page, the touch host still defaults to 390x844.touch-host

2. Use Playwright 1.64 or later, or skip two WebKit tests in sheet.test.tsx that compare a height to 100dvh exactly under Vitest 4's scaled frame: "sits at the bottom edge, fits its content and keeps clear of the top" and "covers the visible screen on a page scrolled 2000px".sheet-dvh-tests

Ouro UI's repository CI runs Vitest 5.ci-versions The page option also has a native-input consumer regression on Vitest 4.1.11 with Playwright 1.64: portrait, landscape and portrait restoration keep scale 1 and origin 0, preserve coarse touch input, and run the unchanged SwipeActions touch tests.vitest-4-regression This is not coverage of every tooling/browser combination.vitest-4-regression

Finding fields in tests

These lookups are the same in Playwright (page.getByRole) and in Vitest browser mode (page.getByRole from vitest/browser). test/finding-fields.test.tsx in Ouro UI's repository asserts each one.finding-fields

A labelled field
Use: getByRole("textbox", { name: "Email", exact: true })
The label names the input. exact keeps "Email" from also matching "Work email".
A required field
Use: The same: getByRole("textbox", { name: "Email", exact: true }), or getByLabel("Email", { exact: true })
The required marker is drawn with CSS beside the label, not in its text. Both lookups use "Email" without *.
A password field
Use: getByRole("textbox", { name: "Password", exact: true }), or getByLabel("Password", { exact: true })
Playwright and Vitest give a password input the textbox role. The "Show password" button is named by hidden text, not aria-label, so a loose label lookup also finds the input alone. Find the button by its role and name. In Testing Library a password input has no role: use getByLabelText("Password", { exact: true }), required or not.password-role
A field's error
Use: getByText("Enter an email address."), or assert on the field: toHaveAccessibleDescription("Enter an email address.") and toHaveAttribute("aria-invalid", "true")
The error is the field's description. It is not an alert, so getByRole("alert") does not find it.
The form's message
Use: getByRole("alert").filter({ hasText: "We could not save your changes." })
A Form renders an alert only while there is a message. An accepted new answer inserts a new alert with its text already present; a form without a message has no alert.form-alert Filtering by text checks the actual message rather than another alert on the page.

Other controls: a Select and a Combobox are getByRole("combobox", { name }); a Checkbox is getByRole("checkbox", { name }); a Switch is getByRole("switch", { name }); a RadioGroup is getByRole("radiogroup", { name }) with getByRole("radio", { name }) inside.

These lookups describe the current source, not v0.7.0's behavior.finding-fields In v0.7.0, required labels include * in their text, password buttons also match loose label lookups, and Form keeps an empty alert region.v0.8.0 When upgrading from that release, update those selectors and empty-alert assertions; the required field's accessible name stays the same.

Choosing an option in a Select

Open the list and choose with real pointer events: locator.click() in Playwright, userEvent.click() from vitest/browser. The options act on pointer events, so a synthetic fireEvent.click chooses nothing.

await page.getByRole("combobox", { name: "Course" }).click();
await page.getByRole("option", { name: "Main" }).click();

A Select with multiple posts one entry per chosen value under its name; read them with formData.getAll("tags").

Proving motion

lib/ouro/testing/motion.ts proves what a state change animates, one part at a time. Change the state, then call expectMotion before any other await: it reads the CSS transitions and animations running on the element, fails unless they are exactly the properties you name (no missing and no extra property, with durations in milliseconds when you give them), and waits for them to end. expectNoMotion proves a part does not move, a frame after the change. The element needs a data-ouro-part; Ouro UI's animated parts carry one (Checkbox's box and tick).

const token = (name: string) => parseDuration(getComputedStyle(document.documentElement).getPropertyValue(name));
await act(() => box.click());
await Promise.all([
  expectMotion(tick, { stem: "checkbox", state: "data-checked", properties: { "stroke-dashoffset": token("--ouro-duration-draw") }, reduced: false }),
  expectMotion(box, { stem: "checkbox", state: "data-checked", properties: ["background-color", "border-color"], reduced: false }),
]);

The part, or an element around it inside the part's own component ([data-ouro]), must carry the state's attribute when you call a helper (data-checked, or data-swiping=left for a value), so render outside the state and change into it; a checked row or an open overlay around the component does not count. A transition or CSS animation the browser cancels mid-way counts only when one takes its place on the same element (a transition of the same property, or a CSS animation of the same name) or 90% of its duration had passed.motion-cancel The replacement may still be running when the helper next reads the part, or, on a slow frame, may have begun and ended, uncancelled, in between: the helper listens for the part's transition and animation events while it waits. A replacement for another property or animation, or one that is itself cancelled, does not count. reduced must match the page's reduced-motion setting (commands.emulateMedia({ reducedMotion: "reduce" })). A DOM click() keeps a pointer's press scale out of the motion you measure. Each helper also records what it proved in the test's meta; Ouro UI's own CI holds every component's declared motion to those records, and in your app nothing reads them.motion-records

Proving announcements

lib/ouro/testing/announcements.ts records what live regions announce, so a test proves a screen reader hears a message once per change, in the right region. Start watchAnnouncements() before the change. It records, once per region for each batch of DOM changes, the text added to or changed inside a live region already on the page (role="status", role="alert", role="log" or aria-live, portals included), and the text an assertive region (role="alert" or aria-live="assertive") is added with.announcements-batch A polite region added with its text records nothing until that text changes, because screen readers generally don't announce a new polite region's first content; render the region empty and fill it, as Ouro's components do. Only exposed text counts: nothing under aria-hidden="true" or aria-live="off", nothing outside the region (text added beside it in the same subtree), and nothing from a render that leaves the text as it was.

const announcements = watchAnnouncements();
await userEvent.click(page.getByRole("button", { name: "Save" }));
await expect.poll(() => announcements.count("Saved")).toBe(1);
expect(announcements.matching("Saved")[0]!.region.getAttribute("role")).toBe("status");
announcements.stop();

changes lists every announcement in order, each with its region and text; matching(text) and count(text) find those whose text includes text.announcements Toast's tests use it.

Claim sources