Documentation menu

Testing

Gyral's loop is made of pure functions and data, so most of a component can be tested without a browser: feed messages to update, look at the state and the commands. What's left, rendering and the platform, you test in a real browser. @gyral/testing covers both, and works with any test runner.

npm install -D @gyral/testing vitest

The examples on this page test this component: a lookup that debounces typing, then asks a server.

// src/lookup.ts
import { define, html } from '@gyral/core';
import { get } from '@gyral/http';
import { debounce } from '@gyral/time';
import * as v from 'valibot';

const Result = v.object({ name: v.string() });

export interface State {
  readonly query: string;
  readonly found: string | undefined;
}

export type Msg =
  | { readonly _tag: 'Typed'; readonly query: string }
  | { readonly _tag: 'Search'; readonly query: string }
  | { readonly _tag: 'Found'; readonly query: string; readonly name: string }
  | { readonly _tag: 'Failed' };

export const Lookup = define<State, Msg>()('my-lookup', {
  init: () => ({ query: '', found: undefined }),
  intent: { Typed: ({ value }) => ({ _tag: 'Typed', query: value ?? '' }) },
  update: {
    Typed: (s, m) => [
      { ...s, query: m.query },
      [debounce<Msg>(300, { _tag: 'Search', query: m.query })],
    ],
    Search: (s, m) => [
      s,
      [
        get(`/api/lookup?q=${encodeURIComponent(m.query)}`, {
          schema: Result,
          key: 'lookup',
          concurrency: 'switch',
          onSuccess: (r): Msg => ({ _tag: 'Found', query: m.query, name: r.name }),
          onFailure: (): Msg => ({ _tag: 'Failed' }),
        }),
      ],
    ],
    // A late answer for an old query is ignored.
    Found: (s, m) => (m.query === s.query ? { ...s, found: m.name } : s),
    Failed: (s) => ({ ...s, found: undefined }),
  },
  view: (s, i) => html`
    <label for="q">Name</label>
    <input id="q" value=${s.query} data-intent=${i.Typed} />
    <output for="q">${s.found ?? ''}</output>
  `,
});

step() and run()

Lookup.spec is the object passed to define(). step(spec, state, msg) runs one message through update; run(spec, messages) folds several, starting from init. Both return the state and the commands, so a test never has to unpack [state, commands] itself.

// src/lookup.test.ts
import { expect, it } from 'vitest';
import { http } from '@gyral/http';
import { inputsFor, resolve, run, step } from '@gyral/testing';
import { Lookup } from './lookup.js';

it('asks the server for the typed name', () => {
  const searched = step(
    Lookup.spec,
    { query: 'ada', found: undefined },
    { _tag: 'Search', query: 'ada' },
  );
  expect(inputsFor(searched.commands, http).map((r) => r.url)).toEqual(['/api/lookup?q=ada']);

  // Answer the command through its own mapper, then feed the message back.
  const request = searched.commands[0];
  if (request === undefined) throw new Error('expected a request');
  const answer = resolve(request, { name: 'Ada Lovelace' });
  if (answer === undefined) throw new Error('expected a message');
  expect(step(Lookup.spec, searched.state, answer).state.found).toBe('Ada Lovelace');
});

it('ignores answers to an older query', () => {
  const { state } = run(Lookup.spec, [
    { _tag: 'Typed', query: 'ada' },
    { _tag: 'Typed', query: 'grace' },
    { _tag: 'Found', query: 'ada', name: 'Ada Lovelace' },
  ]);
  expect(state).toEqual({ query: 'grace', found: undefined });
});
  • inputsFor(commands, driver) lists the inputs of the commands for one driver, matched by name, so the test reads the request that would be sent.
  • outputsIn(commands, Component) lists what a reducer sent to the parent through outputs<Out>(), typed by the component's output union, and focusTargetsIn(commands) lists where it asked focus to go (below).
  • resolve(command, output) and reject(command, error) run a command's own mappers, so the test checks the exact message the component would receive.
  • Framework messages step like any other: { _tag: 'PropsChanged', props, prev }, IntentRejected, StoreChanged, Hydrated, Errored.
  • step(…, props, stores) and run(…, { props, stores, state }) give reducers their context.

These tests run in Node, in milliseconds, and cover most of the behaviour.

Outputs and focus requests are commands too, so a model test checks them the same way. Take a tag editor that tells its parent about each new tag and puts focus back in its input:

// src/tag-editor.ts
import { define, focus, html, outputs } from '@gyral/core';

export type TagEditorOutput = { readonly _tag: 'TagAdded'; readonly tag: string };

const emit = outputs<TagEditorOutput>();

export interface State {
  readonly tags: readonly string[];
}

export type Msg = { readonly _tag: 'Add'; readonly tag: string };

export const TagEditor = define<State, Msg, object, TagEditorOutput>()('my-tag-editor', {
  init: () => ({ tags: [] }),
  intent: { Add: ({ value }) => ({ _tag: 'Add', tag: value ?? '' }) },
  update: {
    Add: (s, m) => [
      { tags: [...s.tags, m.tag] },
      [emit({ _tag: 'TagAdded', tag: m.tag }), focus('input', { select: true })],
    ],
  },
  view: (s, i) => html`
    <label for="tag">New tag</label>
    <input id="tag" />
    <button type="button" value="urgent" data-intent=${i.Add}>Add “urgent”</button>
    <output for="tag">${s.tags.join(', ')}</output>
  `,
});
// src/tag-editor.test.ts
import { expect, it } from 'vitest';
import { focusTargetsIn, outputsIn, step } from '@gyral/testing';
import { TagEditor } from './tag-editor.js';

it('tells the parent about the new tag and keeps focus in the input', () => {
  const { commands } = step(TagEditor.spec, { tags: [] }, { _tag: 'Add', tag: 'urgent' });
  expect(outputsIn(commands, TagEditor)).toEqual([{ _tag: 'TagAdded', tag: 'urgent' }]);
  expect(focusTargetsIn(commands)).toEqual([{ selector: 'input', select: true }]);
});

Passing the class types the result by its outputs, so a test that expects an output the component can't send doesn't compile. Without a class, outputsIn<TagEditorOutput>(commands) names the union itself. Read outputs and focus requests through these helpers, not by filtering on driver names: those are Gyral's internals and may change.

Views in Node

A view is a pure function of state, so its markup can be tested in Node too. viewOf(Component, state, { props, stores }) calls the component's view with the named intents and context it would get on the page, without mounting it or a DOM shim. Render the result with renderToString from @gyral/ssr, passing { dev: false } so the HTML carries no development markers:

// src/lookup.view.test.ts
import { expect, it } from 'vitest';
import { renderToString } from '@gyral/ssr';
import { viewOf } from '@gyral/testing';
import { Lookup } from './lookup.js';

it('shows the name it found', async () => {
  const markup = await renderToString(viewOf(Lookup, { query: 'ada', found: 'Ada Lovelace' }), {
    dev: false,
  });
  expect(markup).toContain('<output for="q">Ada Lovelace</output>');
});

This tests what the view renders for a state, nothing more. Hooks, focus, native validity and hydration need the browser tests below.

Browser tests

For rendering, events and focus, test the real element in a real browser. Gyral's own tests use Vitest browser mode with Chromium; spread gyralVitePreset() into the browser project's config.

Gyral renders on a schedule: reducers run as soon as a message arrives, and the DOM updates in a microtask, once for every message that arrived together. await settled() from @gyral/core waits until every component on the page has rendered its latest state, including view transitions, focus commands and lazily loaded code, and until messages have stopped arriving. Await it before you look at the DOM; there's nothing to poll.

  • It waits for chains of messages: a driver that answers at once, a store that notifies in a microtask, a stream that re-arms in one, a reducer that answers with the next command. Each message restarts its quiet window, so you need no await Promise.resolve() loops before it.
  • It doesn't wait for commands that never end, such as a store watch or a socket, only for the messages they deliver.
  • It never waits for timers or the network. Answer fake drivers and advance virtual time first, then await settled().
  • On a page that loads its components automatically, await componentsReady() instead: it first waits until the loader has defined every rendered component, then settles.

Because it waits for the whole chain, a fake that answers at once runs to the end before settled() resolves. To see an in-between state such as "Loading…", use a fake that waits for the test (fakeDriver(name) without impl), assert, then answer it with resolveNext.

mount() from @gyral/testing does the setup: it creates the element, sets drivers, stores and props before the element connects, appends it, waits for its first render, and removes it after the test (with Vitest's globals on; otherwise call dispose()). Its $ and $$ query the shadow root, and type() and click() fire real events, then wait for settled().

// src/lookup.browser.test.ts
import { afterEach, expect, it } from 'vitest';
import { fakeHttp } from '@gyral/http/testing';
import { mount, virtualTime, type VirtualTime } from '@gyral/testing';
import { Lookup } from './lookup.js';

let time: VirtualTime | undefined;
afterEach(() => time?.restore());

it('debounces typing, then shows the answer', async () => {
  time = virtualTime();
  const http = fakeHttp(); // the real http driver over a fake fetch: schemas still decode
  const lookup = await mount(Lookup, { drivers: { http } });

  await lookup.type('input', 'ada');
  await time.advance(299);
  expect(http.requests).toHaveLength(0);
  await time.advance(1);
  expect(http.requests.map((r) => r.url)).toEqual(['/api/lookup?q=ada']);

  http.respondNext({ body: { name: 'Ada Lovelace' } });
  await time.advance(0);
  await lookup.settled();
  expect(lookup.$('output')?.textContent).toBe('Ada Lovelace');
});

What a parser decides

Some decisions happen during the event itself: whether a parser calls preventDefault(), or declines so an outer intent gets the key. A model test can't see them, but a browser test can, because parsers run while the event is dispatched. Dispatch a cancelable event and read defaultPrevented as soon as dispatchEvent() returns. Take a listbox that owns the arrow keys of its orientation prop:

// src/folder-list.ts
import { define, html, prop } from '@gyral/core';

export type Msg = { readonly _tag: 'Step'; readonly by: -1 | 1 };

export const FolderList = define<
  { readonly active: number },
  Msg,
  { readonly orientation: string }
>()('my-folder-list', {
  props: { orientation: prop.string({ default: 'vertical' }) },
  init: () => ({ active: 0 }),
  intent: {
    Step: ({ key, event }, { props }) => {
      const [back, next] =
        props.orientation === 'horizontal' ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
      const by = key === back ? -1 : key === next ? 1 : 0;
      if (by === 0) return undefined;
      event.preventDefault();
      return { _tag: 'Step', by };
    },
  },
  update: { Step: (s, m) => ({ active: Math.min(1, Math.max(0, s.active + m.by)) }) },
  view: (s, i, { props }) => html`
    <ul
      role="listbox"
      tabindex="0"
      aria-label="Folders"
      aria-orientation=${props.orientation}
      data-intent-keydown=${i.Step}
    >
      <li role="option" aria-selected=${s.active === 0}>Inbox</li>
      <li role="option" aria-selected=${s.active === 1}>Sent</li>
    </ul>
  `,
});
// src/folder-list.browser.test.ts
import { afterEach, expect, it } from 'vitest';
import { settled } from '@gyral/core';
import { FolderList } from './folder-list.js';

afterEach(() => {
  document.body.replaceChildren();
});

/** Presses a key on `target` and returns the event, to read what the parser decided. */
const press = (target: Element, key: string): KeyboardEvent => {
  const event = new KeyboardEvent('keydown', {
    key,
    bubbles: true,
    composed: true,
    cancelable: true,
  });
  target.dispatchEvent(event);
  return event;
};

it('takes only the arrow keys of its orientation', async () => {
  const el = new FolderList();
  el.orientation = 'horizontal';
  document.body.append(el);
  await settled();
  const list = el.shadowRoot?.querySelector('[role="listbox"]');
  if (list == null) throw new Error('missing listbox');

  expect(press(list, 'ArrowDown').defaultPrevented).toBe(false); // left to the page
  expect(press(list, 'ArrowRight').defaultPrevented).toBe(true);
  await settled();
  expect(el.state.active).toBe(1);
});

Props reach the parser as they are when the event fires, so setting el.orientation before the key press is enough. Setting a prop to a value equal to the current one changes nothing, though: no render and no PropsChanged. A test that expects either must set a different value.

For the parser's own result, without a DOM, parse(Component, intent, input, { props, state }) calls one parser with its ctx filled in and returns the message (or undefined when it declines):

// src/folder-list.node.test.ts
import { expect, it } from 'vitest';
import { parse } from '@gyral/testing';
import { FolderList } from './folder-list.js';

it('steps with the arrows of its orientation', () => {
  const event = new Event('keydown', { cancelable: true });
  const horizontal = { props: { orientation: 'horizontal' } };
  expect(parse(FolderList, 'Step', { key: 'ArrowRight', event }, horizontal)).toEqual({
    _tag: 'Step',
    by: 1,
  });
  expect(parse(FolderList, 'Step', { key: 'ArrowDown', event }, horizontal)).toBeUndefined();
});

Fake drivers and commands

Substitute drivers by name, as the app would (Effects):

  • el.drivers = { http } for one element.
  • provideDrivers(root, drivers) from @gyral/core for every component under a container, including nested components in shadow roots, as the app itself would at its entry. It returns a function that removes the overrides.
  • mount(Component, { drivers }) for the element a browser test mounts.
  • fakeDriver(driverOrName, { impl? }) records every call and waits for the test: resolveNext(output), rejectNext(error), emitNext(value) for streaming drivers, or calls[i].resolve(…). With impl, it answers at once. fakeDriver(name, run) is the short form: it answers every call with run and records the inputs.
  • fakeHttp() from @gyral/http/testing is the real HTTP driver over a controllable fetch, so response schemas, status errors and JSON parsing behave as in production. respondNext({ status, body }), reply(422, problem) and failNext() answer requests.

Drivers go in as they are: any driver, a fake included, fits el.drivers, mount and provideDrivers with no cast. A driver token (defineDriver<I, O, E>('name'), which has no implementation of its own) is substituted the same way, by its name. When you keep a map of drivers yourself, type it as DriverOverrides from @gyral/core. Record<string, Driver<unknown, unknown>> looks right but rejects every driver with a typed input.

// src/test-drivers.ts
import type { DriverOverrides } from '@gyral/core';
import { fakeHttp } from '@gyral/http/testing';
import { fakeDriver } from '@gyral/testing';

/** What the component copied to the clipboard, for the test to read. */
export const copied: string[] = [];

export const testDrivers: DriverOverrides = {
  http: fakeHttp(),
  clipboard: fakeDriver<string, undefined>('clipboard', (text) => void copied.push(text)),
};

Removing an element stops its commands one microtask later, so that a move (removed and inserted again in the same task) keeps them running. After el.remove(); await Promise.resolve();, every running command's signal.aborted is true, its abort listeners have run, and no later result is dispatched. Cleanup a driver runs after an await (a finally once its promise settles) happens later still; yield again (or await clock.advance(0) under virtualTime()) to observe it. To test a component that is attached again after a real removal, check its subscriptions for that state: subscriptionsFor(Component.spec, state, props) returns the commands it would start.

Virtual time

virtualTime() replaces timers, Date and requestAnimationFrame with a virtual clock. advance(ms) runs what's due and the promise work in between; runAll() runs every pending timer; restore() puts the real clock back. It patches the platform, not Gyral, so it covers debounces, every, driver timeouts and retry delays alike. Advance the clock, then await settled() before you assert on the DOM.

It also works in a Vitest node project, where there is no requestAnimationFrame to fake, so driver and polling tests that need no DOM can use it:

// test/poll.node.test.ts
import { afterEach, beforeEach, expect, it } from 'vitest';
import { virtualTime, type VirtualTime } from '@gyral/testing';

let time: VirtualTime;
beforeEach(() => {
  time = virtualTime();
});
afterEach(() => time.restore());

it('polls every 30 s', async () => {
  const seen: number[] = [];
  const id = setInterval(() => seen.push(Date.now()), 30_000);
  await time.advance(90_000);
  clearInterval(id);
  expect(seen).toHaveLength(3);
});

SSR and hydration tests

A hydration test mounts real server markup in the browser, imports the component modules the way a page load would, and checks that each component took over the server's DOM instead of rendering again. The browser can't produce that markup itself (there, define() registers elements rather than server specs), so it comes from Node, in one of two ways.

Server markup on demand

renderOnServer from @gyral/testing/vitest is a Vitest browser command: it runs in Vitest's Node process, loads your module through the project's Vite server and renders it with Gyral's server renderer. Register it in the browser project's config:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { gyralVitePreset } from '@gyral/core/vite';
import { renderOnServer } from '@gyral/testing/vitest';

export default defineConfig({
  ...gyralVitePreset(),
  test: {
    // Your browser setup (provider, instances) as usual, plus the command.
    browser: { commands: { renderOnServer } },
  },
});

Then a browser test asks for the markup, mounts it, imports the component and waits for it to hydrate:

// src/lookup-ssr.browser.test.ts
import { afterEach, describe, expect, it } from 'vitest';
import { commands } from 'vitest/browser';
import { provideDrivers, settled } from '@gyral/core';
import { fakeHttp } from '@gyral/http/testing';
import { hydrated, mountSsr, virtualTime, type MountedSsr, type VirtualTime } from '@gyral/testing';

let page: MountedSsr | undefined;
let time: VirtualTime | undefined;

afterEach(() => {
  time?.restore();
  page?.unmount();
});

// The first call loads the module graph on the server, which can take seconds in a busy run.
describe('the server-rendered lookup', { timeout: 60_000 }, () => {
  it('hydrates in place and answers typing', async () => {
    page = mountSsr(await commands.renderOnServer({ module: './lookup.ts', export: 'Lookup' }));
    const host = page.root.querySelector('my-lookup');
    const input = host?.shadowRoot?.querySelector('input');
    const http = fakeHttp();
    provideDrivers(page.root, { http });

    await import('./lookup.js'); // import after mounting, as a real page load would
    await hydrated(page);
    expect(host?.shadowRoot?.querySelector('input')).toBe(input); // the server's node, adopted

    time = virtualTime();
    if (input == null) throw new Error('missing input');
    input.value = 'ada';
    input.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
    await time.advance(300);
    http.respondNext({ body: { name: 'Ada Lovelace' } });
    await time.advance(0);
    await settled();
    expect(host?.shadowRoot?.querySelector('output')?.textContent).toBe('Ada Lovelace');
  });
});
  • module is relative to the test file. export names a define() class (rendered with props, which must be JSON), a function of props that returns a template, an HTML string or a Response (a whole page from renderPage, store seed included), or a template.
  • The server keeps modules loaded between calls, like a dev server, so pass what a render needs as props. Give the tests that make the first call a long timeout, as above.
  • If your tsconfig.json doesn't include vitest.config.ts, add import type {} from '@gyral/testing/vitest'; to the test for the command's types.

A golden fixture

When the markup needs your whole server (routing, data loading, the page shell), or when you want markup changes to show up in review, render the page in a Node test and keep the output as a file:

// src/lookup-page.node.test.ts
import { expect, it } from 'vitest';
import { html } from '@gyral/core';
import { renderPage } from '@gyral/ssr';
import './lookup.js';

it('renders the lookup page', async () => {
  const response = renderPage({
    title: 'Lookup',
    body: html`<main><my-lookup></my-lookup></main>`,
    scripts: ['/src/entry-client.ts'],
  });
  await expect(await response.text()).toMatchFileSnapshot('./fixtures/lookup-page.html');
});

vitest -u rewrites the file. The browser test imports it with ?raw:

// src/page.browser.test.ts
import { expect, it } from 'vitest';
import { hydrated, mountSsr } from '@gyral/testing';
import pageHtml from './fixtures/lookup-page.html?raw';

it('hydrates the server page in place', async () => {
  const page = mountSsr(pageHtml);
  const before = page.root.querySelector('my-lookup');
  await import('./lookup.js'); // import after mounting, as a real page load would
  await hydrated(page);
  expect(page.root.querySelector('my-lookup')).toBe(before); // same element: no re-render
  page.unmount();
});

mountSsr() and hydrated()

  • mountSsr(html) parses Declarative Shadow DOM, applies only the <head> styles, restores the store seed and <meta> tags, and starts recording console errors.
  • hydrated(page) waits until every component, including nested ones, has hydrated (it awaits settled()). It fails on a hydration mismatch, on any console error or warning since mounting, and on a server-rendered element whose module was never imported. Islands keep waiting unless you pass { releaseIslands: true }.
  • Render the markup with development output (Vitest does by default) and the browser also checks each template's id while hydrating. Run the hydration tests against a production build of your components too: production hydration recovers from a mismatch instead of throwing, so only a warning shows it.

Testing stores

stepStore(store, state, msg) runs a store reducer, store.instance(initial) gives a test its own instance (mount(Cart, { stores: { cart: instance } })), and inputsFor(commands, store) lists the messages a reducer sent to a store.

Coverage

Keep gyralVitePreset() in the tested project and name your sources in Vitest's coverage settings: test: { coverage: { provider: 'v8', include: ['src/**/*.ts'] } }. Install @vitest/coverage-v8 at the same version as Vitest.

In development and tests, the preset records where each html and svg template was written, so errors can name the file and line. That adds no branches to your coverage report: the counts are your own code's.

Accessibility checks for your app

Use a browser accessibility scanner alongside component behavior tests. An app-level helper can use Playwright and axe directly; @gyral/testing does not need to wrap their APIs:

// test/accessibility.ts
import assert from 'node:assert/strict';
import { AxeBuilder } from '@axe-core/playwright';
import type { Page } from 'playwright';

export async function expectAccessible(page: Page): Promise<void> {
  const result = await new AxeBuilder({ page }).analyze();
  assert.deepEqual(result.violations, []);
}

Install @axe-core/playwright and playwright as development dependencies. Navigate to a built page, wait for its application-ready condition, then call the helper. Scan meaningful states such as invalid forms and open dialogs in both color schemes. Axe traverses open shadow roots; keep those components mounted during the scan, and check that a rule actually ran on an element inside a shadow root, so a scan of the wrong root can't pass silently. See Accessibility for keyboard, focus and screen-reader checks that an automated scan cannot replace.