Testing complex XState machines
I love using state machines in my frontends, and the current winner library to do it is XState. State machines (or actually state charts) can get quite complex, and like with everything I've developed some specific ways to test it. I've collected some guidelines and tricks I use in testing my XState machines, and I'll eternalise them here. For posterity.
Simple machines, little to test
First and foremost: keep your machines small. Nested parallel machines with lots of interdependent states and events are complicated to test not because of any technological reasons, but because the machines themselves are complicated. Small machines will be significantly easier to test.
Instead of nesting parallel machines, see if you can get away with defining the machines separately. You can invoke child machines from the parent and pass messages back and forth. There's a little more work involved, maybe, but your machines will be much more constrained.
Replacing actions and services won't do the job
This might be a controversial take, but I almost never redefine actions or services with .withConfig
. I like to test the machine as a whole, including the internal logic of actions and services and, importantly, the way that they affect the machine.
Instead I mock the side effects. Usually, this means jest.mock
ing the function. I don't like mocking external things though, so I actually ended up providing the functions through the machine's Context
[1]. This makes it super easy to inject mocks that to test specific behaviour.
Looks like this:
const Machine = createMachine({
schema: {} as {
context: {
doAThing: (foo: string) => number;
};
},
// ... ommitted
});
it('does a thing', async () => {
const doAThingMock = jest.fn<
// The type parameters aren't absolutely necessary in this example, but
// they're useful when creating mock implementations.
ReturnType<ContextFrom<typeof Machine>['doAThing']>,
Parameters<ContextFrom<typeof Machine>['doAThing']>
>();
const service = interpret(Machine.withContext({ doAThing: doAThingMock }));
service.start();
await waitFor(service, (state) => state.matches('...'));
expect(doAThingMock).toHaveBeenLastCalledWith(/* ... */);
});
Builder pattern to the rescue
I'm a sucker for a good XYZBuilder
in tests. A good builder allows you to define only the properties relevant for the test, where the others have sane but mostly no-op defaults. I started out creating classes for each machine, but got annoyed at the boilerplate quickly. I tried out builder-pattern but eventually built TSBuilder.
TSBuilder allows easily setting up a type-safe builder. I'm not entirely content with the API yet (especially the typings), but it does the job better than custom classes for now.
const MachineBuilder = TSBuilder<
ContextFrom<typeof Machine>,
typeof Machine,
never
>(
{
prop: () => 'defaultValue',
},
(ctx) => Machine.withConfig(ctx),
);
const machine = MachineBuilder.withProp('otherValue')[Build]().result;
const service = interpret(machine);
Please open issues and PRs if you don't like things about TSBuilder, I welcome feedback!
ActorRefs
This is new to me and a bit of a work in progress. Sometimes you need to depend on, spawn, or reference other machines. This is extremely useful in connecting multiple independent processes, but quite difficult to test. Primarily you'll want to test one of the machines in isolation. Actually testing the machines altogether will be a bigger and more complex test, and as such you'll want to reduce the amount of tests at this level.
However, replacing machines through createMachine
ing simpler, dumber versions of the original can be a big hassle. Most of the time I only need the nested machine to respond to a given action or send an event to its parent at a certain point. I ended up directly implementing ActorRefFrom<Machine>
:
class MockMachine implements ActorRefFrom<Machine> {
public readonly id = 'Machine';
private _state: StateFrom<typeof Machine>['value'] =
'idle';
private _context: Context;
private _listeners: Array<{ func: Listener; id: string }> = [];
constructor(context: Context) {
this._context = context;
}
getSnapshot() {
return {
context: this._context,
value: this._state,
} as StateFrom<Machine>;
}
unsubscribe(id: string) {
this._listeners = this._listeners.filter((l) => l.id !== id);
}
subscribe(subscriber: Listener | { next: Listener }): Subscription {
const listener = {
func: 'next' in subscriber ? subscriber.next : subscriber,
id: v4(),
};
this._listeners.push(listener);
return {
unsubscribe: () => {
this.unsubscribe(listener.id);
},
};
}
send(
event:
| EventFrom<Machine>['type']
| EventFrom<Machine>,
) {
const parsedEvent = typeof event === 'string' ? { type: event } : event;
this._listeners.forEach((l) =>
l.func({
...this.getSnapshot(),
event: parsedEvent as EventFrom<Machine>,
} as StateFrom<Machine>),
);
}
get state() {
return this.getSnapshot();
}
[Symbol.observable]() {
return {} as InteropSubscribable<StateFrom<Machine>>;
}
}
As you can probably tell, I don't actually implement the full behaviour of the ActorRef
. It seems to work though, and Typescript believes it. Currently, this isn't generic but it should be easy enough. Pass it the correct types and you can then send certain events from the machine with machine.send({ type: 'ACTION' });
. It's similar to XState's empty actor but you get more control over the snapshot.
I do think this could benefit from a generalised library. Maybe I'll build on eventually.
Concluding...
That's it for now. I realize that my specific testing strategies may be controversial. Dependency injection could be replaced with mocking, for one. However, it comes from some amount of experience, and I haven't seen anyone else's approaches to this.
Happy to discuss though, of course!
This is called dependency injection, by the way. ↩︎