- Model-based app
- State visualization
- Connect from test
- Set the initial data
- Send state events
- Listen to events
- See also
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:
1 | import { createMachine, assign, spawn } from "xstate"; |
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 | "NEWTODO.COMMIT": { |
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.
1 | import { Todos } from './Todos' |
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.
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
1 | import { useMachine } from "@xstate/react"; |
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.
1 | export function Todos() { |
Now let's write a Cypress test that "tricks" the xstate machine into exposing its instance via window.__xstate__
property.
1 | describe('TodoMVC', () => { |
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.
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
Execute copy(localStorage['todos-xstate'])
from the browser's DevTools console to copy the value and paste it into the test
1 | const todos = [ |
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
.
But our test fails - seems the "completed" class is NOT hydrated correctly
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.
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 | export function Todos() { |
So the items are deserialized correctly, let's dig further. Let's print the context inside the individual Todo items
1 | import { useActor } from '@xstate/react' |
Hmm, seems the completed
property gets "lost" on the way to the 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 | it('adds todos', () => { |
So we can "drive" the application by sending events to the state machine, and triggering actions, rather than always going through the user interface
🦉 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 | it('listens to events', () => { |
Notice how there are lots of events, but we only confirm the one we are interested in using:
1 | cy.get('@events').should('have.been.calledWith', { |