Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

Testing

@uniaria/testing is how UniAria checks that a component behaves the same in every adapter. A component’s behavior is described once, as a conformance suite of scenarios that mount it, drive it with real mouse, keyboard and touch input, and check the DOM, focus, events, state and what a screen reader says. Each adapter implements one mount function, and every suite runs against it in Chromium, Firefox and WebKit.

Use it to:

  • Write an adapter for another framework, and prove it with the same suites the React and custom-element adapters pass.
  • Test your own components: write suites for your definitions, or use the real-input and spoken-output helpers in ordinary browser tests.

It is a development dependency. To test a definition’s logic without a browser, you don’t need it: drive an instance with createInstance in Node.

Entries

Entry Runs in Exports
@uniaria/testing Anywhere, Node included defineConformance; the templates h, nested, rows and VIRTUALIZER; renderTemplate, templateParts, partSelector, partName; the contract types
@uniaria/testing/browser Vitest browser mode runConformance; pointer, keyboard, touch, releaseInput, isTouchSupported; emulateMedia; spokenPhrases
@uniaria/testing/commands Node, in your Vitest config uniCommands, screenReaderCommands

It needs Vitest 5 (a peer dependency) with browser mode and its Playwright provider: real input goes through Playwright, never through synthetic dispatchEvent calls, so scenarios see the browser’s own event sequences. Spoken output comes from @guidepup/virtual-screen-reader, a dependency. Real screen readers need @guidepup/guidepup, an optional peer.

Writing a suite

defineConformance<Props, State>(
  component: string,
  define: (builder: { scenario; group }) => void,
): ConformanceSuite<Props, State>

A suite is data: the component’s definition name and a tree of scenarios. It names the component by string and never imports a definition or an adapter, so it can be written from a spec before the implementation exists.

import { defineConformance, h } from '@uniaria/testing';
import { expect } from 'vitest';

import type { DisclosureProps, DisclosureState } from './types';

export const disclosureConformance = defineConformance<
  DisclosureProps,
  DisclosureState
>('disclosure', ({ scenario, group }) => {
  const markup = [
    h('button', { part: 'trigger' }, 'Shipping'),
    h('div', { part: 'panel' }, 'Ships in 2 days'),
  ];

  group('keyboard', () => {
    scenario('Enter expands and collapses', async ({ mount, keyboard }) => {
      const mounted = await mount({
        template: markup,
        events: ['expandedChange'],
      });
      mounted.part('trigger').focus();

      await keyboard.press('Enter');

      expect(mounted.part('trigger').getAttribute('aria-expanded')).toBe(
        'true',
      );
      expect(mounted.part('panel').hidden).toBe(false);
      expect(mounted.state().isExpanded).toBe(true);
      expect(mounted.events).toEqual([
        { name: 'expandedChange', detail: true },
      ]);
    });
  });

  scenario('a press renders nothing', async ({ mount, pointer }) => {
    const mounted = await mount({ template: markup });
    const renders = mounted.renderCount();
    await pointer.click(mounted.part('trigger'));
    expect(mounted.state().isExpanded).toBe(true);
    expect(mounted.renderCount()).toBe(renders);
  });
});
  • scenario(name, run, options?) adds a scenario; group(name, define) nests scenarios under a name.
  • options.phase (default 1) tags the roadmap phase whose features a scenario needs. A run declares the phase its adapter has reached, and later scenarios are skipped, not failed.
  • options.needs lists input beyond mouse and keyboard: 'touch' (only Chromium takes it; skipped elsewhere) or 'screenReader' (runs only in a real-screen-reader run).
  • Event names are camelCase, as in the definition (expandedChange); props are the definition’s (defaultExpanded, isDisabled).

Templates

A template is the consumer’s markup, described without a framework. Each adapter renders it its own way: React spreads part props onto the elements, custom elements wrap them in the host.

Helper Description
h(tag, attrs?, ...children) An element. part in attrs names the UniAria part; every other key is an attribute the consumer owns, with its DOM name (class, for, aria-label). Children are nodes or text.
nested(component, props?, ...children) Another component inside, such as a checkbox in its group. React renders it with its own useBehavior() inside its owner’s OwnerProvider; custom elements wrap its children in <uni-checkbox>. Parts in its children are its own.
rows(render) Rows rendered from the component’s renderedKeys and renderedSections, as a virtualized list’s consumer renders them: rows(({ keys }) => keys.map((key) => h('div', { part: 'option', 'data-key': key }, key))).
VIRTUALIZER Stands for virtualizer from @uniaria/core/virtual in a scenario’s props; each adapter passes the real one.

The scenario context

Each scenario receives:

Member Description
mount(options) Mounts the suite’s component through the adapter under test: template, props, and the events to record. Returns a Mounted.
pointer Real mouse input at an element’s center: down, up, move, moveOutside (24 px below), click, wheel. It can hold a button while moving.
keyboard Real keys to the focused element: down, up, press, and tab({ isBackward }). Key names follow Playwright: Enter, Space, ArrowDown.
touch One finger: down, move, moveOutside, up, tap. Only in scenarios that declare needs: ['touch'].
spoken() Reads the whole document with the virtual screen reader and returns every phrase, from document to end of document.
listen() Starts hearing live regions. The function it returns gives what they announced since, each phrase prefixed with its politeness: assertive: Saved.
screenReader A real screen reader. Only in scenarios that declare needs: ['screenReader']. See real screen readers.

Mounted is what a scenario asserts on:

Member Description
container The element the adapter rendered into.
part(name = 'root') The element of a part of the mounted component, by partSelector(): the root by data-uni, a part outside the root by data-uni-part, any other part by data-element inside the root. Throws unless exactly one matches, so a missing marker fails loudly.
parts(name) Every element of a part, for item parts.
events, eventNames(), clearEvents() The recorded events, in order: { name, detail }.
state() What the definition’s expose() returns now.
setProps(props) Merges new props, the way the adapter’s consumers pass them; resolves once the DOM shows them.
call(action) Calls one of the component’s public actions, such as 'open', the way the adapter’s consumers do; resolves once the DOM shows the result.
renderCount() How many times the framework rendered the owning component. 0 for custom elements.

The runner cleans up after every scenario: it releases held buttons, keys and fingers, unmounts, removes the container, blurs the focused element and empties live regions.

The mount contract

An adapter implements one function. The runner owns everything else: containers, part lookup, the event log, input, spoken output, cleanup and skipping.

interface ConformanceAdapter {
  readonly name: string; // 'react', 'elements'; appears in test names
  mount(request: MountRequest): Promise<AdapterMount>;
}
MountRequest Description
component The definition name: 'checkbox-group'. The adapter maps it to its definition or element.
props The starting props, to apply the way the adapter’s consumers do (React props, element properties).
template The consumer’s markup, as template nodes.
events The component’s events to observe, camelCase.
record(event, detail) Call it whenever one of events fires: the React callback’s argument, or the DOM event’s detail. Record only this component’s own events, not a nested component’s that bubble up.
container An empty element attached to document.body. Render into it.
AdapterMount Description
setProps(props) Merges props as consumers update them. Resolves once the DOM reflects them.
call(action) Calls a public action the way consumers do: the React handle’s actions, the element’s method. Resolves once the DOM reflects it.
state() The object expose() returns now: instance.getState().
renderCount() Framework renders of the component that owns the instance, the first included; 0 without renders.
unmount() Removes everything rendered and stops the instance.

Attribute changes must be visible as soon as the interaction that caused them resolves, since scenarios assert right after an awaited input. The shared binder writes synchronously after each flush, so an adapter built on it gets this for free.

renderTemplate(nodes, document, component, options?) turns a template of component into real DOM, for adapters that render plain markup. It marks the parts as you would for the custom elements: the root with data-uni, a part outside the root with data-uni-part, the others with data-element. Its options: outsideParts(component) names a component’s parts outside its root (its definition’s outsideParts), host creates the element for a nested component, rowsIn places rows(), and decorate adds attributes to part elements. templateParts(nodes) lists a template’s own part elements. partSelector(component, part) is a part’s CSS selector: inside the component’s root, minus nested roots, or anywhere no root is around it, as in a component rendered without its root. partName(element, component) is the part an element is, if any. isNested and isRows tell node kinds apart.

A minimal adapter

The adapters UniAria tests itself with aren’t exported. To run suites for your own components, implement mount for them. This one runs any suite whose template has no nested components or rows, through custom elements registered with an acme prefix:

import { domEventName } from '@uniaria/core';
import type { AnyDefinition } from '@uniaria/core';
import type { UniElement } from '@uniaria/elements';
import { renderTemplate } from '@uniaria/testing';
import type { ConformanceAdapter } from '@uniaria/testing';

import './elements'; // defines <acme-disclosure> and the rest

export const acmeElements: ConformanceAdapter = {
  name: 'acme-elements',
  async mount({ component, props, template, events, record, container }) {
    const host = document.createElement(
      `acme-${component}`,
    ) as UniElement<AnyDefinition>;
    Object.assign(host, props);
    for (const name of events) {
      host.addEventListener(domEventName(name), (event) => {
        // Events bubble: a nested component's aren't this one's.
        if (event.target === host) record(name, (event as CustomEvent).detail);
      });
    }
    const { outsideParts = [] } = host.instance.definition;
    host.append(
      renderTemplate(template, document, component, {
        outsideParts: () => outsideParts,
      }),
    );
    container.append(host);
    return {
      async setProps(next) {
        Object.assign(host, next);
      },
      async call(action) {
        // The element's own method, as consumers call it.
        (host as unknown as Record<string, () => void>)[action]!();
      },
      state: () => host.instance.getState() as Record<string, unknown>,
      renderCount: () => 0,
      async unmount() {
        container.replaceChildren();
      },
    };
  },
};

Running suites

Register the input commands in a Vitest browser project, then run the suites against your adapter from a *.browser.test.ts file:

// vitest.config.ts
import { uniCommands } from '@uniaria/testing/commands';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      headless: true,
      provider: playwright(),
      commands: uniCommands,
      instances: [
        { browser: 'chromium' },
        { browser: 'firefox' },
        { browser: 'webkit' },
      ],
    },
  },
});
// conformance.browser.test.ts
import { runConformance } from '@uniaria/testing/browser';

import { myAdapter } from './adapter';
import { disclosureConformance } from './disclosure.conformance';

runConformance(myAdapter, [disclosureConformance], { phase: 1 });

runConformance(adapter, suites, options?) registers one describe per suite (disclosure (my-adapter)) and one test per scenario.

Option Default Description
phase every phase The highest roadmap phase the adapter implements. Later scenarios are skipped.
screenReader false Runs only the scenarios that need a real screen reader, with the machine’s screen reader started for the file. Without it they aren’t registered.

UniAria’s own suites, one per component, live in the repository’s conformance folder. They aren’t published yet, so run them against a new adapter from a checkout of the repository; they are how the React and custom-element adapters are judged, and they are changed only with the author’s approval. See Testing in Contributing.

Input in your own tests

pointer, keyboard and touch work in any browser test, not only in scenarios, as long as uniCommands are registered. Call releaseInput() after each test so a held button or key doesn’t leak into the next. isTouchSupported() resolves to whether the browser takes touch input (Chromium only).

import { keyboard, pointer, releaseInput } from '@uniaria/testing/browser';
import { afterEach, expect, it } from 'vitest';

afterEach(releaseInput);

it('keeps the press while the pointer comes back', async () => {
  const button = document.querySelector('button')!;
  await pointer.down(button);
  await pointer.moveOutside(button);
  await pointer.move(button);
  expect(button.hasAttribute('data-pressed')).toBe(true);
  await pointer.up();
});

Media features

emulateMedia() sets media features for the whole page through Playwright, so a test can check forced colors (Chromium only), prefers-contrast: more, reduced motion or a color scheme. It needs uniCommands registered, and resolves once the page has restyled, so computed styles already follow. The page outlives a test, so end what you set: null ends a feature’s emulation.

import { emulateMedia } from '@uniaria/testing/browser';
import { afterEach, expect, it } from 'vitest';

afterEach(() => emulateMedia({ forcedColors: null }));

it('keeps the focus ring in forced colors', async () => {
  await emulateMedia({ forcedColors: 'active' });
  expect(matchMedia('(forced-colors: active)').matches).toBe(true);
});

Spoken output

Every scenario can check what a screen reader would say, with the virtual screen reader that runs on every push. It reads the accessibility tree the browser exposes, so phrases follow its format: button, Save, disabled.

  • spoken() in a scenario, or spokenPhrases(container?) from @uniaria/testing/browser in any browser test, reads from the start of the container (the body by default) to its end and returns every phrase.
  • listen() hears live regions, such as the announcer’s: a region speaks only when its content changes after listening starts. Listening lasts until the scenario ends, and spoken() can’t run meanwhile.

From NumberField’s suite, where a step announces the new value:

scenario(
  'a step announces the new text',
  async ({ mount, keyboard, listen }) => {
    const mounted = await mount({
      template: h(
        'div',
        { part: 'root' },
        h('label', { part: 'label' }, 'Quantity'),
        h('input', { part: 'input' }),
      ),
      props: { defaultValue: 0 },
    });
    mounted.part('input').focus();
    const heard = await listen();
    await keyboard.press('ArrowDown');
    await expect.poll(heard).toEqual(['assertive: −1']);
  },
);

Real screen readers

Scenarios that declare needs: ['screenReader'] run against VoiceOver on macOS or NVDA on Windows, in headed Chromium, through Guidepup. Keys go through the operating system, so the screen reader hears them as a user’s. Running them takes over the machine’s screen reader and keyboard, so UniAria runs them nightly in CI, not locally.

screenReader in the scenario context has:

Member Description
name 'voiceover' or 'nvda'.
startFrom(element) Clicks element (a text field before the component), waits for the speech, and forgets it: start from there with Tab.
press(key) Presses a key through the screen reader and waits until it stops speaking.
type(text) Types text, a key per character, and waits after each until the screen reader stops speaking.
next() Moves the screen reader’s own cursor to the next item.
readFocus() What the screen reader holds for the focused element, a check that holds when the speech on arrival is cut short.
spoken() What it said since startFrom or the last call, each key’s phrases joined with /.

spoken() holds everything a key made the screen reader say, a live region read after the key’s own speech included. VoiceOver runs with its hints (“Speak instructions for using the item”) off, so what it says is the page, not how to use VoiceOver.

The screen readers word things differently (check box in NVDA, checkbox in VoiceOver), so match what you need to hear, not whole phrases:

scenario(
  'Tab reads the trigger by name, role and state',
  async ({ mount, screenReader }) => {
    const mounted = await mount({
      template: [
        h('input', { id: 'before', 'aria-label': 'Before' }),
        ...markup,
      ],
    });
    await screenReader.startFrom(
      mounted.container.querySelector<HTMLElement>('#before')!,
    );
    await screenReader.press('Tab');
    expect(await screenReader.spoken()).toMatch(/Shipping.*collapsed/);
  },
  { needs: ['screenReader'] },
);

To run them, register screenReaderCommands beside uniCommands in a separate project that runs headed Chromium one file at a time, on a machine prepared with @guidepup/setup, and call runConformance(adapter, suites, { screenReader: true }) from its test file.