@gyral/testing
v0.3.6
Published
Gyral test helpers: pure update stepping, command helpers, fake drivers, virtual time, SSR page mounting, server rendering in Vitest browser tests.
Readme
@gyral/testing
Gyral test helpers: step pure updates, inspect commands, fake drivers, virtual time, mount server-rendered pages, and generate inputs from schemas. Works with any test runner; examples use Vitest.
Install
pnpm add -D @gyral/testingExample
import { expect, test } from 'vitest';
import { step } from '@gyral/testing';
import { Counter } from '../src/counter.js';
test('increment', () => {
expect(step(Counter.spec, { count: 1 }, { _tag: 'Increment' }).state).toEqual({ count: 2 });
});Commands are data: inputsFor(commands, driver) lists a driver's inputs,
outputsIn(commands, Component) the outputs sent to the parent (typed by the component's
output union), focusTargetsIn(commands) the focus() requests and callsIn(commands) the
call() requests.
viewOf(Component, state, { props, stores }) calls a component's pure view in Node with its
named intents and context; render the result
with renderToString(…, { dev: false }) from @gyral/ssr for stable HTML. Use the browser for
hooks, focus, validity and hydration.
Element tests run in a real browser (Vitest browser mode, not jsdom). mount() creates the
element with its drivers, stores and props set before it connects, waits for the first render,
and gives you shadow-root queries plus type() and click() that wait for the result:
import { mount, parse } from '@gyral/testing';
const search = await mount(Search, { drivers: { http: fakeHttp() } });
await search.type('input', 'gyral');
expect(search.$$('li')).toHaveLength(3);
search.dispose(); // automatic after each test when the runner has a global afterEach
// One parser, no DOM: the result is typed by the parser.
expect(parse(Search, 'Key', { key: 'Escape' }, { state: { open: true } })).toEqual({
_tag: 'Close',
});Without mount, append the element, dispatch events, then await settled() (from
@gyral/core) before you check the DOM: it
waits until every component has rendered and messages have stopped arriving (streams that
never end don't block it; timers are yours to advance). Swap
drivers for fakes with fakeDriver and core's provideDrivers; mountSsr and hydrated test
server-rendered pages through hydration (below).
import { defineDriver } from '@gyral/core';
import { provideDrivers } from '@gyral/core';
import { fakeDriver } from '@gyral/testing';
const urls: (string | null)[] = [];
el.drivers = {
'saved-query': defineDriver<undefined, string | null>({
name: 'saved-query',
run: () => 'gyral',
}),
// fakeDriver(name, run) answers every call with run and records the inputs.
'url-sync': fakeDriver<string | null, undefined>('url-sync', (query) => void urls.push(query)),
clipboard: fakeDriver<string, undefined, string>('clipboard'), // waits for resolveNext()
};
provideDrivers(container, { 'saved-query': fakeDriver('saved-query', () => null) }); // a subtreeDrivers need no cast in el.drivers or provideDrivers: any
Driver<I, O, E> (typed input, output and error, toError, a retry() wrapper, a subscription, a fake)
is assignable to AnyDriver. Type your own driver maps as DriverOverrides from
@gyral/core; Record<string, Driver<unknown, unknown>> rejects drivers with typed inputs.
Server-rendered pages: hydration tests
A hydration test mounts real server markup with mountSsr(html), imports the component modules
so they upgrade and hydrate in place, then await hydrated(page): it fails on a hydration
mismatch, on console errors or warnings, and on server-rendered elements that never upgraded.
The browser can't produce that markup itself (in the browser define() registers elements
instead of server specs, and client builds drop the server half of templates), so it comes
from Node in one of two ways.
On demand: renderOnServer (Vitest browser mode). @gyral/testing/vitest is a Vitest
browser command: it runs in Vitest's Node process,
loads your module through the project's Vite server (same plugins and preset, development
build of core) and renders it with Gyral's server renderer. Install vitest 4.1 or 5 (an optional peer)
and register it:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import { gyralVitePreset } from '@gyral/core/vite';
import { renderOnServer } from '@gyral/testing/vitest';
export default defineConfig({
...gyralVitePreset(),
test: {
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
commands: { renderOnServer },
},
},
});// test/counter-hydration.test.ts (runs in the browser)
import { expect, it } from 'vitest';
import { commands } from 'vitest/browser';
import { settled } from '@gyral/core';
import { hydrated, mountSsr } from '@gyral/testing';
it('hydrates the server markup in place and stays interactive', async () => {
const html = await commands.renderOnServer({
module: '../src/counter.ts', // relative to this test file
export: 'Counter', // a define() class, a function of props, or a template result
props: { start: 3 },
});
const page = mountSsr(html);
const host = page.root.querySelector('my-counter');
const button = host?.shadowRoot?.querySelector('button');
await import('../src/counter.js'); // after mounting, as on a page load
await hydrated(page);
expect(host?.shadowRoot?.querySelector('button')).toBe(button); // same node: adopted
button?.click();
await settled();
expect(host?.shadowRoot?.querySelector('output')?.textContent).toBe('4');
page.unmount();
});export(default'default') names adefine()class (its element is rendered withpropsset as properties), a function (called withprops; it may return or resolve to a template result, an HTML string or aResponse, e.g. from@gyral/ssr'srenderPageor your app'sfetchhandler, so a whole page with its store seed works too) or a template result.propstravel from the browser, so they must be JSON.dev: falserenders production markup (default: development, as Vitest resolves core).- The server keeps modules loaded between calls, like a dev server: module-level state carries
over, so pass what a render needs through
props. The first call loads the module graph (core's server renderer included), which can take seconds in a busy parallel run: give those tests a longer timeout (describe('…', { timeout: 60_000 }, …)). - Types: the command's types come with
@gyral/testing/vitest. If your tsconfig doesn't includevitest.config.ts, addimport type {} from '@gyral/testing/vitest';to the test.
Golden fixture. A Node test (*.node.test.ts) calls your real server and writes the HTML
with await expect(html).toMatchFileSnapshot('./fixtures/about.ssr.html'); the browser test
imports it (import serverHtml from './fixtures/about.ssr.html?raw') and mounts it. Regenerate
with vitest -u and review the diff.
| Use renderOnServer when… | Use a golden fixture when… |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| testing a component or a page you can render from one module | the markup comes from the whole server (routing, data loading, headers, a built server) |
| each test wants different props or state | you want markup changes reviewed in diffs, or a page shared by many tests |
| you want no files to keep in sync | tests run outside Vitest browser mode, or the server needs services Vite can't load |
Both feed the same mountSsr / hydrated assertions; run hydration tests against production
builds of core too (a mismatch there warns and re-renders instead of throwing).
Documentation
Guides and API reference: gyral.dev. Source, issues and the consumer setup guide (packages, Vite preset, server rendering): github.com/gyraljs/gyral.
Status: 0.x, on npm. Breaking changes to the public API come only in a new minor version, with a migration guide.
License
MIT © Mike Zupper. See LICENSE and NOTICE (Cycle.js attribution). Gyral, gyraljs and the Gyral logo are trademarks of Mike Zupper.
