-->
After contributing to an open-source project, I’ve come to understand the importance of writing tests. One advantage of projects with tests is that we can quickly verify if existing features break when changes are made. In fact, when developing a feature, we can swiftly ensure it works as intended for given cases through testing.
This benefit is also true when writing finite state machines (FSM) using XState. Questions arose in my mind, “What if we could quickly verify our FSM works while we’re creating it?” and “Does adding new features disrupt existing functionalities?”
In this article, I’ll share how I incorporate testing when developing FSMs.
Before continuing, as a disclaimer, XState also offers a package for writing tests. Even more advanced, this package can automatically generate test cases. However, this article focuses on how I test the FSMs I’ve created against our own (imperative) test cases.
The application discussed in this article can be viewed in this repository.
For me, one of the most exciting ways to learn is by using case studies.
In this article, I will use the “phone keypad” as a case study. For those who are unfamiliar, a “phone keypad” is a type of “keyboard” found on older mobile phones.
(Source https://www.gsmarena.com/nokia_3310-pictures-192.php)
Some functionalities we aim to achieve:
Pressing a button for the first time will select the character group on that button and choose the first character in the group.
Repeatedly pressing the same button will select characters according to their order.
If no button is pressed after a set time, the currently selected character will be inserted into the text.
Pressing a different button will insert the currently selected character into the text and change the selection to the first character on the newly pressed button.
I am using Next.js 13 with the app directory and TailwindCSS. A tutorial on creating a Next.js project with the app router can be found at this link.
What is your project named? logs-understanding-fsm-with-xstate
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? Yes
What import alias would you like configured? @/*
xstate
& @xstate/react
In this article, I am using XState version 4.
yarn add xstate@^4.38.1 @xstate/react@^3.2.2
jest
& ts-jest
Install the following libraries:
yarn add -D jest ts-jest @types/jest
jest
: this library is used for testing.
ts-jest
: this library allows us to directly run tests written in TypeScript without having to transpile to JS.
@types/jest
: this is the type definition for jest.
Then, execute the following line to initialize Jest configuration with the ts-jest
preset.
yarn ts-jest config:init
Here is the folder structure:
next-app/
├─ src/
│ ├─ app/
│ │ ├─ phone-keypad/
│ │ │ ├─ page.tsx
│ │ ├─ ...
│ ├─ features/
│ │ ├─ phone-keypad/
│ │ │ ├─ constant.ts
│ │ │ ├─ phoneKey.fsm.spec.ts
│ │ │ ├─ phoneKeypad.fsm.ts
│ │ │ ├─ phoneKeypad.module.css
│ │ │ ├─ PhoneKeypad.tsx
│ │ ├─ ...
│ ├─ ../
├─ ...
.fsm
are files that contain the definition and the test of our FSM
PhoneKeypad
is a component that implements the state machine we will create and integrates it with the UI.
phone-keypad/page.tsx
is the page where we display the created keypad.
Separating an application into distinct layers according to their responsibilities makes it easier to maintain. This principle is known as “separation of concern.” In this article, I have divided the application into two layers: the UI layer (presentation layer) and the domain layer.
The UI Layer is the layer that consists of displays, such as web pages or components. The domain layer, on the other hand, is the layer that contains the business logic, which in this case is the FSM.
The first thing I did was create a representation of the keypad to be displayed. Based on the functionality criteria above, the keypad will be pressed one or several times to obtain the desired character. For example, on a Nokia 3310 phone, the number 2 key consists of 3 alphabets and 1 number:
'abc2'
To get the letter “b”, I have to press the button twice. The first press will display the letter “a”, and the second press within a certain period will display the letter “b”.
There are at least two alternatives that I have thought of to represent the existing keys:
Using array of string (1-dimensional array)
export const PHONE_KEYS = [
"1",
"ABC2",
"DEF3",
"GHI4",
"JKL5",
"MNO6",
"PQRS7",
"TUV8",
"WXYZ9",
"*",
" 0",
"#"
];
or, as array of characters (2-dimensional array)
export const PHONE_KEYS = [
["1"],
["A", "B", "C", "2"],
["D", "E", "F", "3"],
["G", "H", "I", "4"],
["J", "K", "L", "5"],
["M", "N", "O", "6"],
["P", "Q", "R", "S", "7"],
["T", "U", "V", "8"],
["W", "X", "Y", "Z", "9"],
["*"],
[" ", "0"],
["#"]
];
Here, I am using the first option. There’s no specific reason; it’s just a personal preference.
Later on, this array can be used to create the keypad display. It would look something like this:
From the illustration of the mapping above, we can use the expression PHONE_KEYS[characterGroupIndex][characterIndex]
to refer to a character
Considering the focus of this article is on how I test the FSM I created, I have already developed the FSM to be used.
Here is the context that will be used by this FSM:
export type MachineContext = {
currentCharacterGroupIndex?: number;
currentCharacterIndex?: number;
lastPressedKey?: number;
str: string;
};
currentCharacterGroupIndex
is used to select a character group, and currentCharacterIndex
is used to select a character within that group. For example, to refer to the character “A”, the value of currentCharacterIndex
would be 0. To refer to the character “B”, the value used would be 1, and so on.
lastPressedKey
is used to track the last pressed button. Lastly, str
is used to store the text that we type.
The FSM recognizes one type of event, which are:
export type MachineEvent = {
type: "KEY.PRESSED";
key: number;
};
The event “KEY.PRESSED
” informs the FSM that a button has been pressed. This event carries a “key
” property that tells the machine which character group to use.
The overall behavior of the FSM can be seen in the following diagram:
The first functionality,
Pressing a button for the first time will select the character group on that button and choose the first character in the group.
is fulfilled when transitioning from the state “Idle
” to “Waiting for key being pressed again
”. In the event that is sent, “KEY.PRESSED
”, there is an action named onFirstPress
which will change the value of currentCharacterGroupIndex
to the “key
” carried by the “KEY.PRESSED
” event and currentCharacterIndex
to 0. In this action, we also store the “key
” carried in the lastPressedKey
property as a reference for the second functionality.
(Where the first functionality is fulfilled)
The second functionality,
Pressing the same button repeatedly will select characters in sequence.
is fulfilled when the state “Waiting for key being pressed again
” receives the “KEY.PRESSED
” event but the guard “isTheSameKey?
” is satisfied. The “isTheSameKey?
” guard checks whether the “key
” brought by the “KEY.PRESSED
” event is the same as the lastPressedKey
property stored in the context. If satisfied, the onNextPress
action on the event will be called. This action will increment the value of currentCharacterIndex
. If the value of currentCharacterIndex
reaches the last character, it will reset to 0.
(Where the second functionality is fulfilled)
The third functionality,
If no button is pressed after a set time, the currently selected character will be inserted into the text.
is fulfilled when no “KEY.PRESSED
” event is received within 500ms
while in the “Waiting for key being pressed again
” state. In other words, the FSM will wait for 500ms
before triggering the “after 500ms
” event. The state will then transition to “Waited time passed
” which subsequently transitions to the “Idle
” state and triggers the actions “assignToString
” and “removeSelectedKey
” in sequence.
The “assignToString
” action will add the character at currentCharacterGroupIndex
and currentCharacterIndex
to the context str
. The “removeSelectedKey
” action will clear the values of currentCharacterGroupIndex
, currentCharacterIndex
, and lastPressedKey
.
It is important to note that in the “Waiting for key being pressed again
” state, if a “KEY.PRESSED
” event is received, the 500ms
wait time will reset from 0
.
(Where the third functionality is fulfilled)
The final functionality,
Pressing a different button will insert the currently selected character into the text and change the selection to the first character on the newly pressed button.
is fulfilled when in the “Waiting for key being pressed again
” state, a “KEY.PRESSED
” event is received but does not satisfy the guard “isTheSameKey?
”. This event will trigger the actions “assignToString
”, “removeSelectedKey
”, and “onFirstPress
”. Notably, the first two actions in this event are the same as when we add the currently referenced character to the string. Meanwhile, the “onFirstPress
” action, which is last in the sequence, will update the properties currentCharacterGroupIndex
, currentCharacterIndex
, and lastPressedKey
according to the “key
” property brought by the “KEY.PRESSED
” event.
(Where the fourth functionality is fulfilled)
The complete definition of the FSM can be viewed in the repository.
Finally, we get to the core of this article!
Before I knew about testing, what I did to verify my state machine was to test it directly alongside the UI! However, the more complex my FSM became, the harder it was to test certain states, especially states approaching the final state.
For me, there are two main benefits of testing the FSM I created:
During the development process, I can verify that the FSM I created works as intended without having to touch the UI. Returning to the principle of layering mentioned earlier, the FSM is in the logic layer while the UI is in the UI layer (presentation layer). The UI layer has no connection to the correctness of the logic layer.
If there are changes to the FSM, I can quickly re-verify whether my FSM still works as expected, as outlined in the test cases.
So, what needs to be tested?
For the case in this article, I take the existing functionalities as a reference for testing.
First, I create a test file named phoneKey.fsm.spec.ts
. Then, I add a test suite named “phoneKeypad”:
// phoneKey.fsm.spec.ts
describe("phoneKeypad", () => {
// test cases will be written in here...
});
The first test case ensures that functionalities 1, 2, and 3 are met:
const waitFor = async (time: number) => new Promise((r) => setTimeout(r, time));
describe("phoneKeypad", () => {
it("should be able to type using the keys", async () => {
const fsm = interpret(phoneKeypadMachine);
fsm.start();
fsm.send({ type: "KEY.PRESSED", key: 0 });
await waitFor(500);
expect(fsm.getSnapshot().context.str).toBe("1");
fsm.send({ type: "KEY.PRESSED", key: 1 });
await waitFor(200);
fsm.send({ type: "KEY.PRESSED", key: 1 });
await waitFor(200);
fsm.send({ type: "KEY.PRESSED", key: 1 });
await waitFor(200);
fsm.send({ type: "KEY.PRESSED", key: 1 });
await waitFor(500);
expect(fsm.getSnapshot().context.str).toBe("12");
});
});
In the expression:
const fsm = interpret(phoneKeypadMachine.withConfig({})).start();
We interpret the already created phoneKeypadMachine
using the interpret
function. This function will return a running process based on the FSM we have created. This process is referred to as an “actor”.
As a note, the interpret
function is deprecated in XState5. If you are using XState5, you can use the createActor
function. (Ref)
The process stored in the fsm
variable has not yet started. To run it, we can use the start
method.
fsm.start();
Next, we simulate a button being pressed.
Before defining the test suite, we define a function named waitFor
. This function is used to wait for a certain amount of time.
In these statements:
fsm.send({ type: "KEY.PRESSED", key: 0 });
await waitFor(500);
expect(fsm.getSnapshot().context.str).toBe("1");
We send the “KEY.PRESSED
” event with key 0 to the state machine. Referring to the buttons we have created:
export const PHONE_KEYS = [
"1",
"ABC2",
"DEF3",
"GHI4",
"JKL5",
"MNO6",
"PQRS7",
"TUV8",
"WXYZ9",
"*",
" 0",
"#"
];
the selected character is “1
”. We then wait for 500ms
. We check if the value of context.str
is “1
”. This set of statements tests functionalities 1 and 3.
Then, in the following statements:
fsm.send({ type: "KEY.PRESSED", key: 1 });
await waitFor(200);
fsm.send({ type: "KEY.PRESSED", key: 1 });
await waitFor(200);
fsm.send({ type: "KEY.PRESSED", key: 1 });
await waitFor(200);
fsm.send({ type: "KEY.PRESSED", key: 1 });
await waitFor(500);
expect(fsm.getSnapshot().context.str).toBe("12");
We send the “KEY.PRESSED
” event four times with key “1”. To ensure that a character is only added after 500ms
, each time we press the button with key “1”, we wait for 200ms
. We know that the button with key “1” refers to the second character group, 'ABC2'
. Pressing it four times before 500ms
are up will select the fourth character, which is “2”.
At the end of these statements, we test whether the character “2” has been added to the existing str
, so the current value of str
should now be “12”.
Finally, we test the fourth functionality:
it("pressing different key will added the current key to string", async () => {
const fsm = interpret(phoneKeypadMachine).start();
fsm.send({ type: "KEY.PRESSED", key: 0 });
fsm.send({ type: "KEY.PRESSED", key: 1 });
fsm.send({ type: "KEY.PRESSED", key: 2 });
await waitFor(500);
expect(fsm.getSnapshot().context.str).toBe("1AD");
});
What we do in the above test case is more or less the same as what we did in the previous test case. We interpret the FSM, start the actor from the FSM, and send events according to the functionality requirements.
We can run the test using Jest. First, open the terminal and run the following command:
yarn test phoneKey.fsm
If everything goes smoothly, your terminal should display the following message:
PASS src/features/phoneKeypad/phoneKey.fsm.spec.ts
phoneKeypad
✓ should be able to type using the keys (1628 ms)
✓ pressing different key will added the current key to string (508 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 3.962 s
All functionalities have been tested solely through the state machine! Now, it’s time to integrate the FSM with the UI!
I created a file named PhoneKeypad.tsx
which will later be imported into a Next.js page.
Here is the UI component without integration with FSM:
"use client";
import { useInterpret, useSelector } from "@xstate/react";
import { PHONE_KEYS } from "./constant";
export function PhoneKeypad() {
return (
<div className="h-screen w-screen flex flex-col justify-center items-center">
<div className="grid grid-cols-3 gap-4">
<div className="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4">
<p className="max-w-full overflow-x-auto flex-nowrap whitespace-nowrap">
Text will be displayed here...
{/* TODO: Add current str value */}
{/* TODO: Add current selected character preview */}
{/* TODO: Add blinking caret */}
</p>
</div>
{PHONE_KEYS.map((key, index) => (
<button
key={index}
className={[
"w-[90px] h-[90px] rounded-lg bg-gray-100 flex flex-col justify-center items-center",
"hover:bg-gray-200 cursor-pointer active:bg-gray-300"
].join(" ")}
style={{
userSelect: "none"
}}
// TODO: add "KEY.PRESSED" event
onClick={() => {}}
>
<p className="text-2xl">{key[key.length - 1]}</p>
<div className="gap-1 flex">
<span>{key}</span>
</div>
</button>
))}
</div>
</div>
);
}
The above code snippet includes placeholders for the text input by the user and buttons that will send the “KEY.PRESSED
” event.
First, we import hooks to integrate XState into a React component. Here I use useInterpret
and useSelector
. In XState 4, useInterpret
is a hook that returns an “actor” or “service” based on the given state machine. Unlike the interpret
used in the test, the “service” returned here automatically starts and runs for the lifetime of the React component.
useInterpret
returns a static reference from the FSM to the React component used only to interpret the FSM. Unlike useMachine
, which flush all updates to the React component causing re-renders on every update, updates to the FSM used by useInterpret
will not cause the React component to re-render.
Then how do we get the latest state from the FSM? We can use the useSelector
hook to select which parts of the FSM we want to monitor and cause re-renders in our component.
For this case, there are at least 4 things we want to track:
Context currentCharacterGroupIndex
and currentCharacterIndex
to display the currently selected character
Context str
to display the text that has been created
State isIdle
used to display a “cursor” or “caret” indicating the machine is waiting for input from the user.
Here is how to use useInterpret
and useSelector
:
export function PhoneKeypad() {
// ...
const fsm = useInterpret(phoneKeypadMachine);
const { currentCharacterGroupIndex, currentCharacterIndex, value, isIdle } =
useSelector(fsm, (state) => ({
currentCharacterGroupIndex: state.context.currentCharacterGroupIndex,
currentCharacterIndex: state.context.currentCharacterIndex,
value: state.context.str,
isIdle: state.matches("Idle")
}));
// ...
}
Then, we can use the value
to complete the first TODO, “Add current str value”
"use client";
import { useInterpret, useSelector } from "@xstate/react";
import { PHONE_KEYS } from "./constant";
export function PhoneKeypad() {
return (
<div className="h-screen w-screen flex flex-col justify-center items-center">
<div className="grid grid-cols-3 gap-4">
<div className="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4">
<p className="max-w-full overflow-x-auto flex-nowrap whitespace-nowrap">
{/* TODO: Add current str value */}
{value}
{/* TODO: Add current selected character preview */}
{/* TODO: Add blinking caret */}
</p>
</div>
{/* ... */}
</div>
</div>
);
}
The second TODO, “Add current selected character preview”, is completed by adding a character preview using currentCharacterGroupIndex
and currentCharacterIndex
// ...
export function PhoneKeypad() {
// ...
return (
<div className="h-screen w-screen flex flex-col justify-center items-center">
<div className="grid grid-cols-3 gap-4">
<div className="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4">
<p className="max-w-full overflow-x-auto flex-nowrap whitespace-nowrap">
{/* TODO: Add current str value */}
{value}
{/* TODO: Add current selected character preview */}
{selectedIndex != undefined &&
selectedIndexElement != undefined && (
<span>{PHONE_KEYS[selectedIndex][selectedIndexElement]}</span>
)}
{/* TODO: Add blinking caret */}
</p>
</div>
{/* ... */}
</div>
</div>
);
}
Lastly, to indicate that we will add text at the end of the existing text, we can add a caret or cursor when the FSM is in the “Idle
” state
// ...
import classes from "./phoneKeypad.module.css";
// ...
export function PhoneKeypad() {
// ...
return (
<div className="h-screen w-screen flex flex-col justify-center items-center">
<div className="grid grid-cols-3 gap-4">
<div className="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4">
<p className="max-w-full overflow-x-auto flex-nowrap whitespace-nowrap">
{/* TODO: Add current str value */}
{value}
{/* TODO: Add current selected character preview */}
{selectedIndex != undefined &&
selectedIndexElement != undefined && (
<span>{PHONE_KEYS[selectedIndex][selectedIndexElement]}</span>
)}
{/* TODO: Add blinking caret */}
<span
className={[classes.blinkingCaret, isIdle ? "" : "hidden"].join(
" "
)}
>
|
</span>
</p>
</div>
{/* ... */}
</div>
</div>
);
}
I also created a CSS module that will make the “caret
” blink every 500ms
:
/* phoneKeypad.module.css */
.blinkingCaret {
animation: blink 500ms infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
Finally, we send the “KEY.PRESSED
” event when a button is pressed:
"use client";
import { useInterpret, useSelector } from "@xstate/react";
import { PHONE_KEYS } from "./constant";
import { phoneKeypadMachine } from "./phoneKeypad.fsm";
import classes from "./phoneKeypad.module.css";
export function PhoneKeypad() {
// ...
return (
<div className="h-screen w-screen flex flex-col justify-center items-center">
<div className="grid grid-cols-3 gap-4">
{/* ... */}
{PHONE_KEYS.map((key, index) => (
<button
key={index}
className={[
"w-[90px] h-[90px] rounded-lg bg-gray-100 flex flex-col justify-center items-center",
"hover:bg-gray-200 cursor-pointer active:bg-gray-300"
].join(" ")}
style={{
userSelect: "none"
}}
// TODO: add "KEY.PRESSED" event
onClick={() => fsm.send({ type: "KEY.PRESSED", key: index })}
>
<p className="text-2xl">{key[key.length - 1]}</p>
<div className="gap-1 flex">
<span>{key}</span>
</div>
</button>
))}
</div>
</div>
);
}
Here is the complete code snippet:
"use client";
import { useInterpret, useSelector } from "@xstate/react";
import { PHONE_KEYS } from "./constant";
import { phoneKeypadMachine } from "./phoneKeypad.fsm";
import classes from "./phoneKeypad.module.css";
export function PhoneKeypad() {
const fsm = useInterpret(phoneKeypadMachine);
const { selectedIndex, selectedIndexElement, value, isIdle } = useSelector(
fsm,
(state) => ({
selectedIndex: state.context.currentCharacterGroupIndex,
selectedIndexElement: state.context.currentCharacterIndex,
value: state.context.str,
isIdle: state.matches("Idle")
})
);
return (
<div className="h-screen w-screen flex flex-col justify-center items-center">
<div className="grid grid-cols-3 gap-4">
<div className="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4">
<p className="max-w-full overflow-x-auto flex-nowrap whitespace-nowrap">
{value}
{selectedIndex != undefined &&
selectedIndexElement != undefined && (
<span>{PHONE_KEYS[selectedIndex][selectedIndexElement]}</span>
)}
<span
className={[classes.blinkingCaret, isIdle ? "" : "hidden"].join(
" "
)}
>
|
</span>
</p>
</div>
{PHONE_KEYS.map((key, index) => (
<button
key={index}
className={[
"w-[90px] h-[90px] rounded-lg bg-gray-100 flex flex-col justify-center items-center",
"hover:bg-gray-200 cursor-pointer active:bg-gray-300"
].join(" ")}
style={{
userSelect: "none"
}}
onClick={() => fsm.send({ type: "KEY.PRESSED", key: index })}
>
<p className="text-2xl">{key[key.length - 1]}</p>
<div className="gap-1 flex">
<span>{key}</span>
</div>
</button>
))}
</div>
</div>
);
}
When we run this application, it will look something like this:
{% embed https://www.loom.com/share/2c9cd3b3830849e4adc61a3da5f46f0e?sid=8a4ce46e-6900-4b31-bfea-83585f0ab786 %}
For instance, suppose this application is shipped, and we can continue with our lives peacefully. But one day, there’s a request to add a feature!
Users can write text but can’t delete it!
We can say there’s a new functionality added, “User can delete text.”
What can we do to implement this functionality?
Firstly, of course, we add the delete function. Here, I add a new event named “DELETE.PRESSED
” that can be sent when the FSM is in the “Idle
” state.
This event will trigger an action named “onDeleteLastChar” that will delete the last character in str
.
Do we immediately add this event to the UI? Certainly not!
After adding the delete functionality to the FSM, we need to write a test. Here’s the test case I wrote to test this functionality:
describe("phoneKeypad", () => {
// ...
it("pressing delete will remove the last char", async () => {
const fsm = interpret(phoneKeypadMachine.withConfig({})).start();
fsm.send({ type: "KEY.PRESSED", key: 0 });
fsm.send({ type: "KEY.PRESSED", key: 1 });
fsm.send({ type: "KEY.PRESSED", key: 2 });
await waitFor(500);
expect(fsm.getSnapshot().context.str).toBe("1AD");
fsm.send({ type: "DELETE.PRESSED" });
expect(fsm.getSnapshot().context.str).toBe("1A");
fsm.send({ type: "DELETE.PRESSED" });
fsm.send({ type: "DELETE.PRESSED" });
expect(fsm.getSnapshot().context.str).toBe("");
});
});
To ensure this test is passed and the previous test cases are also passed, open the terminal and run the following command again:
yarn test phoneKey.fsm
If everything goes smoothly, your terminal should display the following message:
PASS src/features/phoneKeypad/phoneKey.fsm.spec.ts
phoneKeypad
✓ should be able to type using the keys (1626 ms)
✓ pressing different key will added the current key to string (507 ms)
✓ pressing delete will remove the last char (514 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 4.621 s
What I like about having tests is that I can ensure all the existing functionalities still work according to the written test cases. If there are indeed changes to the functionalities, then the test cases should also change and adapt. But if not, I can still use the same test cases!
Finally, we only need to add a button to send the “DELETE.PRESSED
” event from the UI
// ...
export function PhoneKeypad() {
// ...
return (
<div className="h-screen w-screen flex flex-col justify-center items-center">
<div className="grid grid-cols-3 gap-4">
{/* ... */}
<button
className={[
"col-start-3 col-end-3",
"w-[90px] rounded-lg bg-gray-100 flex flex-col justify-center items-center py-4",
"hover:bg-gray-200 cursor-pointer active:bg-gray-300",
].join(" ")}
onClick={() => fsm.send({ type: "DELETE.PRESSED" })}
>
DEL
</button>
{PHONE_KEYS.map((key, index) => (
// ...
))}
</div>
</div>
);
}
Our application now looks like this:
In this article, I have shared how I test the FSM I created with XState4 imperatively using Jest. By writing tests, we gain the confidence that at least the FSM we created works according to the given test cases.
Please remember, XState version 5 (the latest) has slightly different APIs, but the principles are more or less the same. Additionally, XState also provides a package for testing with a model-based testing approach. This package can also automatically create test cases based on the provided state machine definition!
Thank you for reading my article! Have a nice day!