xstate-audition
v1.2.0
Published
Test helpers for XState v5+
Downloads
213
Maintainers
Readme
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
usingxstate.createActor(logic)
. - Create a
Promise<T>
using one of the functions below (e.g.,runUntilDone(actor: Actor) => Promise<T>
whereT
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
thePromise<T>
(examples below). - Now, you can
await
thePromise<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);
// 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});
});
});
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({
// 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(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'stoPromise()
.runUntilDoneWith()
may be used to overwrite the internal logger and/or add an inspector callback (orObserver
) 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(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'swaitFor()
.runUntilSnapshotWith()
may be used to overwrite the internal logger and/or add an inspector callback (orObserver
) 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 {
// 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();
}
});
});
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 anAuditionOptions
object as the second argument. If the function name doesn't end withWith()
, it does not accept anAuditionOptions
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 callingxstate.createActor()
.logger
-(args: ...any[]) => void
: Default: no-op (no logging; XState defaults toconsole.log
). Set the logger of the Actor.The behavior is similar to setting the
logger
option when callingxstate.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, thePromise
will be rejected.A
timeout
of0
, a negative number, orInfinity
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.