Recently, I needed some state-heavy logic within this new application, so following from my previous research, I went with XState.
The implementation of the logic went smoothly, within no time I had our new State Machine in place and functioning correctly.
Then came testing.
I got a bit stuck when it came to writing the unit tests. In an ideal world, I wouldn't be relying so much on unit testing. However, as many companies do, mine prefers to align with the Testing Pyramid rather than the Testing Trophy. Unit testing is a must for us. So I hit the docs.
So what is model-based testing anyway?
The first thing to wrap my head around was the lack of actual unit tests. Model-based testing allows us to give information about our State Machine and instructions on how to perform small steps within the logic to transition between states.
We take this information and generate end to end paths through our logic. Finally, we use these paths to base the generation of our unit tests. It'll look something like this:
// State machine test
describe('StateMachine', () => {
const testPlans = stateMachineModel.getShortestPathPlans();
testPlans.forEach((plan) => {
describe(plan.description, () => {
afterEach(cleanup);
plan.paths.forEach((path) => {
it(path.description, async () => {
await path.test(render(<TestComponent />));
});
});
});
});
});
First, we need a component to test.
Typically, State Machines used with visual components, interacting with these visuals causes transitions through our logic. We don't want to be tied to production visuals for our testing here, just in case the visuals change and the logic doesn't. Also, creating a component purely for testing allows us to simplify how we trigger our transitions.
// State machine test
const TestComponent = () => {
const [state, publish] = useMachine(stateMachine, {
actions: {
loadingEntryAction,
userSubmitAction,
},
});
return (
<div>
<p data-testid="current_state">{state.value}</p>
<button
onClick={() => {
publish('SUBMIT');
}}
>
SUBMIT
</button>
<button
onClick={() => {
publish('SUCCESS');
}}
>
SUCCESS
</button>
<button
onClick={() => {
publish('FAILURE');
}}
>
FAILURE
</button>
</div>
);
};
So here's our simple component, we display the current state and have buttons for each type of transition we support. We also import and use our State Machine as you would in a normal React Component.
Asserting we're right.
Looking at the documentation, we see examples like this one:
// XState Test Docs
const toggleMachine = Machine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: {
on: {
/* ... */
},
meta: {
test: async page => {
await page.waitFor('input:checked');
}
}
},
/* ... */
});
I'm not a fan of this as it looks like we're hardcoding our testing logic into our production code. I prefer to keep these two worlds apart.
So let's take our State Machine initialisation:
// State machine
import { Machine } from 'xstate';
export const stateMachine = Machine({
id: 'statemachine',
initial: 'IDLE',
states: {
IDLE: {
on: { SUBMIT: { target: 'LOADING', actions: ['userSubmitAction'] } },
},
LOADING: {
entry: ['loadingEntryAction'],
on: {
SUCCESS: 'SUCCESS',
FAILURE: 'FAILURE',
},
},
SUCCESS: {},
FAILURE: {
SUBMIT: { target: 'LOADING', actions: ['userSubmitAction'] },
},
},
});
We'll change this to separately export the declaration of logic for our State Machine alongside the export of the State Machine itself.
// State machine
import { Machine } from 'xstate';
export const machineDeclaration = {
id: 'statemachine',
initial: 'IDLE',
states: {
IDLE: {
on: { SUBMIT: { target: 'LOADING', actions: ['userSubmitAction'] } },
},
LOADING: {
entry: ['loadingEntryAction'],
on: {
SUCCESS: 'SUCCESS',
FAILURE: 'FAILURE',
},
},
SUCCESS: {},
FAILURE: {
SUBMIT: { target: 'LOADING', actions: ['userSubmitAction'] },
},
},
};
export const stateMachine = Machine(machineDeclaration);
We can then take this and within our test, add logic allowing us to assert we're in the right state by looking at the current_state we implemented in our Test Component.
// State machine test
machineDeclaration.states.idle.meta = {
test: async ({ getByTestId }) => {
expect(getByTestId('current_state')).toHaveTextContent('idle');
},
};
machineDeclaration.states.loading.meta = {
test: async ({ getByTestId }) => {
expect(getByTestId('current_state')).toHaveTextContent('loading');
expect(loadingEntryAction).toHaveBeenCalled();
},
};
machineDeclaration.states.success.meta = {
test: async ({ getByTestId }) => {
expect(getByTestId('current_state')).toHaveTextContent('success');
},
};
machineDeclaration.states.failure.meta = {
test: async ({ getByTestId }) => {
expect(getByTestId('current_state')).toHaveTextContent('failure');
},
};
Forming our machine model
Let's create a new model using our machineDeclaration and some events. These events are the actions which trigger state transitions within our Testing Component, in our case that's clicking buttons, we use React Testing Library's FireEvent to do this.
// State machine test
const stateMachineModel =
createModel(xstate.createMachine(machineDeclaration)).withEvents({
SUBMIT: {
exec: ({ getByText }) => {
fireEvent.click(getByText('SUBMIT'));
expect(userSubmitAction).toHaveBeenCalled();
},
},
SUCCESS: {
exec: ({ getByText }) => {
fireEvent.click(getByText('SUCCESS'));
},
},
FAILURE: {
exec: ({ getByText }) => {
fireEvent.click(getByText('FAILURE'));
},
},
});
Asserting Actions
We use a typical way of triggering events in our state machine - actions. You can see we use actions twice, once when we enter a state and another to accompany a transition. We saw our assertions for these before but, let's have a focused look:
Firstly, we created mock functions to assert against then we pass those to XState when we initialise our State Machine in Test Component.
// State machine test
//...
const loadingEntryAction = jest.fn();
const userSubmitAction = jest.fn();
const TestComponent = () => {
const [state, publish] = useMachine(stateMachine, {
actions: {
loadingEntryAction,
userSubmitAction,
},
});
//...
We can then use these functions within the assertions we pass to XState to assert a function is called upon entry.
// State machine test
machineDeclaration.states.loading.meta = {
test: async ({ getByTestId }) => {
expect(getByTestId('current_state')).toHaveTextContent('loading');
expect(loadingEntryAction).toHaveBeenCalled();
},
};
To assert that a function is called during a transition, we can add an assertion in our testing model, as we do here for userSubmitAction.
// State machine test
const stateMachineModel =
createModel(xstate.createMachine(machineDeclaration)).withEvents({
SUBMIT: {
exec: ({ getByText }) => {
fireEvent.click(getByText('SUBMIT'));
expect(userSubmitAction).toHaveBeenCalled();
},
},
//...
});
Finally
Tying all this together took a little time for me, so I wanted to write it down to remind myself and hopefully help anybody else looking to unit test their XState State Machines. The key for me was to understand each of these small parts and using them with a combination of Jest and React Testing Library, rather than Puppeteer.
You can see this example in its entirety here.
As always, this is just the way I found to achieve this goal. If you have any thoughts or opinions then please reach out.
Top comments (0)