xstate-audition

xstate-audition

Harnesses for testing XState v5 Actors. Actor test...audition...get it??

xstate-audition is a dependency-free library for testing the behavior of XState Actors.

API Documentation | GitHub | npm



TL;DR:

  1. Create an Actor using xstate.createActor(logic).
  2. Create a Promise<T> using one of the functions below (e.g., runUntilDone(actor: Actor) => Promise<T> where T is the Actor output). If the actor hadn't yet been started, it will be started now.
  3. If your actor needs external input to resolve the condition (e.g., it must receive an event), perform that operation before you await the Promise<T> (examples below).
  4. Now, you can await the Promise<T> from step 2.
  5. Finally, make an assertion about T.

Run a Promise Actor or State Machine to Completion

runUntilDone(actor) / runUntilDoneWith(actor, options) are curried functions that will start a Promise Actor or State Machine Actor and run it until it reaches a final state. Once the Actor reaches a final state, it will immediately be stopped. The Promise will be resolved with the output of the Actor.

[!NOTE]

  • runUntilDone() is not significantly different than XState's toPromise().
  • runUntilDoneWith() may be used to overwrite the internal logger and/or add an inspector callback (or Observer) to an Actor.
  • There is no such waitForDone(...) / waitForDoneWith(...) variant, since that would be silly.
import {strict as assert} from 'node:assert';
import {beforeEach, describe, it} from 'node:test';
import {type Actor, createActor, fromPromise} from 'xstate';

import {runUntilDone, runUntilDoneWith} from 'xstate-audition';

const promiseLogic = fromPromise<string, string>(
// this signal is aborted via call to Actor.stop()
async ({input, signal}) => {
let listener!: () => void;
try {
return await new Promise((resolve, reject) => {
listener = () => {
clearTimeout(timeout);
// this rejection is eaten by xstate-audition
// in lieu of its own timeout error (seen below)
reject(signal.reason);
};

const timeout = setTimeout(() => {
resolve(`hello ${input}`);
}, 500);

signal.addEventListener('abort', listener);
});
} finally {
signal.removeEventListener('abort', listener);
}
},
);

describe('logic', () => {
let actor: Actor<typeof promiseLogic>;

beforeEach(() => {
actor = createActor(promiseLogic, {input: 'world'});
});

it('should output with the expected value', async () => {
const result = await runUntilDone(actor);

assert.equal(result, 'hello world');
});

it('should abort when provided a too-short timeout', async () => {
await assert.rejects(
runUntilDoneWith(actor, {timeout: 100}),
(err: Error) => {
assert.equal(err.message, 'Actor did not complete in 100ms');

return true;
},
);
});
});

Run a State Machine Until It Emits Events

runUntilEmitted(actor, eventTypes) / runUntilEmittedWith(actor, options, eventTypes) are curried function that will start an Actor and run it until emits one or more events of the specified type. Once the events have been emitted, the actor will immediately be stopped.

waitForEmitted(actor, eventTypes) / waitForEmittedWith(actor, options, eventTypes) are similar, but do not stop the actor.

[!NOTE]

This function only applies to events emitted via the event emitter API.

import {strict as assert} from 'node:assert';
import {beforeEach, describe, it} from 'node:test';
import {type Actor, createActor, emit, setup} from 'xstate';

import {type CurryEmittedP1, runUntilEmitted} from 'xstate-audition';

type Emit1 = {type: 'EMIT1'; value: string};

type Emit2 = {type: 'EMIT2'; value: number};

type EmitterEmitted = Emit1 | Emit2;

const emitterMachine = setup({
types: {
emitted: {} as EmitterEmitted,
},
}).createMachine({
entry: [
emit({type: 'EMIT1', value: 'value'}),
emit({type: 'EMIT2', value: 42}),
],
});

describe('emitterMachine', () => {
let actor: Actor<typeof emitterMachine>;

let runUntilEmit: CurryEmittedP1<typeof actor>;

beforeEach(() => {
actor = createActor(emitterMachine);

// runUntilEmitted is curried, so could be called with [actor, ['EMIT1', 'EMIT2']]
// instead
runUntilEmit = runUntilEmitted(actor);
});

it('should emit two events', async () => {
const [emit1Event, emit2Event] = await runUntilEmit(['EMIT1', 'EMIT2']);

assert.deepEqual(emit1Event, {type: 'EMIT1', value: 'value'});
assert.deepEqual(emit2Event, {type: 'EMIT2', value: 42});
});
});

Run a State Machine Until It Transitions from One State to Another

runUntilTransition(actor, fromStateId, toStateId) / runUntilTransitionWith(actor, options, fromStateId, toStateId) are curried functions that will start an Actor and run it until it transitions from state with ID fromStateId to state with ID toStateId. Once the Actor transitions to the specified state, it will immediately be stopped.

waitForTransition(actor, fromStateId, toStateId) / waitForStateWith(actor, options, fromStateId, toStateId) are similar, but do not stop the Actor.

import {strict as assert} from 'node:assert';
import {beforeEach, describe, it} from 'node:test';
import {type Actor, createActor, createMachine} from 'xstate';

import {type CurryTransitionP2, runUntilTransition} from '../src/index.js';

const transitionMachine = createMachine({
// if you do not supply a default ID, then the ID will be `(machine)`
id: 'transitionMachine',
initial: 'first',
states: {
first: {
after: {
100: 'second',
},
},
second: {
after: {
100: 'third',
},
},
third: {
type: 'final',
},
},
});

describe('transitionMachine', () => {
let actor: Actor<typeof transitionMachine>;

let runWithFirst: CurryTransitionP2<typeof actor>;

beforeEach(() => {
actor = createActor(transitionMachine);
// curried
runWithFirst = runUntilTransition(actor, 'transitionMachine.first');
});

it('should transition from "first" to "second"', async () => {
await runWithFirst('transitionMachine.second');
});

it('should not transition from "first" to "third"', async () => {
await assert.rejects(runWithFirst('transitionMachine.third'));
});
});

Run a Actor Until It Satisfies a Snapshot Predicate

runUntilSnapshot(actor, predicate) / runUntilSnapshotWith(actor, options, predicate) are curried functions that will start an Actor and run it until the actor's Snapshot satisfies predicate (which is the same type as the predicate parameter of xstate.waitFor()). Once the snapshot matches the predicate, the actor will immediately be stopped.

[!NOTE]

  • Like runUntilDone(), runUntilSnapshot() is not significantly different than XState's waitFor().
  • runUntilSnapshotWith() may be used to overwrite the internal logger and/or add an inspector callback (or Observer) to an Actor.
import {strict as assert} from 'node:assert';
import {describe, it} from 'node:test';
import {assign, createActor, setup} from 'xstate';

import {runUntilSnapshot} from 'xstate-audition';

const snapshotLogic = setup({
types: {
context: {} as {word?: string},
},
}).createMachine({
initial: 'first',
states: {
done: {
type: 'final',
},
first: {
after: {
50: 'second',
},
entry: assign({
word: 'foo',
}),
},
second: {
after: {
50: 'third',
},
entry: assign({
word: 'bar',
}),
},
third: {
after: {
50: 'done',
},
entry: assign({
word: 'baz',
}),
},
},
});

describe('snapshotLogic', () => {
it('should contain word "bar" in state "second"', async () => {
const actor = createActor(snapshotLogic);

const snapshot = await runUntilSnapshot(actor, (snapshot) =>
snapshot.matches('second'),
);

assert.deepEqual(snapshot.context, {word: 'bar'});
});

it('should be in state "second" when word is "bar"', async () => {
const actor = createActor(snapshotLogic);

const snapshot = await runUntilSnapshot(
actor,
(snapshot) => snapshot.context.word === 'bar',
);

assert.equal(snapshot.value, 'second');
});
});

Run a State Machine Actor Until Its System Spawns a Child Actor

runUntilSpawn(actor, childId) / runUntilSpawnWith(actor, options, childId) are curried functions that will start an Actor and run it until it spawns a child ActorRef with id matching childId (which may be a RegExp). Once the child ActorRef is spawned, the Actor will immediately be stopped. The Promise will be resolved with a reference to the spawned ActorRef (an AnyActorRef by default).

waitForSpawn(actor, childId) / waitForSpawnWith(actor, options, childId) are similar, but do not stop the actor.

The root State Machine actor itself needn't spawn the child with the matching id, but any ActorRef within the root actor's system may spawn the child.

[!NOTE]

  • The type of the spawned ActorRef cannot be inferred by ID alone. For this reason, it's recommended to provide an explicit type argument to runUntilSpawn (and variants) declaring the type of the spawned ActorRef's ActorLogic, as seen in the below example.
  • As of this writing, there is no way to specify the parent of the spawned ActorRef.
import {strict as assert} from 'node:assert';
import {describe, it} from 'node:test';
import {createActor, fromPromise, setup, spawnChild} from 'xstate';

import {waitForSpawn} from 'xstate-audition';

const noopPromiseLogic = fromPromise<void, void>(async () => {});

const spawnerMachine = setup({
actors: {noop: noopPromiseLogic},
types: {events: {} as {type: 'SPAWN'}},
}).createMachine({
on: {
SPAWN: {
actions: spawnChild('noop', {id: 'noopPromise'}),
},
},
});

describe('spawnerMachine', () => {
it('should spawn a child with ID "noopPromise" when "SPAWN" event received', async () => {
const actor = createActor(spawnerMachine);

try {
// spawnerMachine needs an event to spawn the actor. but at this point,
// the actor hasn't started, so we cannot send the event because nothing
// will be listening for it.
//
// but if we start the actor ourselves & send the event, spawning could
// happen before waitForSpawn can detect it! so instead of immediately
// awaiting, let's just set it up first.
const promise = waitForSpawn<typeof noopPromiseLogic>(
actor,
'noopPromise',
);

// the detection is now setup and the actor is active; the code running in
// the Promise is waiting for the spawn to occur. so let's oblige it:
actor.send({type: 'SPAWN'});

// ...then we can finally await the promise.
const actorRef = await promise;

assert.equal(actorRef.id, 'noopPromise');
} finally {
// you can shutdown manually! for fun!
actor.stop();
}
});
});

Run an Actor Until It Receives an Event

runUntilEventReceived(actor, eventTypes) / runUntilEventReceivedWith(actor, options, eventTypes) are curried functions that will start a State Machine Actor, Callback Actor, or Transition Actor and run it until it receives event(s) of the specified type. Once the event(s) are received, the actor will immediately be stopped. The Promise will be resolved with the received event(s).

runUntilEventReceived()'s options parameter accepts an otherActorId (string or RegExp) property. If set, this will ensure the event was received from the actor with ID matching otherActorId.

withForEventReceived(actor, eventTypes) / waitForEventReceivedWith(actor, options, eventTypes) are similar, but do not stop the actor.

Usage is similar to runUntilEmitted()—with the exception of the otherActorId property as described above.

Run an Actor Until It Sends an Event

runUntilEventSent(actor, eventTypes) / runUntilEventSentWith(actor, options, eventTypes) are curried functions that will start an Actor and run it until it sends event(s) of the specified type. Once the event(s) are sent, the actor will immediately be stopped. The Promise will be resolved with the sent event(s).

runUntilEventSentWith()'s options parameter accepts an otherActorId (string or RegExp) property. If set, this will ensure the event was sent to the actor with ID matching otherActorId.

waitForEventSent(actor, eventTypes) / waitForEventSentWith(actor, options, eventTypes) are similar, but do not stop the actor.

Usage is similar to runUntilEmitted()—with the exception of the otherActorId property as described above.

Curried Function to Create an Actor from Logic

A convenience function for when you find yourself repeatedly creating the same actor with different input.

See also createActorWith().

const createActor = createActorFromLogic(myLogic);

it('should do x with input y', () => {
const actor = createActor({input: 'y'});
// ...
});

it('should do x2 with input z', () => {
const actor = createActor({input: 'z'});
// ...
});

Curried Function to Create an Actor with Options

A function for when you find yourself repeatedly creating different actors with the same input.

See also createActorFromLogic().

const createYActor = createActorWith({input: 'y'}});

it('should do x with FooMachine', () => {
const actor = createYActor(fooMachine);
// ...
});

it('should do x2 with BarMachine', () => {
const actor = createYActor(barMachine);
// ...
});

Modify an Actor for Use with xstate-audition

This is used internally by all of the other curried functions to violate mutate existing actors. You shouldn't need to use it, but it's there if you want to.

Revert Modifications Made to an Actor by xstate-audition

[!WARNING]

This function is experimental and may be removed in a future release.

unpatchActor(actor) will "undo" what xstate-inspector did in patchActor.

If xstate-audition has never mutated the Actor, this function is a no-op.

Options for many xstate-audition Functions

If you want to attach your own inspector, use a different logger, or set a different timeout, you can use AuditionOptions.

It bears repeating: all functions ending in With() accept an AuditionOptions object as the second argument. If the function name doesn't end with With(), it does not accept an AuditionOptions object.

The AuditionOptions object may contain the following properties:

  • inspector - ((event: xstate.InspectionEvent) => void) | xstate.Observer<xstate.InspectionEvent>: An inspector callback or observer to attach to the actor. This will not overwrite any existing inspector, but may be "merged" with any inspector used internally by xstate-audition.

    The behavior is similar to setting the inspect option when calling xstate.createActor().

  • logger - (args: ...any[]) => void: Default: no-op (no logging; XState defaults to console.log). Set the logger of the Actor.

    The behavior is similar to setting the logger option when calling xstate.createActor(); however, this logger will should cascade to all child actors.

  • timeout - number: Default: 1000ms. The maximum number of milliseconds to wait for the actor to satisfy the condition. If the actor does not satisfy the condition within this time, the Promise will be rejected.

    A timeout of 0, a negative number, or Infinity will disable the timeout.

    The value of timeout should be less than the test timeout!

  • Node.js v20.0.0+ or modern browser
  • xstate v5+

[!CAUTION]

Haven't tested the browser yet, but there are no dependencies on Node.js builtins.

xstate v5+ is a peer dependency of xstate-audition.

npm install xstate-audition -D
  • All functions exposed by xstate-audition's are curried. The final return type of each function is Promise<T>.
  • All functions ending in With() accept an AuditionOptions object as the second argument. If the function name doesn't end with With(), it does not accept an AuditionOptions object (excepting createActorFromLogic).
  • Any inspectors already attached to an Actor provided to xstate-audition will be preserved.
  • At this time, xstate-audition offers no mechanism to set global defaults for AuditionOptions.

©️ 2024 Christopher "boneskull" Hiller. Licensed Apache-2.0.

This project is not affiliated with nor endorsed by Stately.ai.