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 (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.