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.
Usage
TL;DR:
- Create an
Actor using xstate.createActor(logic).
- 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.
- 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).
- Now, you can
await the Promise<T> from step 2.
- Finally, make an assertion about
T.
runUntilEmitted()
Run a State Machine Until It Emits Events
runUntilEmitted(actorRef, eventTypes) / runUntilEmittedWith(actorRef, 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(actorRef, eventTypes) / waitForEmittedWith(actorRef, 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);
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});
});
});
runUntilTransition()
Run a State Machine Until It Transitions from One State to Another
runUntilTransition(actorRef, fromStateId, toStateId) / runUntilTransitionWith(actorRef, 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(actorRef, fromStateId, toStateId) / waitForStateWith(actorRef, 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({
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);
runWithFirst = runUntilTransition(actorRef, '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'));
});
});
runUntilDone()
Run a Promise Actor or State Machine to Completion
runUntilDone(actor) / runUntilDoneWith(actorRef, 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>(
async ({input, signal}) => {
let listener!: () => void;
try {
return await new Promise((resolve, reject) => {
listener = () => {
clearTimeout(timeout);
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(actorRef, {timeout: 100}),
(err: Error) => {
assert.equal(err.message, 'Actor did not complete in 100ms');
return true;
},
);
});
});
runUntilSnapshot()
Run a Actor Until It Satisfies a Snapshot Predicate
runUntilSnapshot(actorRef, predicate) / runUntilSnapshotWith(actorRef, 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(actorRef, (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');
});
});
runUntilSpawn()
Run a State Machine Actor Until Its System Spawns a Child Actor
runUntilSpawn(actorRef, childId) / runUntilSpawnWith(actorRef, options, childId) are curried functions that will start an actor and run it until it spawns a child actor with id matching childId (which may be a RegExp). Once the child actor is spawned, the actor will immediately be stopped. The Promise will be resolved with a reference to the spawned actor (an xstate.ActorRef).
waitForSpawn(actorRef, childId) / waitForSpawnWith(actorRef, 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 actor within the root actor's system may spawn the child. As of this writing, there is no way to specify the parent of the spawned actor.
[!NOTE]
The type of the spawned actor cannot be inferred by ID alone. For this reason, it's recommended to provide an explicit type argument declaring the type of the spawned actor's ActorLogic, as seen in the below example.
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 {
const promise = waitForSpawn<typeof noopPromiseLogic>(
actor,
'noopPromise',
);
actor.send({type: 'SPAWN'});
const actorRef = await promise;
assert.equal(actorRef.id, 'noopPromise');
} finally {
actor.stop();
}
});
});
runUntilEventReceived()
Run an Actor Until It Receives an Event
runUntilEventReceived(actorRef, eventTypes) / runUntilEventReceivedWith(actorRef, 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(actorRef, eventTypes) / waitForEventReceivedWith(actorRef, 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.
runUntilEventSent()
Run an Actor Until It Sends an Event
runUntilEventSent(actorRef, eventTypes) / runUntilEventSentWith(actorRef, 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(actorRef, eventTypes) / waitForEventSentWith(actorRef, 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.
createActorFromLogic(logic, options)
Curried Function to Create an Actor from Logic
If you find yourself creating actors repeatedly with different options, try this function. Example:
const createActor = createActorFromLogic(myLogic);
it('should do x with input y', () => {
const actorRef = createActor({input: 'y'});
});
it('should do x2 with input z', () => {
const actorRef = createActor({input: 'z'});
});
See also createActorWith().
createActorWith(options, logic)
Curried Function to Create an Actor with Options
If you find yourself using the same options to repeatedly create different actors, try this function. Example:
const createYActor = createActorWith({input: 'y'}});
it('should do x with FooMachine', () => {
const actorRef = createYActor(fooMachine);
});
it('should do x2 with BarMachine', () => {
const actorRef = createYActor(barMachine);
});
See also createActorFromLogic().
unpatchActor()
Revert Modifications Made to an Actor by xstate-audition
[!WARNING]
This function is experimental and may be removed in a future release.
unpatchActor(actorRef) will "undo" what xstate-inspector did (e.g., unsubscribe its inspector and reset the logger), you can call this function with the ActorRef.
If xstate-audition has never touched the ActorRef, this function is a no-nop.
Requirements
- Node.js v20.0.0+ or modern browser
xstate v5.17.1+ (peer dependency)
[!CAUTION]
Haven't tested the browser yet!
Installation
npm install xstate-audition xstate -D
API Notes
[!IMPORTANT]
- 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.
- 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.
AuditionOptions
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!
License
©️ 2024 Christopher "boneskull" Hiller. Licensed Apache-2.0.
Disclaimer
This project is not affiliated with nor endorsed by Stately.ai.