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 inthis repository.
Case Study
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.
(Sourcehttps://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.
Preparing the Project
Next.js
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-xstateWould you like to use TypeScript? YesWould you like to use ESLint? YesWould you like to use Tailwind CSS? YesWould you like to use `src/` directory? YesWould you like to use App Router? (recommended) YesWould you like to customize the default import alias (@/*)? YesWhat 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 thets-jest
preset.
yarn ts-jest config:init
Folder Structure
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.
Layering the application
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.
Domain Layer
Representation of the keypad
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)
exportconstPHONE_KEYS=['1','ABC2','DEF3','GHI4','JKL5','MNO6','PQRS7','TUV8','WXYZ9','*',' 0','#']
or, as array of characters (2-dimensional array)
exportconstPHONE_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 expressionPHONE_KEYS[characterGroupIndex][characterIndex]
to refer to a character
Finite State Machine
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:
exporttypeMachineContext={currentCharacterGroupIndex?:number;currentCharacterIndex?:number;lastPressedKey?:number;str:string;};
currentCharacterGroupIndex
is used to select a character group, andcurrentCharacterIndex
is used to select a character within that group. For example, to refer to the character “A”, the value ofcurrentCharacterIndex
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:
exporttypeMachineEvent=|{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 namedonFirstPress
which will change the value ofcurrentCharacterGroupIndex
to the "key
" carried by the "KEY.PRESSED
" event andcurrentCharacterIndex
to 0. In this action, we also store the "key
" carried in thelastPressedKey
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 thelastPressedKey
property stored in the context. If satisfied, theonNextPress
action on the event will be called. This action will increment the value ofcurrentCharacterIndex
. If the value ofcurrentCharacterIndex
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 within500ms
while in the "Waiting for key being pressed again
" state. In other words, the FSM will wait for500ms
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 atcurrentCharacterGroupIndex
andcurrentCharacterIndex
to the contextstr
. The "removeSelectedKey
" action will clear the values ofcurrentCharacterGroupIndex
,currentCharacterIndex
, andlastPressedKey
.
It is important to note that in the "Waiting for key being pressed again
" state, if a "KEY.PRESSED
" event is received, the500ms
wait time will reset from0
.
(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 propertiescurrentCharacterGroupIndex
,currentCharacterIndex
, andlastPressedKey
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.
Testing the FSM
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.
Writing Test Cases
First, I create a test file namedphoneKey.fsm.spec.ts
. Then, I add a test suite named “phoneKeypad”:
// phoneKey.fsm.spec.tsdescribe("phoneKeypad",()=>{// test cases will be written in here...})
The first test case ensures that functionalities 1, 2, and 3 are met:
constwaitFor=async(time:number)=>newPromise((r)=>setTimeout(r,time));describe("phoneKeypad",()=>{it("should be able to type using the keys",async()=>{constfsm=interpret(phoneKeypadMachine)fsm.start();fsm.send({type:"KEY.PRESSED",key:0});awaitwaitFor(500);expect(fsm.getSnapshot().context.str).toBe("1");fsm.send({type:"KEY.PRESSED",key:1});awaitwaitFor(200);fsm.send({type:"KEY.PRESSED",key:1});awaitwaitFor(200);fsm.send({type:"KEY.PRESSED",key:1});awaitwaitFor(200);fsm.send({type:"KEY.PRESSED",key:1});awaitwaitFor(500);expect(fsm.getSnapshot().context.str).toBe("12");});})
In the expression:
constfsm=interpret(phoneKeypadMachine.withConfig({})).start();
We interpret the already createdphoneKeypadMachine
using theinterpret
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, theinterpret
function is deprecated in XState5. If you are using XState5, you can use thecreateActor
function. (Ref)
The process stored in thefsm
variable has not yet started. To run it, we can use thestart
method.
fsm.start();
Next, we simulate a button being pressed.
Before defining the test suite, we define a function namedwaitFor
. This function is used to wait for a certain amount of time.
In these statements:
fsm.send({type:"KEY.PRESSED",key:0});awaitwaitFor(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:
exportconstPHONE_KEYS=['1','ABC2','DEF3','GHI4','JKL5','MNO6','PQRS7','TUV8','WXYZ9','*',' 0','#']
the selected character is “1
”. We then wait for500ms
. We check if the value ofcontext.str
is “1
”. This set of statements tests functionalities 1 and 3.
Then, in the following statements:
fsm.send({type:"KEY.PRESSED",key:1});awaitwaitFor(200);fsm.send({type:"KEY.PRESSED",key:1});awaitwaitFor(200);fsm.send({type:"KEY.PRESSED",key:1});awaitwaitFor(200);fsm.send({type:"KEY.PRESSED",key:1});awaitwaitFor(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 after500ms
, each time we press the button with key “1”, we wait for200ms
. We know that the button with key “1” refers to the second character group,'ABC2'
. Pressing it four times before500ms
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 existingstr
, so the current value ofstr
should now be “12”.
Finally, we test the fourth functionality:
it("pressing different key will added the current key to string",async()=>{constfsm=interpret(phoneKeypadMachine).start();fsm.send({type:"KEY.PRESSED",key:0});fsm.send({type:"KEY.PRESSED",key:1});fsm.send({type:"KEY.PRESSED",key:2});awaitwaitFor(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 totalTests: 2 passed, 2 totalSnapshots: 0 totalTime: 3.962 s
All functionalities have been tested solely through the state machine! Now, it's time to integrate the FSM with the UI!
Integrating FSM into UI
I created a file namedPhoneKeypad.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";exportfunctionPhoneKeypad(){return(<divclassName="h-screen w-screen flex flex-col justify-center items-center"><divclassName="grid grid-cols-3 gap-4"><divclassName="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4"><pclassName="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)=>(<buttonkey={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" eventonClick={()=>{}}><pclassName="text-2xl">{key[key.length-1]}</p><divclassName="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.
Integrating Machine
First, we import hooks to integrate XState into a React component. Here I useuseInterpret
anduseSelector
. In XState 4,useInterpret
is a hook that returns an “actor” or “service” based on the given state machine. Unlike theinterpret
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. UnlikeuseMachine
, which flush all updates to the React component causing re-renders on every update, updates to the FSM used byuseInterpret
will not cause the React component to re-render.
Then how do we get the latest state from the FSM? We can use theuseSelector
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
andcurrentCharacterIndex
to display the currently selected characterContext
str
to display the text that has been createdState
isIdle
used to display a “cursor” or “caret” indicating the machine is waiting for input from the user.
Here is how to useuseInterpret
anduseSelector
:
exportfunctionPhoneKeypad(){// ...constfsm=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 thevalue
to complete the first TODO, “Add current str value”
"use client";import{useInterpret,useSelector}from"@xstate/react";import{PHONE_KEYS}from"./constant";exportfunctionPhoneKeypad(){return(<divclassName="h-screen w-screen flex flex-col justify-center items-center"><divclassName="grid grid-cols-3 gap-4"><divclassName="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4"><pclassName="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 usingcurrentCharacterGroupIndex
andcurrentCharacterIndex
// ...exportfunctionPhoneKeypad(){// ...return(<divclassName="h-screen w-screen flex flex-col justify-center items-center"><divclassName="grid grid-cols-3 gap-4"><divclassName="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4"><pclassName="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
// ...importclassesfrom"./phoneKeypad.module.css";// ...exportfunctionPhoneKeypad(){// ...return(<divclassName="h-screen w-screen flex flex-col justify-center items-center"><divclassName="grid grid-cols-3 gap-4"><divclassName="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4"><pclassName="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 */}<spanclassName={[classes.blinkingCaret,isIdle?"":"hidden"].join("")}> |</span></p></div>{/* ... */}</div></div>);}
I also created a CSS module that will make the “caret
” blink every500ms
:
/* phoneKeypad.module.css */.blinkingCaret{animation:blink500msinfinite;}@keyframesblink{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";importclassesfrom"./phoneKeypad.module.css";exportfunctionPhoneKeypad(){// ...return(<divclassName="h-screen w-screen flex flex-col justify-center items-center"><divclassName="grid grid-cols-3 gap-4">{/* ... */}{PHONE_KEYS.map((key,index)=>(<buttonkey={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" eventonClick={()=>fsm.send({type:"KEY.PRESSED",key:index})}><pclassName="text-2xl">{key[key.length-1]}</p><divclassName="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";importclassesfrom"./phoneKeypad.module.css";exportfunctionPhoneKeypad(){constfsm=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(<divclassName="h-screen w-screen flex flex-col justify-center items-center"><divclassName="grid grid-cols-3 gap-4"><divclassName="w-full border border-solid col-span-3 h-[50px] max-w-[332px] flex justify-start items-center px-4"><pclassName="max-w-full overflow-x-auto flex-nowrap whitespace-nowrap">{value}{selectedIndex!=undefined&&selectedIndexElement!=undefined&&(<span>{PHONE_KEYS[selectedIndex][selectedIndexElement]}</span>)}<spanclassName={[classes.blinkingCaret,isIdle?"":"hidden"].join("")}> |</span></p></div>{PHONE_KEYS.map((key,index)=>(<buttonkey={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})}><pclassName="text-2xl">{key[key.length-1]}</p><divclassName="gap-1 flex"><span>{key}</span></div></button>))}</div></div>);}
When we run this application, it will look something like this:
Benefits of Testing
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?
Adding Delete Functionality to the FSM
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 instr
.
Do we immediately add this event to the UI? Certainly not!
Adding a New Test Case
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()=>{constfsm=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});awaitwaitFor(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:
yarntestphoneKey.fsm
If everything goes smoothly, your terminal should display the following message:
PASS src/features/phoneKeypad/phoneKey.fsm.spec.ts phoneKeypad ✓ should be able totypeusing 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 totalTests: 3 passed, 3 totalSnapshots: 0 totalTime: 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!
Adding a Delete Button
Finally, we only need to add a button to send the “DELETE.PRESSED
” event from the UI
// ...exportfunctionPhoneKeypad(){// ...return(<divclassName="h-screen w-screen flex flex-col justify-center items-center"><divclassName="grid grid-cols-3 gap-4">{/* ... */}<buttonclassName={["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:
Conclusion
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!
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse