Skip to content

Testing a wizard

A flow is data and the engine is a plain object, so a wizard is testable without a renderer, without a DOM and without a helper library. There is no test build of the engine and no mock factory - the thing under test is the thing that ships.

Build the wizard the way the application does, then move it. The files below are examples/tasks/src/testing in the repository, and the test is one the repository runs: the application’s flow and registry, imported by a test file beside them.

testing/flow.ts
import { defineFlow, group, step } from '@wizzard-packages/core';
/** The flow under test: a trip that must be named before payment. */
export const booking = defineFlow({
id: 'booking',
order: ['trip', 'payment'],
steps: {
trip: step<{ name: string }>({ label: 'Trip', validate: { $ref: 'tripRules' } }),
payment: step({ label: 'Payment' }),
},
});
/** A flow with a group step, which needs the traversal from `core/groups` to run. */
export const withGroups = defineFlow({
id: 'with-groups',
order: ['travellers'],
steps: {
travellers: group({ label: 'Travellers', flow: 'traveller' }),
},
});
testing/registry.ts
import type { AsyncRegistry } from '@wizzard-packages/core';
/**
* The registry the application ships. Annotated as `AsyncRegistry`, so each
* resolver's `args` and `scope` are typed without writing them out.
*/
export const registry: AsyncRegistry = {
tripRules: (_args, scope) => {
const trip = scope.data['trip'] as { name?: string } | undefined;
return trip?.name ? null : { name: 'required' };
},
};
testing/booking.test.ts
import { createWizard, type Wizard } from '@wizzard-packages/core';
import { validateFlow } from '@wizzard-packages/core/validate-flow';
import { afterEach, describe, expect, it } from 'vitest';
import { booking, withGroups } from './flow';
import { registry } from './registry';
// One wizard per test that moves one, built the way the application builds
// it, and destroyed after: a plugin's teardown runs there, and no test
// inherits another's state.
let wizard: Wizard | undefined;
afterEach(() => {
wizard?.destroy();
wizard = undefined;
});
describe('booking', () => {
it('will not leave an incomplete step', async () => {
wizard = createWizard({ flow: booking, registry });
// What a binding does on mount. Without it the stack is empty, and the
// first `next()` would enter the flow rather than leave a step.
await wizard.start();
// A refusal is a value: compared, not caught.
expect(await wizard.next()).toMatchObject({ ok: false, reason: 'invalid', by: 'trip' });
expect(wizard.getSnapshot().current).toBe('trip');
wizard.set('trip.name', 'Lisbon');
expect(await wizard.next()).toMatchObject({ ok: true, to: 'payment' });
});
it('rejects a move that needs a resolver the registry does not hold', async () => {
// A mistake in the wiring throws. This one is found while moving, so the
// promise `next()` returns rejects.
wizard = createWizard({ flow: booking, registry: {} });
await wizard.start();
await expect(wizard.next()).rejects.toMatchObject({ code: 'resolver-not-registered' });
});
it('refuses to build a flow with a group and no traversal', () => {
// Found before there is a wizard at all, so the assertion wraps the construction.
expect(() => createWizard({ flow: withGroups })).toThrowError(/groups-not-installed/);
});
it('is a valid flow', () => {
// The definition checked without walking it: every `$ref` resolves, every
// transition names a step that exists.
expect(validateFlow(booking, registry)).toEqual([]);
});
});

start() is what a binding does on mount, and a headless test has to do it too: without it the stack is empty and the first next() is the move that enters the flow rather than one that leaves a step.

A resolver is an ordinary function, so a registry in a test is an object of ordinary functions. Nothing about it is a stub: the same shape goes to production.

Two kinds of outcome, and they are not the same assertion.

A refusal is a value, as the move above shows: next(), back() and go() resolve to a NavResult, and an expected refusal is compared rather than caught. reason says which kind it is, by names the step or the plugin that refused, and the wizard is still where it was.

A mistake in the flow throws instead, and where it throws depends on when it is found. A $ref the registry does not hold is found while moving, so the promise rejects - the second test above. A group step with no traversal installed is found before there is a wizard at all - createWizard throws on the spot - so the third test asserts around the construction instead.

API behaviour is the division in full: refusals are values, failures are exceptions.

For state, read getSnapshot(). It carries current, index, isLast, progress, breadcrumbs, errors and data together, so one read answers most assertions, and it is a new object only when something committed.

A flow that is never walked can still be wrong, and the check for that is not a test run: the last test above hands the definition and the registry to validateFlow and expects no problems. That catches a transition to a step that does not exist, a $ref no registry holds, a when that is not an expression - the class of mistake a type cannot see and a test only finds when it happens to walk that branch. Validation covers what it reports.

A component test needs no special treatment either: render it inside the provider with a flow and a registry of your own, and assert on what it renders. The engine does not know it is being tested, and the binding does not care where its wizard came from. The test below renders the Steps component from Hooks on both bindings; in Vue the provider goes in a parent the test mounts, because the component that provides cannot read what it provided.

testing/component.test.tsx
import { flushPromises, mount } from '@vue/test-utils';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { WizardProvider } from '@wizzard-packages/react';
import { provideWizard } from '@wizzard-packages/vue';
import { describe, expect, it } from 'vitest';
import { defineComponent, h } from 'vue';
import { trip } from '../hooks/flow';
import { registry } from '../hooks/registry';
import StepsVue from '../hooks/Steps.vue';
import { Steps } from '../hooks/Steps';
/**
* The component under test is `Steps` from the Hooks page, rendered inside a
* provider built for the test. The provider starts the engine on mount and
* destroys it on unmount, as it does in the application.
*/
describe('Steps, on React', () => {
it('shows the refusal and moves once the name is filled in', async () => {
render(
<WizardProvider flow={trip} registry={registry}>
<Steps />
</WizardProvider>
);
const input = await screen.findByLabelText(/Trip name/);
await act(async () => {
fireEvent.click(screen.getByText('Next'));
});
expect(screen.getByRole('alert').textContent).toBe('Give the trip a name.');
await act(async () => {
fireEvent.change(input, { target: { value: 'Lisbon' } });
fireEvent.click(screen.getByText('Next'));
});
expect(await screen.findByText('Ready to book Lisbon.')).toBeTruthy();
});
});
describe('Steps, on Vue', () => {
// `provideWizard` must run in a parent of the component that uses the
// composables, so the test mounts a parent of its own.
const Harness = defineComponent({
name: 'TestHarness',
setup() {
provideWizard({ flow: trip, registry });
return () => h(StepsVue);
},
});
it('shows the refusal and moves once the name is filled in', async () => {
const app = mount(Harness);
await flushPromises();
const next = app.findAll('button').find((b) => b.text() === 'Next');
await next?.trigger('click');
await flushPromises();
expect(app.find('[role="alert"]').text()).toBe('Give the trip a name.');
await app.find('input').setValue('Lisbon');
await next?.trigger('click');
await flushPromises();
expect(app.text()).toContain('Ready to book Lisbon.');
app.unmount();
});
});

Where the two bindings have to behave identically, that is the repository’s own contract suite - one set of flows driven through React and Vue - rather than something to reproduce in an application.