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.
(Sumber: https://www.gsmarena.com/nokia_3310-pictures-192.php)
Beberapa fungsionalitas yang ingin dicapai:
Menekan sebuah tombol untuk pertama kali akan memilih grup karakter yang ada pada tombol tersebut dan memilih karakter pertama yang ada pada grup karakter.
Menekan tombol yang sama berulang-ulang akan memilih karakter sesuai dengan urutan.
Jika tidak ada tombol yang ditekan setelah waktu yang ditentukan, karakter yang dipilih saat ini akan dimasukkan ke dalam teks.
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? @/*
xstate
& @xstate/react
Pada tulisan ini, saya menggunakan XState 4
yarn add xstate@^4.38.1 @xstate/react@^3.2.2
jest
dan ts-jest
Install libraries berikut ini:
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 untukjest
Kemudian, eksekusi baris berikut untuk menginisiasi konfigurasi jest
dengan preset ts-jest
yarn ts-jest config:init
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
โ โ โโ ...
โ โโ ../
โโ ...
.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'
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:
- Menggunakan array of string (1-dimensional array)
export const PHONE_KEYS = [
'1',
'ABC2',
'DEF3',
'GHI4',
'JKL5',
'MNO6',
'PQRS7',
'TUV8',
'WXYZ9',
'*',
' 0',
'#'
]
- 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"],
["#"],
];
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:
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;
};
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.
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;
};
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:
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)
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)
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)
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)
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:
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.
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...
})
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");
});
})
Pada expression:
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)
Proses yang disimpan di dalam variable fsm
belum berjalan. Untuk menjalankannya, kita bisa menggunakan method start
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
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:
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:
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:
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:
yarn test phoneKey.fsm
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
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>
);
}
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:
Context
currentCharacterGroupIndex
dancurrentCharacterIndex
untuk menampilkan karakter yang sedang dipilih saat iniContext
str
untuk menampilkan teks yang sudah dibuatState
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"),
})
);
// ...
}
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>
);
}
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>
);
}
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>
);
}
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;
}
}
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>
);
}
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>
);
}
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
โ
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("");
});
});
Untuk memastikan bahwa test ini berhasil dan test cases sebelumnya juga berhasil, buka terminal dan jalankan kembali perintah berikut:
yarn test phoneKey.fsm
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
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>
);
}
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)
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.