🇮🇩 Menguji FSM XState dengan Jest
Tags: xstate, finitestatemachine, fsm, jest
Published: 2023-12-26 12:54:49

Setelah beberapa kontribusi saya ke salah satu *open source*, saya menjadi semakin paham tentang pentingnya menulis *test*. Salah satu keuntungan dari *project* yang memiliki tests adalah kita bisa dengan cepat memverifikasi apakah fitur yang sudah ada rusak ketika kita membuat perubahan. Bahkan, ketika membuat sebuah fitur, kita bisa dengan cepat memastikan apakah fitur itu sudah bekerja sesuai dengan kasus yang diberikan melalui test. 

Keuntungan yang sama juga bisa didapatkan ketika menulis *finite state machine* (FSM) menggunakan XState. Pertanyaan yang muncul di kepala saya, “bagaimana jika kita bisa dengan cepat memverifikasi FSM kita bekerja saat kita sedang membuatnya?” dan “apakah fitur baru yang ditambahkan tidak merusak fungsionalitas yang sudah ada?”.

Di tulisan ini, saya akan berbagi tentang bagaimana saya menyertakan pengujian ketika mengembangkan FSM.

Sebelum lanjut, sebagai *disclaimer*, XState juga menyediakan *package* untuk menuliskan *test*. Lebih canggih lagi, package tersebut bisa membuat test cases secara otomatis. Tetapi, tulisan ini berfokus pada **bagaimana saya menguji FSM yang sudah dibuat terhadap test cases yang kita buat sendiri (secara imperatif)**.

Aplikasi yang dibuat di tulisan ini dapat dilihat [di repo ini](https://github.com/ibedwi/logs-fsm-with-xstate/tree/2023.12.26).

---

## Studi kasus

Bagi saya, salah satu belajar yang seru adalah dengan menggunakan studi kasus.

Untuk tulisan ini, saya akan menggunakan “phone keypad” sebagai studi kasus. Bagi kamu yang belum tahu, “phone keypad” ini adalah bentuk “keyboard” yang ada di handphone lama.


![Ilustrasi HP Nokia 3310](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bh6aezf1dft8tlo4ekoa.jpeg)

(Sumber: <https://www.gsmarena.com/nokia_3310-pictures-192.php>)

Beberapa fungsionalitas yang ingin dicapai:

1. Menekan sebuah tombol untuk pertama kali akan memilih grup karakter yang ada pada tombol tersebut dan memilih karakter pertama yang ada pada grup karakter.

2. Menekan tombol yang sama berulang-ulang akan memilih karakter sesuai dengan urutan.

3. Jika tidak ada tombol yang ditekan setelah waktu yang ditentukan, karakter yang dipilih saat ini akan dimasukkan ke dalam teks.

4. Menekan tombol yang berbeda akan memasukkan karakter yang terpilih saat ini ke dalam teks dan mengganti karakter yang sedang dipilih menjadi karakter pertama pada tombol yang ditekan.



## Menyiapkan Project

### Next.js

Saya menggunakan Next.js 13 dengan app *directory* dan TailwindCSS. Tutorial membuat project Next.js dengan *app router* dapat ditemukan [di link ini](https://nextjs.org/docs/getting-started/installation).

```bash
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`

Pada tulisan ini, saya menggunakan XState 4

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

### `jest` dan `ts-jest`

*Install libraries* berikut ini:

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

- `jest` adalah library yang digunakan untuk *testing*.

- `ts-jest` adalah *library* yang memungkinkan kita untuk langsung menjalankan *test* yang ditulis menggunakan TypeScript tanpa harus melakukan transpilasi ke JS.

- `@types/jest` adalah *type definition* untuk `jest`

Kemudian, eksekusi baris berikut untuk menginisiasi konfigurasi `jest` dengan preset `ts-jest`

```bash
yarn ts-jest config:init
```

### Struktur _folder_

Berikut adalah struktur *folder*:

```plain
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` adalah file yang berisi definisi dari state machine kita

`PhoneKeypad` adalah component yang mengimplementasikan state machine yang akan kita buat dan mengintegrasikannya dengan UI.

`phone-keypad/page.tsx` adalah halaman di mana kita menampilkan keypad yang sudah dibuat.

---

## Membuat lapisan aplikasi

Memisahkan aplikasi menjadi beberapa lapisan (*layer*) terpisah sesuai dengan tanggung jawabnya membuat kita lebih mudah memelihara aplikasi kita. Prinsip ini dikenal dengan nama “*separation of concern*”. Di tulisan ini, saya membagi aplikasi ini menjadi 2 lapisan, *UI layer (presentation layer)* dan *domain layer.* 

*UI Layer* adalah lapisan yang teridiri dari tampilan, misalnya halaman *web* atau *component*. Sedangkan *domain layer* adalah lapisan yang berisi *business logic*, dalam hal ini adalah FSM.

### Domain Layer - FSM

#### Representasi keypad (tombol)

Hal yang pertama saya lakukan adalah membuat representasi dari keypad yang akan ditampilkan. Berdasarkan kriteria fungsionalitas di atas, keypad akan ditekan satu hingga beberapa kali untuk mendapatkan karakter yang diinginkan. Sebagai contoh, kita di handphone Nokia 3310, key nomor 2 terdiri dari 3 alfabet dan 1 angka:

```plain
'abc2'
```

Untuk mencetak huruf “b”, saya harus menekan tombol sebanyak 2 kali. Tekanan pertama akan menampilkan huruf “a”, dan tekanan kedua dalam kurun waktu tertentu akan menampilkan huruf “b”.

Setidaknya ada dua alternatif yang terpikirkan oleh saya untuk merepresentasikan keys yang ada:

1. Menggunakan _array of string_ (_1-dimensional array_)

   ```ts
   export const PHONE_KEYS = [
     '1',
     'ABC2',
     'DEF3',
     'GHI4',
     'JKL5',
     'MNO6',
     'PQRS7',
     'TUV8',
     'WXYZ9',
     '*',
     ' 0',
     '#'
   ]
   ```

2. Atau sebagai _array of characters_ (_2-dimensional array_)

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

Di sini, saya menggunakan opsi pertama. Tidak ada alasan spesifik, hanya preferensi pribadi.

Nantinya, *array* ini dapat digunakan untuk membuat tampilan keypad. Kurang lebih seperti ini:


![Ilustrasi mapping array](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ejbzy9cmpcnxxqze6m28.png)


Dari ilustrasi pemetaan di atas, kita bisa menggunakan expression `PHONE_KEYS[characterGroupIndex][characterIndex]` untuk mengacu pada sebuah karakter.

#### Finite State Machine

Mengingat fokus tulisan ini adalah bagaimana saya menguji FSM yang saya buat, saya sudah membuat FSM yang akan digunakan. 

Berikut adalah *context* yang akan digunakan oleh FSM ini:

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

`currentCharacterGroupIndex` digunakan untuk memilih grup karakter dan `currentCharacterIndex` digunakan untuk memilih karakter yang ada pada grup tersebut. Misanya, untuk mengacu pada karakter “A”, nilai dari `currentCharacterIndex` adalah 0. Untuk mengacu pada karakter “B”, nilai yang dipakai adalah 1, dan seterusnya.


![Ilustrasi pilihan karakter](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/108xlah4x9l7aig5qy1x.png)

`lastPressedKey` digunakan untuk melacak tombol terakhir yang dipencet. Yang terakhir, `str` digunakan untuk menyimpan teks yang kita ketik.

*Event* yang diterima oleh FSM ada 1, yaitu:

```ts
export type MachineEvent =
  | {
      type: "KEY.PRESSED";
      key: number;
    };
```

*Event* “`KEY.PRESSED`” memberi tahu FSM bahwa ada tombol yang ditekan. *Event* ini membawa *property* “`key`” yang akan memberi tahu mesin grup karakter mana yang akan digunakan.

_Behavior_ keseluruhan dari FSM dapat dilihat pada diagram berikut:

![Behavior keseluruhan FSM](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hy62zokwfq29etd5l76c.png)

Fungsionalitas pertama,

> Menekan sebuah tombol untuk pertama kali akan memilih grup karakter yang ada pada tombol tersebut dan memilih karakter pertama yang ada pada grup karakter.

terpenuhi ketika transisi dari *state* “`Idle`” ke “`Waiting for key being pressed again`”. Pada *event* yang dikirim, “`KEY.PRESSED`”, ada *action* bernama `onFirstPress` yang akan merubah nilai dari `currentCharacterGroupIndex` menjadi “`key`” yang dibawa oleh *event* “`KEY.PRESSED`” dan `currentCharacterIndex` menjadi 0. Pada *action* ini kita juga menyimpan “`key`” yang dibawa di *property* “`lastPressedKey`” sebagai referensi untuk fungsionalitas kedua.


![Bagian di mana fungsionalitas pertama terpenuhi](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kr5dhelfgpops4ftk35c.png)

(Bagian di mana fungsionalitas pertama terpenuhi)


Fungsionalitas kedua,

> Menekan tombol yang sama berulang-ulang akan memilih karakter sesuai dengan urutan.

terpenuhi ketika *state* “`Waiting for key being pressed again`” menerima *event* “`KEY.PRESSED`” tetapi *guard* “`isTheSameKey?`” terpenuhi. *Guard* “`isTheSameKey?`” mengecek apakah “`key`” yang dibawa oleh *event* “`KEY.PRESSED`” sama dengan *property* “`lastPressedKey`” yang disimpan di `context`. Jika terpenuhi, *action* `onNextPress` yang ada pada event akan dipanggil. *Action* ini akan menambah nilai dari `currentCharacterIndex` . Jika nilai `currentCharacterIndex` sudah mencapai karakter terakhir, nilainya akan kembali menjadi 0.


![Bagian di mana fungsionalitas kedua terpenuhi](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vv4chpidxcdhs2ec6cxc.png)

(Bagian di mana fungsionalitas kedua terpenuhi)

Fungsionalitas ketiga,

> Jika tombol tidak diklik setelah waktu yang ditentukan, karakter yang dipilih saat ini akan dimasukkan ke dalam teks.

terpenuhi saat tidak ada event “`KEY.PRESSED`” yang diterima dalam kurun waktu `500ms` saat berada pada *state* “`Waiting for key being pressed again`”. Atau dengan kata lain, FSM akan menunggu selama `500ms` sebelum memicu *event* “`after 500ms`”. *State* akan transisi ke “`Waited time passed`”, yang kemudian transisi ke *state* “`Idle`” dan memicu *action* “`assignToString`” dan “`removeSelectedKey`” secara berurutan. 

Action “assignToString” akan menambahkan karakter pada grup karakter `currentCharacterGroupIndex` dan karakter `currentCharacterIndex` ke dalam *context* `str`. Sedangkan *action* “`removeSelectedKey`” akan menghapus nilai dari `currentCharacterGroupIndex`, `currentCharacterIndex`, dan `lastPressedKey`.

Perlu diingat, pada state `Waiting for key being pressed again`, jika ada “`KEY.PRESSED`” yang diterima maka waktu tunggu “`500ms`” akan diulang dari `0`.


![Bagian di mana fungsionalitas ketiga terpenuhi](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/55ga75kqb0n67n1rqqev.png)

(Bagian di mana fungsionalitas ketiga terpenuhi)

Fungsionalitas yang terakhir,

> Menekan tombol yang berbeda akan memasukkan karakter yang terpilih saat ini ke dalam teks dan mengganti karakter yang sedang dipilih menjadi karakter pertama pada tombol yang diklik.

terpenuhi ketika pada *state* “`Waiting for key being pressed again`”, ada *event* “`KEY.PRESSED`” yang diterima tetapi tidak memenuhi *guard* “`isTheSameKey?`”. *Event* ini akan memicu action “`assignToString`”, “`removeSelectedKey`”, dan “`onFirstPress`”. Kalau kita perhatikan, 2 *actions* pertama pada *event* ini sama dengan ketika kita menambahkan karakter yang diacu saat ini ke dalam *string*. Sedangkan, *action* “`onFirstPress`” yang ada pada urutan terakhir akan memperbarui *properties* `currentCharacterGroupIndex`, `currentCharacterIndex`, dan `lastPressedKey` sesuai dengan *property* “`key`” yang dibawa oleh event “`KEY.PRESSED`”.


![Bagian di mana fungsionalitas keempat terpenuhi](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jqo92y6mzbr4fgzctfbb.png)

(Bagian di mana fungsionalitas keempat terpenuhi)

Definisi FSM yang lengkap dapat kamu lihat di repo.

---

## Menguji FSM

Akhirnya kita masuk ke inti dari tulisan ini!

Sebelum mengenal *testing*, yang saya lakukan untuk memverifikasi state machine saya adalah dengan mengujinya langsung bersamaan dengan UI! Tetapi, semakin kompleks FSM yang saya punya, akan semakin sulit untuk menguji *state* yang ada pada tahap-tahap tertentu, misalnya state yang mendekati *final state*.

Bagi saya, ada 2 hal yang menguntungkan dengan menguji FSM yang saya buat:

1. dalam proses *development*, saya bisa memverifikasi bahwa FSM yang saya buat bekerja sesuai dengan keinginan saya tanpa harus menyentuh UI. Kembali ke prinsip *layering* di atas, FSM ada *logic layer* sedangkan UI berada pada UI layer (*presentation layer*). UI layer tidak ada sangkut pautnya dengan kebenaran dari *logic layer*.

2. Jika ada perubahan pada FSM, saya bisa dengan cepat memverifikasi kembali apakah FSM saya masih bekerja sesuai dengan ekspektasi yang ada sebelumnya, yang dituangkan dalam *test cases*.

Lalu, apa saja yang harus diuji?

Untuk kasus dalam tulisan ini, saya mengambil fungsionalitas yang ada sebagai acuan untuk testing.

### Menulis test cases

Pertama, saya membuat sebuah file test bernama `phoneKey.fsm.spec.ts`. Kemudian, saya menambahkan sebuah test suite bernama “phoneKeypad”:

```ts
// phoneKey.fsm.spec.ts

describe("phoneKeypad", () => {
  // test cases will be written in here...
})
```

Test case yang pertama adalah saya memastikan bahwa fungsionalitas 1, 2, dan 3 terpenuhi:

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

Pada expression:

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

Kita menggunakan menginterpretasikan `phoneKeypadMachine` yang sudah dibuat menggunakan fungsi “`interpret`”. Fungsi ini akan mengembalikan proses yang berjalan berdasarkan FSM yang sudah kita buat. Proses ini disebut “actor”.

Sebagai catatan, fungsi `interpret` sudah deprecated pada XState5. Jika kamu menggunakan XState5, kamu bisa menggunakan fungsi `createActor`. ([Ref](https://stately.ai/docs/migration#use-createactor-not-interpret))

Proses yang disimpan di dalam variable `fsm` belum berjalan. Untuk menjalankannya, kita bisa menggunakan method `start`

```ts
fsm.start();
```

Kemudian, kita mensimulasikan tombol dipencet. 

Sebelum definisi *test suite,* kita mendefisinikan sebuah fungsi bernama `waitFor`. Fungsi ini digunakan untuk menunggu selama beberapa waktu.

Beberapa statement ini

```ts
fsm.send({ type: "KEY.PRESSED", key: 0 });
await waitFor(500);
expect(fsm.getSnapshot().context.str).toBe("1");
```

kita mengirim event “KEY.PRESSED” dengan key `0` ke state machine. Jika merujuk pada tombol yang sudah kita buat:

```ts
export const PHONE_KEYS = [
  '1',
  'ABC2',
  'DEF3',
  'GHI4',
  'JKL5',
  'MNO6',
  'PQRS7',
  'TUV8',
  'WXYZ9',
  '*',
  ' 0',
  '#'
]
```

karakter yang terpilih adalah “`1`”. Kemudian kita menunggu selama `500ms`. Kita kemudian mengecek apakah nilai dari `context.str` adalah “`1`”. Kumpulan statements tersebut menguji fungsionalitas 1 dan 3.

Kemudian, pada statements berikut:

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

Kita mengirim *event* “`KEY.PRESSED`” sebanyak 4 kali dengan *key* “`1`”. Untuk memastikan karakter hanya ditambahkan setelah 500ms, setiap kali memencet tombol dengan key “1” kita menunggu selama 200ms. Kita tahu bahwa tombol dengan key “1” akan mengacu pada grup karakter kedua, `'ABC2'`. Memencet sebanyak 4 kali dalam sebelum 500ms terpenuhi akan membuat karakter yang dipilih adalah karakter ke-4, yaitu “2”.

Di akhir statement, kita menguji apakah karakter “2” sudah ditambahkan ke `str` yang sudah ada sehingga nilai `str` saat ini harusnya adalah “12”.



Yang terakhir, kita menguji fungsionalitas 4:

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

Yang kita lakukan pada *test case* di atas kurang lebih sama dengan apa yang kita lakukan pada *test case* sebelumnya. Kita menginterpretasikan FSM, menjalankan *actor* dari FSM, dan mengirim *events* sesuai dengan ketentuan fungsionalitas.

Kita bisa menjalankan test dengan menggunakan Jest. Pertama buka terminal dan jalankan perintah berikut:

```bash
yarn test phoneKey.fsm
```

Jika semua berjalan lancar, seharusnya terminal kamu menampilkan pesan berikut:

```plain
 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
```

Semua fungsionalitas sudah diuji hanya melalui state machine! Sekarang, saatnya mengintegrasikan FSM dengan UI!

---

## Mengintegrasikan FSM ke UI

Saya membuat *file* bernama `PhoneKeypad.tsx` yang nantinya akan di-*import* ke dalam halaman Next.js.

Berikut adalah UI *component* tanpa integrasi dengan FSM:

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

Potongan kode di atas mencakup *placeholder* untuk teks yang diinput oleh user dan tombol-tombol yang akan mengirim “`KEY.PRESSED`” *event*.


### Mengintegrasikan FSM ke React component

Pertama, kita *import hooks* untuk mengintegrasikan XState ke React *component*. Di sini saya menggunakan `useInterpret` dan `useSelector`. Di XState 4, `useInterpret` adalah *hook* yang mengembalikan sebuah “actor” atau “service” berdasarkan *state machine* yang diberikan. Berbeda dengan `interpet` yang kita gunakan pada *test*, “service” yang dikembalikan akan otomatis dimulai dan dijalankan selama masa hidup React component. 

`useInterpret` mengembalikan *static reference* dari FSM ke React component yang hanya digunakan untuk menginterpretasikan FSM. Berbeda dengan `useMachine` yang akan mengalirkan seluruh pembaruan ke React component yang mengakibatkan *re-render* setiap pembaruan, pembaruan pada FSM yang digunakan oleh `useInterpret` tidak akan mengakibatkan *re-render* pada React component.

Lalu bagaimana kita bisa mendapatkan *state* terbaru dari FSM? Kita bisa menggunakan hooks `useSelector` untuk memilih bagian apa saja dari FSM yang ingin kita perhatikan dan mengakibatkan re-render pada component kita.

Untuk kasus ini, setidaknya ada 4 hal yang ingin kita lacak:

1. Context `currentCharacterGroupIndex` dan `currentCharacterIndex` untuk menampilkan karakter yang sedang dipilih saat ini

2. Context `str` untuk menampilkan teks yang sudah dibuat

3. State `isIdle` yang digunakan untuk menampilkan “cursor” atau “caret” yang menandakan machine lagi menunggu input dari user.

Berikut adalah cara menggunakan `useInterpret` dan `useSelector` :

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

  // ...
}
```

Kemudian, kita bisa menggunakan `value` untuk menyelesaikan TODO pertama, “*Add current str value*”

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

TODO yang kedua, “Add current selected character preview”, diselesaikan dengan menambahkan *preview* karakter menggunakan `currentCharacterGroupIndex` dan `currentCharacterIndex`

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

Yang terakhir, untuk menandakan kita akan menambahkan teks di akhir teks, kita bisa menambahkan *caret* atau *cursor* ketika FSM sedang berada dalam *state* “`Idle`”

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

Saya juga membuat sebuah CSS modules yang akan membuat “`caret`” berkedip setiap `500ms`:

```css
/* phoneKeypad.module.css */
.blinkingCaret {
  animation: blink 500ms infinite;
}

@keyframes blink {
  50% {
    opacity: 0;
  }
}

```

Yang terakhir, kita mengirim *event* “`KEY.PRESSED`” ketika tombol dipencet:

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

Berikut adalah potongan kode keseluruhan:

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

```

Jika kita menjalankan aplikasi ini, kurang lebih akan seperti ini:

{% embed https://www.loom.com/share/2c9cd3b3830849e4adc61a3da5f46f0e?sid=8a4ce46e-6900-4b31-bfea-83585f0ab786 %}

---

## Manfaat dari testing

Misalnya, aplikasi ini sudah di-*ship* dan kita sudah bisa melanjutkan hidup dengan tenang. Tapi, pada suatu hari, ada permintaan untuk menambah fitur!

User bisa menulis teks tapi tidak bisa menghapusnya!

Kita bisa bilang, ada tambahan fungsionalitas baru, “User bisa menghapus teks”.

Apa yang bisa kita lakukan untuk menyelesaikan fungsionalitas ini?

### Menambahkan fungsionalitas hapus ke FSM

Yang pertama tentunya menambahkan fungsi hapus. Di sini, saya menambahkan *event* baru bernama “`DELETE.PRESSED`” yang bisa dikirim ketika FSM sedang berada dalam keadaan “`Idle`”

![Menambahkan fungsi hapus](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ze7vt1xb4hji72kmvtnf.png)

*Event* ini akan memicu *action* bernama “`onDeleteLastChar`“ yang akan menghapus karakter terakhir yang ada pada `str`.

Apakah kita langsung menambahkan *event* ini ke dalam UI? Tentu tidak!

### Menambahkan *test case* baru

Setelah menambahkan fungsionalitas hapus pada FSM, kita perlu menulis *test*. Berikut *test case* yang saya tulis untuk menguji fungsionalitas ini:

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

Untuk memastikan bahwa _test_ ini berhasil dan _test cases_ sebelumnya juga berhasil, buka terminal dan jalankan kembali perintah berikut:

```bash
yarn test phoneKey.fsm
```

Jika semua berjalan lancar, seharusnya terminal kamu menampilkan pesan berikut:

```plain
 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
```

Hal yang saya sukai dari adanya *test* adalah saya bisa memastikan apakah seluruh fungsionalitas yang sudah ada tetap berjalan sesuai dengan *test cases* yang sudah ditulis. Jika memang ada perubahan terhadap fungsionalitas, tentunya *test cases* juga harus ikut berubah dan menyesuaikan. Tapi jika tidak, saya tetap bisa menggunakan *test cases* yang sama!

### Menambahkan tombol hapus

Yang terakhir, kita hanya perlu menambahkan tombol untuk mengirim *event* “`DELETE.PRESSED`“ dari UI

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

Aplikasi kita sekarang terlihat seperti ini:

{% embed https://www.loom.com/share/15dd14d0672a4d42a031909b0c193933?sid=1fcfcd57-c2b0-4714-8a7a-0b0581b3560c %}

---

## Penutup

Di tulisan ini, saya sudah bercerita tentang bagaimana saya menguji FSM yang sudah saya buat dengan XState4 secara imperatif menggunakan Jest. Dengan menulis *test*, kita punya kepercayaan diri bahwa setidaknya FSM yang kita buat sudah berjalan sesuai dengan *test cases* yang diberikan.

Perlu diingat, XState versi 5 (terbaru) memiliki API yang sedikit berbeda tetapi prinsipnya kurang lebih sama. Selain itu, XState juga menyediakan *package* untuk melakukan pengujian dengan pendekatan model-based testing. *Package* tersebut juga bisa membuat *test cases* secara otomatis berdasarkan definisi state machine yang diberikan!

Terima kasih sudah membaca tulisan saya! *Have a nice day!*