DEV Community

Ibรฉ Dwi
Ibรฉ Dwi

Posted on

๐Ÿ‡ฎ๐Ÿ‡ฉ Menguji FSM XState dengan Jest

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.


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

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

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

Pada tulisan ini, saya menggunakan XState 4

yarn add xstate@^4.38.1 @xstate/react@^3.2.2
Enter fullscreen mode Exit fullscreen mode

jest dan ts-jest

Install libraries berikut ini:

 yarn add -D jest ts-jest @types/jest
Enter fullscreen mode Exit fullscreen mode
  • 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

yarn ts-jest config:init
Enter fullscreen mode Exit fullscreen mode

Struktur folder

Berikut adalah struktur folder:

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

'abc2'
Enter fullscreen mode Exit fullscreen mode

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)
   export const PHONE_KEYS = [
     '1',
     'ABC2',
     'DEF3',
     'GHI4',
     'JKL5',
     'MNO6',
     'PQRS7',
     'TUV8',
     'WXYZ9',
     '*',
     ' 0',
     '#'
   ]
Enter fullscreen mode Exit fullscreen mode
  1. Atau sebagai 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

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

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:

export type MachineContext = {
  currentCharacterGroupIndex?: number;
  currentCharacterIndex?: number;
  lastPressedKey?: number;
  str: string;
};
Enter fullscreen mode Exit fullscreen mode

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

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:

export type MachineEvent =
  | {
      type: "KEY.PRESSED";
      key: number;
    };
Enter fullscreen mode Exit fullscreen mode

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

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

(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

(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

(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

(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โ€:

// phoneKey.fsm.spec.ts

describe("phoneKeypad", () => {
  // test cases will be written in here...
})
Enter fullscreen mode Exit fullscreen mode

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

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

Pada expression:

const fsm = interpret(phoneKeypadMachine.withConfig({})).start();
Enter fullscreen mode Exit fullscreen mode

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)

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

fsm.start();
Enter fullscreen mode Exit fullscreen mode

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

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

kita mengirim event โ€œKEY.PRESSEDโ€ dengan key 0 ke state machine. Jika merujuk pada tombol yang sudah kita buat:

export const PHONE_KEYS = [
  '1',
  'ABC2',
  'DEF3',
  'GHI4',
  'JKL5',
  'MNO6',
  'PQRS7',
  'TUV8',
  'WXYZ9',
  '*',
  ' 0',
  '#'
]
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

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

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:

yarn test phoneKey.fsm
Enter fullscreen mode Exit fullscreen mode

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

 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

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:

"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

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 :

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

Kemudian, kita bisa menggunakan value untuk menyelesaikan TODO pertama, โ€œ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

TODO yang kedua, โ€œAdd current selected character previewโ€, diselesaikan dengan menambahkan preview karakter menggunakan currentCharacterGroupIndex dan 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

Yang terakhir, untuk menandakan kita akan menambahkan teks di akhir teks, kita bisa menambahkan caret atau cursor ketika FSM sedang berada dalam state โ€œIdleโ€

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

Saya juga membuat sebuah CSS modules yang akan membuat โ€œcaretโ€ berkedip setiap 500ms:

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

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

Enter fullscreen mode Exit fullscreen mode

Yang terakhir, kita mengirim event โ€œKEY.PRESSEDโ€ ketika tombol dipencet:

"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

Berikut adalah potongan kode keseluruhan:

"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

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


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

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:

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

Untuk memastikan bahwa test ini berhasil dan test cases sebelumnya juga berhasil, buka terminal dan jalankan kembali perintah berikut:

yarn test phoneKey.fsm
Enter fullscreen mode Exit fullscreen mode

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

 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

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

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

Aplikasi kita sekarang terlihat seperti ini:


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!

Top comments (1)

Collapse
 
euse44 profile image
euse44 • Edited

VoIP services can implement call queuing, which places incoming calls on hold in a queue until an available agent is ready to handle them VoIP Security. Call queuing ensures that callers are not left waiting indefinitely and creates a more organized and efficient call handling process.