Access XState from Cypress Test

How to access the XState state machine from Cypress test to verify the current context, observe events, and drive the app via actions

Model-based app

David K has recently released a good TodoMVC example implemented using state machines via XState library. You can find a cloned version of the application at bahmutov/xstate-todomvc. The app's state and allowed actions are implemented using a state machine:

src/todoMachine.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import { createMachine, assign, spawn } from "xstate";
import uuid from "uuid-v4";
import { createTodoMachine } from "./todoMachine";

const createTodo = (title) => {
return {
id: uuid(),
title,
completed: false
};
};

export const todosMachine = createMachine({
id: "todos",
context: {
todo: "", // new todo
todos: [],
filter: "all"
},
initial: "loading",
states: {
loading: {
entry: assign({
todos: (context) => {
// "Rehydrate" persisted todos
return context.todos.map((todo) => ({
...todo,
ref: spawn(createTodoMachine(todo))
}));
}
}),
always: "ready"
},
ready: {}
},
on: {
"NEWTODO.CHANGE": {
actions: assign({
todo: (_, event) => event.value
})
},
"NEWTODO.COMMIT": {
actions: [
assign({
todo: "", // clear todo
todos: (context, event) => {
const newTodo = createTodo(event.value.trim());
return context.todos.concat({
...newTodo,
ref: spawn(createTodoMachine(newTodo))
});
}
}),
"persist"
],
cond: (_, event) => event.value.trim().length
},
"TODO.COMMIT": {
actions: [
assign({
todos: (context, event) =>
context.todos.map((todo) => {
return todo.id === event.todo.id
? { ...todo, ...event.todo, ref: todo.ref }
: todo;
})
}),
"persist"
]
},
"TODO.DELETE": {
actions: [
assign({
todos: (context, event) =>
context.todos.filter((todo) => todo.id !== event.id)
}),
"persist"
]
},
SHOW: {
actions: assign({
filter: (_, event) => event.filter
})
},
"MARK.completed": {
actions: (context) => {
context.todos.forEach((todo) => todo.ref.send("SET_COMPLETED"));
}
},
"MARK.active": {
actions: (context) => {
context.todos.forEach((todo) => todo.ref.send("SET_ACTIVE"));
}
},
CLEAR_COMPLETED: {
actions: assign({
todos: (context) => context.todos.filter((todo) => !todo.completed)
})
}
}
});

The above machine has 2 states: 'loading' and 'ready', and it starts in the 'loading' state. The machine has 'context' object that keeps the current todo text and the list of existing todos. The most important part of the machine are actions - they control how every event changes the machine. For example, when an event "NEWTODO.COMMIT" happens, if the current todo is not empty, then we add the new todo object to the list of todos.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
"NEWTODO.COMMIT": {
actions: [
assign({
todo: "", // clear todo
todos: (context, event) => {
const newTodo = createTodo(event.value.trim());
return context.todos.concat({
...newTodo,
ref: spawn(createTodoMachine(newTodo))
});
}
}),
"persist"
],
cond: (_, event) => event.value.trim().length
}

State visualization

When the application starts, it uses @xstate/inspect to connect to the state machine to visualize it. This is optional utility that we can enable only in the local development mode.

src/index.js
1
2
3
4
5
6
7
8
import { Todos } from './Todos'
import { inspect } from '@xstate/inspect'

inspect({
iframe: false,
})

ReactDOM.render(<Todos />, document.querySelector('#app'))

Thus a new browser window pops up and lets us see the state machine graph together with events and transitions - it is a magical sight.

TodoMVC state visualization

The React component that uses the state machine is shown below. In order for the state machine to be observable, the React component creating it must pass devTools: true option

src/Todos.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useMachine } from "@xstate/react";
import { todosMachine } from "./todosMachine";

const persistedTodosMachine = todosMachine.withConfig(
{
actions: {
persist: (ctx) => {
try {
localStorage.setItem("todos-xstate", JSON.stringify(ctx.todos));
} catch (e) {
console.error(e);
}
}
}
},
// initial state from localstorage
{
todo: "Learn state machines",
todos: (() => {
try {
return JSON.parse(localStorage.getItem("todos-xstate")) || [];
} catch (e) {
console.error(e);
return [];
}
})()
}
);

export function Todos() {
const [state, send] = useMachine(persistedTodosMachine, { devTools: true });
...
}

Can we connect to the state machine from a Cypress test to unlock the application's internal logic? Can we use App Actions to access the state machine's context to verify it? Can we drive the application by sending events to the machine and verify the DOM and local storage updates? Can our test listen for state events, while we drive the application through the DOM?

Connect from test

While we cannot (yet) embed the state machine visualization inside the Cypress browser, we can still connect to the machine from the test. First, let's only expose the state machine during Cypress test.

src/Todos.jsx
1
2
3
4
5
export function Todos() {
const options = window.Cypress ? { devTools: true } : {}
const [state, send] = useMachine(persistedTodosMachine, options)
...
}

Now let's write a Cypress test that "tricks" the xstate machine into exposing its instance via window.__xstate__ property.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
describe('TodoMVC', () => {
it('starts with todo text', () => {
const state = {}
cy.visit('/', {
onBeforeLoad(win) {
win.__xstate__ = {
register: (x) => {
state.xstate = x
},
}
},
})
// initially
cy.wrap(state).its('xstate.machine.context').should('deep.equal', {
todo: 'Learn state machines',
todos: [],
})
})
})

When a state machine starts with devTools: true it automatically registers using window.__xstate__.register method. Thus our test provides a mock method, and the state machine is now within the test's reach.

I set the machine as a property xstate on the state object. This is a trick that allows us to call cy.wrap(state).its('xstate...') command and Cypress auto-retries automatically until the state machine is set.

Checking the initial context object

In the test above we verify that the machine starts with an empty list of todos, and the next todo text.

Set the initial data

Our application loads the previously saved state from the local storage. Let's test if that is working correctly - we can verify both the context object and the DOM elements. To know what to save in the local storage, we can simply use the application, and then print the localStorage object

Application saves its state in the local storage

Execute copy(localStorage['todos-xstate']) from the browser's DevTools console to copy the value and paste it into the test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const todos = [
{
id: '455de87d-bc9a-4849-b05f-767c5bef7c65',
title: 'write state machine',
completed: false,
ref: { id: '1' },
},
{
id: 'b62e163b-8f2f-4677-a228-9fd28a52a120',
title: 'test using Cypress',
completed: true,
ref: { id: '2' },
prevTitle: 'test using Cypress',
},
]
localStorage['todos-xstate'] = JSON.stringify(todos)
cy.visit(...)
// the context is set correctly
cy.wrap(state).its('xstate.machine.context').should('deep.equal', {
todo: 'Learn state machines',
todos,
})
// check the DOM
cy.get('.todo-list li').should('have.length', todos.length)
todos.forEach((todo, k) => {
cy.get('.todo-list li label').eq(k).should('have.text', todo.title)
if (todo.completed) {
cy.get('.todo-list li').eq(k).should('have.class', 'completed')
} else {
cy.get('.todo-list li').eq(k).should('not.have.class', 'completed')
}
})

Notice how we check the DOM using the list of todos we set in the local storage. For each item we verify the text in the DOM, and if the item has completed: true property, then the DOM element should have the class completed.

Expected DOM with two Todo items

But our test fails - seems the "completed" class is NOT hydrated correctly

The test fails to find the class completed in the second item

Wait, is this possible? Is there a true error we have found in this most excellent TodoMVC application? Let's try the application by itself, without Cypress.

Application does not preserve the completed property for real during reload

Interesting - so the number of completed todos is 1 after reload - so that's correct, but the completed field is not passed correctly to the individual Todo components. But I thought ... I mean, David said that model-based apps cannot have bugs... Has my life been a lie?! Let's see why the completed property is not accurately reflected in the DOM after reload. First, let's see if it is deserialized correctly

1
2
3
4
export function Todos() {
const options = window.Cypress ? { devTools: true } : {}
const [state, send] = useMachine(persistedTodosMachine, options)
console.table(state.context.todos)

Print the todos to the console

So the items are deserialized correctly, let's dig further. Let's print the context inside the individual Todo items

1
2
3
4
5
6
7
8
import { useActor } from '@xstate/react'
export function Todo({ todoRef }) {
const [state, send] = useActor(todoRef)
const inputRef = useRef(null)
console.log(state.context)
const { id, title, completed } = state.context
...
}

Hmm, seems the completed property gets "lost" on the way to the item

Print individual Todo item

Ok, so this goes into the weeds of useActor, so I will stop. This failing test example shows that you still need end-to-end tests, since they can discover problems in your logic, in your code bundling pipeline, in your hosting, in your configuration - all the things that can go wrong for your users can be tested against using Cypress end-to-end tests.

Update - the fix

David K has fixed the Codesandbox, see b7772.

Send state events

If we can access and verify the context, we can also drive the application by sending events from the test to the state machine. For example, let's add several todos not via user interface, but via state events.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
it('adds todos', () => {
const state = {}
cy.visit('/', {
onBeforeLoad(win) {
win.__xstate__ = {
register: (x) => {
state.xstate = x
},
}
},
})

cy.wrap(state)
.its('xstate')
.invoke('send', { type: 'NEWTODO.COMMIT', value: 'first todo' })
cy.get('.todo-list li').should('have.length', 1)
})

So we can "drive" the application by sending events to the state machine, and triggering actions, rather than always going through the user interface

Adding new todo by sending an event to the state machine

🦉 Cypress RealWorld App is using such state action approach in most tests to quickly log into the application bypassing the user interface (which is covered by its own dedicated tests).

Listen to events

While the application is working, the state machine is processing events triggered by the React application. We can listen to these events to confirm they are happening.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
it('listens to events', () => {
const state = {}
cy.visit('/', {
onBeforeLoad(win) {
win.__xstate__ = {
register: (x) => {
state.xstate = x
},
}
},
})
// start listening to xstate events
cy.wrap(state)
.its('xstate')
.invoke('subscribe', (state, event) => cy.stub().as('events')(event))

// if we add the todo via DOM
cy.get('.new-todo').clear().type('write better tests{enter}')

// then we will have the event in the state machine
cy.get('@events').should('have.been.calledWith', {
type: 'NEWTODO.COMMIT',
value: 'write better tests',
})
})

Notice how there are lots of events, but we only confirm the one we are interested in using:

1
2
3
4
cy.get('@events').should('have.been.calledWith', {
type: 'NEWTODO.COMMIT',
value: 'write better tests',
})

Listen to the state events

See also