DEV Community

Ibé Dwi
Ibé Dwi

Posted on • Edited on

Testing XState FSM using Jest

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.


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.

Nokia 3310

(Source https://www.gsmarena.com/nokia_3310-pictures-192.php)

Some functionalities we aim to achieve:

  1. Pressing a button for the first time will select the character group on that button and choose the first character in the group.

  2. Repeatedly pressing the same button will select characters according to their order.

  3. If no button is pressed after a set time, the currently selected character will be inserted into the text.

  4. 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-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? @/*


Enter fullscreen mode Exit fullscreen mode

xstate & @xstate/react

In this article, I am using XState version 4.



yarn add xstate@^4.38.1 @xstate/react@^3.2.2


Enter fullscreen mode Exit fullscreen mode

jest & ts-jest

Install the following libraries:



 yarn add -D jest ts-jest @types/jest


Enter fullscreen mode Exit fullscreen mode
  • 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


Enter fullscreen mode Exit fullscreen mode

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
│  │  ├─ ...
│  ├─ ../
├─ ...


Enter fullscreen mode Exit fullscreen mode

.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'


Enter fullscreen mode Exit fullscreen mode

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',
     '#'
   ]


Enter fullscreen mode Exit fullscreen mode

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"],
     ["#"],
   ];



Enter fullscreen mode Exit fullscreen mode

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:

Illustration of the mapping

From the illustration of the mapping above, we can use the expression PHONE_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:



export type MachineContext = {
  currentCharacterGroupIndex?: number;
  currentCharacterIndex?: number;
  lastPressedKey?: number;
  str: string;
};


Enter fullscreen mode Exit fullscreen mode

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.

Illustration of character selection

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;
    }


Enter fullscreen mode Exit fullscreen mode

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:

Overall FSM

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.

First functionality FSM

(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.

Second functionality FSM

(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.

Third functinoality in FSM

(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.

Fourth functionality in FSM

(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:

  1. 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.

  2. 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 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...
})


Enter fullscreen mode Exit fullscreen mode

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");
  });
})


Enter fullscreen mode Exit fullscreen mode

In the expression:



const fsm = interpret(phoneKeypadMachine.withConfig({})).start();


Enter fullscreen mode Exit fullscreen mode

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();


Enter fullscreen mode Exit fullscreen mode

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");


Enter fullscreen mode Exit fullscreen mode

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',
  '#'
]


Enter fullscreen mode Exit fullscreen mode

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");


Enter fullscreen mode Exit fullscreen mode

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");
});


Enter fullscreen mode Exit fullscreen mode

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



Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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 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>
  );
}


Enter fullscreen mode Exit fullscreen mode

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 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:

  1. Context currentCharacterGroupIndex and currentCharacterIndex to display the currently selected character

  2. Context str to display the text that has been created

  3. 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"),
    })
  );

  // ...
}


Enter fullscreen mode Exit fullscreen mode

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>
  );
}


Enter fullscreen mode Exit fullscreen mode

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>
  );
}


Enter fullscreen mode Exit fullscreen mode

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>
  );
}


Enter fullscreen mode Exit fullscreen mode

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;
  }
}



Enter fullscreen mode Exit fullscreen mode

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>
  );
}


Enter fullscreen mode Exit fullscreen mode

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>
  );
}



Enter fullscreen mode Exit fullscreen mode

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.

Delete functionality in FSM

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!

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 () => {
    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("");
  });
});


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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



// ...
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>
  );
}


Enter fullscreen mode Exit fullscreen mode

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)