รู้จักกับ Storybook และการทำ Component Specs

/ 15 min read

Share on social media

react-storybook สามารถดู video ของหัวข้อนี้ก่อนได้ ดู video

การทำ Component Specs

การทำ Component Specs ใน Frontend คือกระบวนการในการกำหนดและระบุคุณสมบัติและพฤติกรรมของ Component ในการพัฒนา software ซึ่งจะช่วยให้ทีมพัฒนาสามารถทำงานร่วมกันได้ตรงตามความต้องการ และช่วยให้ทราบถึงความสามารถและข้อจำกัดของแต่ละ Component ที่ถูกสร้างขึ้นมา

การทำ Component Specs สามารถแบ่งออกเป็นขั้นโดยประมาณคือ

  1. กำหนดคุณสมบัติ (Attribute) กำหนดคุณสมบัติของ Component ที่ต้องการพัฒนา เช่น ชื่อ Component, รูปแบบข้อมูล input, การแสดงผลที่คาดหวัง เป็นต้น
  2. ระบุพฤติกรรม (Behavior) ระบุพฤติกรรมของ Component ที่ต้องการพัฒนา เช่น การตอบสนองต่อ Event ต่าง ๆ, การปรับแต่งรูปแบบและการแสดงผล เป็นต้น
  3. ทดสอบและตรวจสอบ (Testing) ทดสอบ Component ที่พัฒนาขึ้นมาเพื่อตรวจสอบว่ามีความถูกต้องและประสิทธิภาพตามที่กำหนดหรือไม่

ซึ่งในหัวข้อนี้ เดี๋ยวเราจะมาดูตัวอย่างให้เห็นภาพการทำ Component Specs ผ่าน Storybook กัน

Storybook คืออะไร ?

Ref: https://storybook.js.org/

storybook-web.webp

Storybook เป็นเครื่องมือที่ช่วยในการพัฒนาและทดสอบ Components ต่าง ๆ ใน Web application โดย Storybook จะช่วยให้เราสามารถสร้างและดูตัวอย่างของ Components แต่ละอันได้อย่างง่ายดาย

โดย Storybook มีข้อดีที่สำคัญคือ

  1. การรันการทดสอบ (run test) Storybook ช่วยให้เราสามารถทดสอบ Components ได้อย่างง่ายดาย และเพิ่มประสิทธิภาพในการตรวจสอบความถูกต้องของ Components ก่อนนำมาใช้งานจริงใน project ผ่านตัว UI ที่ Storybook เตรียมไว้
  2. ช่วยให้ Designer สามารถดู Components ได้ Storybook ช่วยให้เราสามารถแสดงตัวอย่างของ Components และทดสอบการแสดงผลของ Components ได้อย่างง่ายดาย ซึ่งช่วยให้ Designer สามารถตรวจสอบและปรับแต่งรูปแบบและการแสดงผลของ Components ได้ตรงตามความต้องการ

ดังนั้นการมี Storybook จะช่วยให้การพัฒนา project เป็นไปได้อย่างมีประสิทธิภาพ และช่วยให้ทั้ง developer และ design สามารถทำงานร่วมกันได้อย่างราบรื่นและมีความสุขมากยิ่งขึ้น (ตามหลักการแล้วอะนะ)

Storybook สามารถใช้ได้กับหลาย Frontend framework เจ้าดังๆอยู่แล้วอย่าง React, Vue, Angular, Svelte รวมถึงกับ Web Components ที่เป็น Standard ของ Browser เองด้วยเช่นกัน

มาเริ่มต้นลง Storybook เข้า project กันก่อน

Ref: https://storybook.js.org/docs/get-started/install

เพื่อให้ทุกคนเห็นภาพตรงกัน เราจะทำการลง project ของ React มาก่อนโดยใช้ Vite

Terminal window
npm create vite@latest my-storybook-app -- --template react
npm install
npm run dev

เมื่อ run project ขึ้นมาได้เรียบร้อยให้ run คำสั่งสำหรับการ install storybook เข้า project ไป

Terminal window
npx storybook@latest init

เมื่อทำการลงเรียบร้อยแล้ว ให้ลอง run คำสั่ง start storybook ขึ้นมา

Terminal window
npm run storybook

หากได้หน้าตาประมาณนี้มาถือว่าลง Storybook แล้วเรียบร้อย

storybook-example-01.webp

โดยสังเกตว่า ตอนที่ทำการ ลง storybook ขึ้นมา ตัว project จะมีไฟล์ .stories.js โผล่มาด้วย ออกมาเป็น folder ใหม่ที่ชื่อ stories ออกมา

Terminal window
.
├── src
└── stories
├── Button.jsx
├── Button.stories.js
├── Configure.mdx
├── Header.jsx
├── Header.stories.js
├── Page.jsx
├── Page.stories.js
└── vite.config.js

เรามาดูตัวอย่างใน Header.stories.js กัน

import { Header } from "./Header";
export default {
title: "Example/Header",
component: Header,
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ["autodocs"],
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
layout: "fullscreen",
},
};
export const LoggedIn = {
args: {
user: {
name: "Jane Doe",
},
},
};
export const LoggedOut = {};

ในส่วนของ code ของ Storybook ด้านบนนั้น มีการกำหนดและระบุ Component Specs และการทดสอบโดยใช้ Storybook ดังนี้

  1. ตอนแรกเรากำหนดคุณสมบัติของ Component และระบุชื่อ Component และตำแหน่งของ Component ใน Storybook
    • title: 'Example/Header' กำหนดชื่อเรื่องของ Component และตำแหน่งที่ Component จะอยู่ใน Storybook
    • component: Header ระบุ Component ที่ต้องการแสดงใน Storybook
    • tags: ['autodocs'] กำหนด tag ของ Component เพื่อใช้ใน Autodocs (Autodocs คือตัวที่สามารถทำ Document ของ Component specs ออกมาได้อย่างอัตโนมัติผ่านการอ่าน Prop Attribute และ Story สามารถอ่านเรื่อง Autodocs เพิ่มเติมได้ ที่นี่)
    • parameters: { layout: 'fullscreen' } กำหนดลักษณะและตำแหน่งของ Component ใน Storybook
  2. เราประกาศตัวแปร LoggedIn และ LoggedOut ซึ่งเป็นตัวอย่างของ parameters ของ Component ใน Storybook
    • LoggedIn เป็นตัวอย่างของการใช้งาน Component โดยมี parameters user ที่มีค่าเริ่มต้นเป็น { name: 'Jane Doe' }
    • LoggedOut เป็นตัวอย่างของการใช้งาน Component โดยไม่มี parameters

โดย LoggedIn และ LoggedOut นั้นคือสิ่งที่เรียกว่า Story ใน Storybook

Story ใน Storybook คือตัวอย่างที่ใช้ในการแสดงและทดสอบ Components ต่าง ๆ ใน Web application ซึ่งสามารถใช้งานได้ผ่าน Storybook UI ที่เตรียมไว้ให้ ใน Storybook เราสามารถสร้าง Story ของ Components ที่ต้องการแสดงและทดสอบได้ ซึ่งสามารถกำหนดค่าและสร้างตัวอย่างของ Components แต่ละอันในรูปแบบต่าง ๆ ได้ (มองง่ายๆว่าเป็นเหมือน Test case ของ Component แต่ละเคสที่เราเตรียมเป็นตัวอย่างให้เอาไว้ดูเป็นตัวอย่างได้)

และเมื่อลองมาดูตัวอย่างผ่าน Storybook UI ในหน้าเว็บ ก็จะเจอว่า มี 2 Story คือ LoggedIn และ LoggedOut ออกมาเหมือนกันกับที่กำหนดไว้ใน storybook ออกมาได้ โดยจะออกมาคู่กับ Component Specs ตัวนั้นๆที่ได้มีการสร้างเอาไว้

story-example-2.webp

อย่างที่เห็นนี่คือการเขียน Component specs ออกมานั่นเอง (เป็นเหมือนกับการเขียน Frontend Component Testing กลายๆด้วยเหมือนกัน) เพื่อเป็นการระบุว่า Component ตัวไหน มีความสามารถในการรับค่าไหนไปบ้าง และ เมื่อตัวแปรต่างกันจะ action แบบไหนออกมาบ้าง

อันที่ทุกคนเห็นอยู่นี้คือตัวอย่างที่ Storybook เตรียมเอาไว้ให้ตอนเริ่มต้น storybook ออกมา เดี๋ยวเราจะลองมาเขียน Storybook กันเพิ่มเติมบ้าง

มาลองสร้าง Component และเล่น Story ไปพร้อมๆกัน

ตัวอย่างที่เราจะทำกัน เราจะลองมาเล่นตัวอย่างกับ 3 Component กัน รวมถึงจะลองเขียน component testing แบบง่ายๆลง Storybook ไปคู่กัน

ก่อนที่เราจะไปต่อ เราจะขอลง tailwind ใน project เพื่อเพิ่มเติมส่วนของ style เข้ามาโดยทำการลง tailwindcss เข้าไปใน project ตาม document ของ tailwind ได้เลย (อ่านเพิ่มเติมการลง tailwind ด้วย vite ได้ ที่นี่)

โดยทำการลงผ่านคำสั่งนี้

Terminal window
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

ปรับ tailwind.config.js ที่เพิ่มเข้ามาใน project

/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

หลังจากนั้นเพิ่ม tailwind.css เข้ามา

@tailwind base;
@tailwind components;
@tailwind utilities;

และทำการ import เข้าผ่าน main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
import "./tailwind.css"; // เพิ่มบรรทัดนี้เข้ามา
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

เป็นอันเสร็จสิ้นการติดตั้ง tailwind ให้ project แล้ว (สามารถตรวจสอบได้โดยการลองใช้ style tailwind ผ่าน document ของ tailwind ได้) หลังจากนั้นให้ทำการลงผ่าน storybook ต่อ (เพื่อให้ storybook สามารถใช้ style ของ tailwind ได้เช่นเดียวกัน สามารถอ่านข้อมูลการลงของ document storybook ได้ ที่นี่)

ขั้นแรก ทำการลง storybook addon ของตัว styling webpack เข้าไป

Terminal window
npx storybook@latest add @storybook/addon-styling-webpack

เมื่อลงเสร็จที่ .storybook/preview.js ให้ทำการเรียกใช้ style ของ tailwind แบบ global ใน storybook

/** @type { import('@storybook/react').Preview } */
import "../src/tailwind.css";
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

เมื่อเรียบร้อยก็จะเป็นอันเสร็จสิ้นการลง tailwind ทั้งจากหน้าเว็บและ storybook (เดี๋ยวมาตรวจสอบกันเพิ่มเติมจากการลองใส่ style ผ่าน Component กัน)

ในตัวอย่างนี้ เราจะทำการลองทำ Component มาทั้งหมด 3 ตัวคือ

  1. Modal Component สำหรับ popup (modal) โดยสามารถใส่ child component (component content ตรงกลางของ popup)
modal.webp
  1. Carousel Component สำหรับการใส่รูปภาพ และสามารถเลื่อนรูปภาพไปมาได้ รวมถึงมี feature autoplay ที่สามารถ auto เลื่อนภาพใน carousal ได้

    carousel.webp
  2. Register Form Component Form สำหรับการใส่ข้อมูล Name, Email และ Phone number สำหรับ register ข้อมูลเข้ามา (Component นี้เป็นตัวอย่างเดียวกับในหัวข้อ Frontend Testing สามารถอ่านเพิ่มเติมได้ ที่นี่ โดยในหัวข้อนี้เราจะพามา mock api ผ่าน Mock Service Worker กัน)

register-form.webp

เราจะมาลองทำกันทีละ Component และมาลองทำ Storybook แต่ละ Component กัน

1. Modal Component

มาที่ Component แรก Modal Component โดย Feature ที่เราจะทำที่ Modal Component คือ

  • Modal ต้องควบคุมการแสดงผลออกมาได้ (มีตัวแปรควบคุมการแสดงผลว่า แสดงผลออกมาได้หรือไม่)
  • ต้องสามารถใส่ Content html อะไรไปใน Component ก็ได้ (เนื่องจาก Modal นั้นต้องมีความเป็น General ใช้ได้กับข้อมูลทุกประเภท)
  • และต้องสามารถใส่ function อะไรก็ได้ที่จะจัดการเมื่อมีการปิด Modal (ในกรณีที่อาจจะต้องมีการ reset ค่าบางอย่าง จะได้ทำการ run function ก่อนที่ Modal จะปิดลง)

ที่ Modal.jsx code Component หน้าตาแบบนี้

Modal.jsx
import PropTypes from "prop-types";
import "./modal.css";
const Modal = ({ isOpen, children, onClose }) => {
if (!isOpen) {
return null;
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
<button className="close-button" aria-label="Close" onClick={onClose}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="24" height="24">
<path
fill="currentColor"
d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"
/>
</svg>
</button>
</div>
</div>
);
};
Modal.propTypes = {
isOpen: PropTypes.bool.isRequired,
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
};
export default Modal;

Code ด้านบนเป็นการสร้าง Component ที่เรียกว่า Modal โดยมีการทำงานดังนี้

  • Component รับ parameters ที่มีคุณสมบัติดังนี้ isOpen (บอกว่า Modal อยู่ในสถานะเปิดหรือปิด), children (เนื้อหาภายใน Modal), onClose (function ที่จะถูกเรียกเมื่อปิด Modal)
  • หาก isOpen เป็น false จะไม่แสดง Modal ออกมาเลย
  • หาก isOpen เป็น true จะแสดง Modal ออกมา โดย
    • <div className="modal-overlay"> เป็นส่วนที่ครอบ Modal และให้คลิกที่ส่วนนี้เพื่อปิด Modal
    • <div className="modal-content"> เป็นส่วนที่เก็บเนื้อหาภายใน Modal และไม่ให้คลิกที่ส่วนนี้ปิด Modal
    • {children} เป็นส่วนที่แสดงเนื้อหาภายใน Modal ซึ่งเป็น parameter ที่ส่งเข้ามา
    • <button className="close-button"> เป็นปุ่มสำหรับปิด Modal และเรียกใช้ function onClose เมื่อคลิก

และนี่คือ Modal Component ของเรา ทีนี้หากเราลองมาคิดเป็น Story ของ Component นี้ก็จะมีเคสประมาณนี้ได้แก่

  1. Story สำหรับการให้ตัวอย่างเวลาเปิดและปิด การใช้ Modal (ผ่านตัวแปร isOpen)
  2. Story สำหรับการทดสอบว่า เปิด และ ปิดใช้งานได้ โดยการกดจริงผ่าน UI (ใช้ Test ผ่าน Interaction Test อ่านเพิ่มเติมได้ ที่นี่)

** อธิบายเพิ่มเติมเรื่อง Interaction Test

Interaction Test ใน Storybook เป็นการทดสอบการตอบสนองของ Component ผ่านการจำลองการกระทำของผู้ใช้งาน (user interaction) ซึ่งเป็นการทดสอบว่า Component ทำงานได้ถูกต้องตามที่คาดหวังหรือไม่ โดยใช้ตัวอย่าง Storybook UI ที่เตรียมไว้ให้

เราสามารถใช้ Storybook UI ในการจำลองการกระทำของผู้ใช้งานเช่น การคลิก, การเลื่อน, การพิมพ์ข้อความ เป็นต้น และตรวจสอบผลลัพธ์ที่คาดหวังว่าถูกต้องหรือไม่ เช่นการตรวจสอบว่า Component ตอบสนองถูกต้องตามการกระทำของผู้ใช้งานหรือไม่ การตรวจสอบการเปิดหรือปิด Modal (เช่นเคสนี้) หรือการเปลี่ยนแปลงค่าในฟอร์ม เป็นต้น

การทำ Interaction Test ใน Storybook เป็นอีกวิธีหนึ่งที่ช่วยให้สามารถทดสอบการทำงานของ Component ได้อย่างรวดเร็ว โดยไม่ต้องเขียนโค้ดทดสอบด้วยตัวเอง (เหมาะเอาไว้ทำทั้ง Test case และให้ Designer ดู action ที่เกิดขึ้นจริงด้วย)

โดยเมื่อนำ Story ด้านบนมาเขียน storybook ที่ Modal.stories.jsx ก็จะได้หน้าตาประมาณนี้ออกมา

Modal.stories.jsx
import { useState } from "react";
import Modal from "./Modal";
import { userEvent, within, waitFor, expect } from "@storybook/test";
export default {
title: "Modal",
component: Modal,
tags: ["autodocs"],
render: function Render(args) {
const [isOpen, setIsOpen] = useState(args.isOpen);
return (
<div>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<Modal {...args} isOpen={isOpen} onClose={() => setIsOpen(false)} />
</div>
);
},
};
export const ClosedModal = {
args: {
isOpen: false,
children: <p>Modal Content</p>,
},
};
export const OpenModal = {
args: {
isOpen: true,
children: <p>Modal Content</p>,
},
};
export const InteractionTest = {
args: {
isOpen: false,
children: <p>Modal Content</p>,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const openButton = canvas.getByText("Open Modal");
userEvent.click(openButton);
await waitFor(() => {
expect(canvas.getByText(/Modal Content/i)).toBeInTheDocument();
});
const closeButton = canvas.getByLabelText("Close");
userEvent.click(closeButton);
await waitFor(() => {
expect(canvas.queryByText(/Modal Content/i)).not.toBeInTheDocument();
});
},
};

และเพิ่ม modal.css เข้ามาเพื่อสร้าง style ของ modal component ออกมา

modal.css
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 20px;
border-radius: 5px;
min-width: 50%;
max-width: 100%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.close-button {
position: absolute;
top: 10px;
right: 10px;
border: none;
background: none;
font-size: 20px;
cursor: pointer;
}

อธิบายจาก code storybook modal ด้านบน

  • ในการเริ่มต้น Storybook สำหรับ Modal Component นั้น เราจะต้องทำการ import ทุกอย่างที่เกี่ยวข้องเข้ามา เช่น useState และ Modal Component ที่เราสร้างไว้
  • เราจะสร้างตัวแปร isOpen และ setIsOpen ด้วย useState เพื่อเก็บสถานะการเปิดหรือปิดของ Modal ไว้ภายใน render ของ Component (render เปรียบเสมือนการเรียกใช้งาน component ของ Modal และ โดยเราจะใช้ args เป็นตัวแทนในการสื่อสารผ่าน State ออกมาแทน เพื่อให้ state สามารถเปลี่ยนแปลงขณะ run storybook ออกมาได้)

โดยอย่างที่เห็นใน code ด้านบน เรามีการ Export story ออกมาทั้งหมด 3 Story คือ

  1. ClosedModal ใน story นี้เรากำหนดให้ isOpen เป็น false ซึ่งจะทำให้ Modal ไม่แสดงออกมา และกำหนดเนื้อหาภายใน Modal ให้เป็น <p>Modal Content</p>
  2. OpenModal ใน story นี้เรากำหนดให้ isOpen เป็น true ซึ่งจะทำให้ Modal แสดงออกมา และกำหนดเนื้อหาภายใน Modal ให้เป็น <p>Modal Content</p>
  3. InteractionTest ใน story นี้เราจะทดสอบการกระทำของผู้ใช้งานกับ Modal ผ่าน Storybook UI เช่นการคลิกที่ปุ่มเพื่อเปิด Modal และการคลิกที่ปุ่มเพื่อปิด Modal ซึ่งเราใช้ @storybook/testing-library และ @storybook/jest เพื่อใช้ในการเขียน Test และตรวจสอบผลลัพธ์ที่คาดหวัง และทำการส่งข้อมูลผ่านคำสั่ง play เพื่อเป็นการจำลองการ run Interactive Test ออกมา

เมื่อดูผลลัพธ์ผ่าน Storybook ก็จะเจอ tab ของ Modal ออกมาพร้อมทั้ง 3 Story ออกมาได้ (ภาพด้านล่างนี้คือตัวอย่างของ Interaction Test จะมี tab Interaction ที่จำลองการเล่นออกมาได้)

interaction-test.webp

โดย เมื่อมีการเพิ่ม autodocs เข้ามา ก็จะมีหัวข้อ Docs ออกมา ที่จะทำการ Auto generate เอกสารของ Component Specs ออกมาได้ (โดยจะอ้างอิงตาม PropTypes ที่มีการประกาศไว้ใน Component) ซึ่งเป็นตัวอำนวยความสะดวกในการทำเอกสาร Component มากๆ (ให้อารมณ์เหมือน Auto gen Swagger ใน Backend)

autodocs.webp

มาที่ Component ที่สอง Carousel Component โดย Feature ที่เราจะทำที่ Carousel Component คือ

  • Carousel ต้องสามารถรับภาพหลายภาพเข้าไปได้
  • Carousel ต้องสามารถควบคุมการเลื่อนของภาพไปมาได้ (เลื่อนไป / เลื่อนกลับได้)
  • Carousel ต้องสามารถเล่น autoplay (เปลี่ยนภาพแบบอัตโนมัติ) ตามจำนวน miliseconds ที่ใส่ไปได้ (สามารถเลือกได้ว่าจะเปิดหรือปิด feature นี้ได้)
  • Carousel สามารถกำหนดภาพเริ่มต้นได้ว่าจะเริ่มต้นจากภาพไหน

ที่ Carousel.jsx code Component ก็จะหน้าตาประมาณนี้

Carousel.jsx
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
const Carousel = ({ images, startIndex, autoplay, interval }) => {
const [currentIndex, setCurrentIndex] = useState(startIndex);
// Function to go to the next image
const nextImage = () => {
setCurrentIndex((prevIndex) => (prevIndex === images.length - 1 ? 0 : prevIndex + 1));
};
// Function to go to the previous image
const prevImage = () => {
setCurrentIndex((prevIndex) => (prevIndex === 0 ? images.length - 1 : prevIndex - 1));
};
// Autoplay effect
useEffect(() => {
if (autoplay) {
const timer = setInterval(() => {
nextImage();
}, interval);
return () => clearInterval(timer);
}
}, [currentIndex, autoplay, interval]);
return (
<div className="flex w-full flex-col items-center justify-center">
<div className="relative w-full max-w-4xl">
{images && images.length > 0 && (
<img
src={images[currentIndex]}
alt={`Slide ${currentIndex}`}
className="h-auto w-full rounded-lg object-cover shadow-lg"
/>
)}
<button
onClick={prevImage}
className="absolute left-0 top-1/2 -translate-y-1/2 transform rounded-full bg-white bg-opacity-50 p-2 text-black shadow hover:bg-opacity-75"
>
Previous
</button>
<button
onClick={nextImage}
className="absolute right-0 top-1/2 -translate-y-1/2 transform rounded-full bg-white bg-opacity-50 p-2 text-black shadow hover:bg-opacity-75"
>
Next
</button>
</div>
</div>
);
};
Carousel.propTypes = {
// images should be an array of strings (URLs) and is required
images: PropTypes.arrayOf(PropTypes.string).isRequired,
// startIndex should be a number and optional
startIndex: PropTypes.number,
// autoplay should be a boolean and optional
autoplay: PropTypes.bool,
// interval should be a number and optional
interval: PropTypes.number,
};
Carousel.defaultProps = {
startIndex: 0,
autoplay: true,
interval: 3000,
};
export default Carousel;

ใน code ของ Carousel Component นี้ เรามีการใช้ Hook ของ React อย่าง useState และ useEffect เพื่อใช้ในการจัดการสถานะและเอฟเฟกต์ของ Carousel นั้น ๆ

  • useState ใช้ในการเก็บค่า currentIndex ซึ่งจะบอกลำดับของภาพปัจจุบันที่กำลังแสดงอยู่ใน Carousel
  • useEffect ใช้ในการทำ Autoplay ของ Carousel หาก autoplay เป็น true จะทำการเปลี่ยนภาพอัตโนมัติตาม interval ที่กำหนด
  • Component จะแสดงผลออกมาเป็นรูปภาพที่อยู่ใน images ตามลำดับของ currentIndex ที่กำหนด และมีปุ่ม “Previous” และ “Next” สำหรับเลื่อนภาพไปข้างหน้าและข้างหลัง
  • ในการใช้งาน Carousel Component นี้ เราสามารถส่ง props ต่าง ๆ เพื่อกำหนดค่าต่าง ๆ ได้ เช่น images (รูปภาพที่จะแสดงใน Carousel) ซึ่งควรเป็น Array ของ URLs และเป็นค่าที่ต้องส่งเข้ามาเสมอ ในส่วนของ startIndex, autoplay, และ interval เป็นค่า default ที่กำหนดไว้ แต่สามารถแก้ไขได้ตามต้องการ
  • startIndex ใช้สำหรับการกำหนด index เริ่มต้นของภาพ
  • autoplay ใช้สำหรับกำหนดว่าเกิดใช้งานเล่นอัตโนมัติหรือไม่ และ interval กำหนดว่าหากเล่นอัตโนมัติจะทำการเปลี่ยนภาพทุกๆกี่ miliseconds

และนี่คือ Carousel Component ของเรา ทีนี้หากเราลองมาคิดเป็น Story ของ Component นี้ เราก็สามารถล้อตามเคสของ Feature ใน Carousel (4 ข้อ) ที่เรา list ด้านบนได้เลย เมื่อนำมาเขียน Storybook ที่ Carousel.stories.jsx ก็จะมีหน้าตาประมาณนี้

Carousel.stories.jsx
import Carousel from "./Carousel";
import { within, userEvent, waitFor, expect } from "@storybook/test";
export default {
title: "Carousel",
component: Carousel,
tags: ["autodocs"],
args: {
images: [
"https://fastly.picsum.photos/id/648/300/200.jpg?hmac=1CBWajz31GOLUdds_HpCDPaHDG6FF3eoY1fYcoFgEMY",
"https://fastly.picsum.photos/id/69/300/200.jpg?hmac=eLc6u_j4wqI6rURIhekE0kS1oYHTmD7tNZ1LeEPyIeY",
"https://fastly.picsum.photos/id/625/300/200.jpg?hmac=2JeYxbeay5cJXc4_CqXxaSVY6atO8yOJOZ9emIGYDf4",
],
},
};
export const Basic = {
args: {
autoplay: false,
},
};
export const Autoplay = {
args: {
autoplay: true,
interval: 1000,
},
};
export const CustomStartIndex = {
args: {
startIndex: 1,
autoplay: false,
},
};
export const NextImageInteraction = {
args: {
autoplay: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const nextButton = canvas.getByText("Next");
await userEvent.click(nextButton);
await waitFor(async () => {
await expect(canvas.getByAltText("Slide 1")).toBeInTheDocument();
});
},
};
export const PrevImageInteraction = {
args: {
autoplay: false,
startIndex: 1,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const prevButton = canvas.getByText("Previous");
await userEvent.click(prevButton);
await waitFor(async () => {
// Replace this with the actual logic to verify the image change
await expect(canvas.getByAltText("Slide 0")).toBeInTheDocument();
});
},
};

ใน code ของ Storybook Carousel Component นี้ เรามีการสร้าง Story ทั้งหมด 5 Stories คือ

  1. Basic ใน Story นี้เรากำหนด autoplay เป็น false ซึ่งจะไม่เปิดใช้งาน Autoplay feature และไม่กำหนดค่า interval ใด ๆ (เพื่อให้เห็น Component แบบทั่วๆไปออกมาก่อน)
  2. Autoplay ใน Story นี้เรากำหนด autoplay เป็น true ซึ่งจะเปิดใช้งาน Autoplay feature และกำหนดค่า interval เป็น 1000 milliseconds (1 วินาที)
  3. CustomStartIndex ใน Story นี้เรากำหนด startIndex เป็น 1 ซึ่งจะกำหนดให้ภาพเริ่มต้นแสดงเป็นภาพลำดับที่ 1 จาก images ที่กำหนด
  4. NextImageInteraction ใน Story นี้ (เป็น Interaction Test) เราจะทดสอบการกระทำของผู้ใช้งานกับ Carousel โดยการคลิกที่ปุ่ม “Next” เพื่อเลื่อนภาพไปข้างหน้า และตรวจสอบผลลัพธ์ว่าภาพถัดไปแสดงตรงตามที่คาดหวังหรือไม่
  5. PrevImageInteraction ใน Story นี้ (เป็น Interaction Test) เราจะทดสอบการกระทำของผู้ใช้งานกับ Carousel โดยการคลิกที่ปุ่ม “Previous” เพื่อเลื่อนภาพย้อนกลับ และตรวจสอบผลลัพธ์ว่าภาพก่อนหน้าแสดงตรงตามที่คาดหวังหรือไม่

และนี่คือตัวอย่าง Storybook ของ Carousel (ก็จะมีเคสตามที่เขียนเอาไว้)

carousel-example.webp

3. Register Form Component

มาที่ Component สุดท้ายของ Carousel Component โดย Feature ที่เราจะทำที่ Register Form Component โดยสิ่งที่เราจะมีใน Component นี้คือ

  • มี Form ทั้งหมด 3 fields คือ name (กรอกชื่อ), email (กรอกอีเมล) และ phoneNumber (กรอกเบอร์โทรศัพท์)
  • ในส่วนของ email และ phoneNumber นั้นมีการ validate ด้วยว่าถูก format หรือไม่ หากกรอกไม่ถูกต้องต้องแสดง Error ออกมาได้
  • หากกรอกทุกอย่างถูกต้อง และ Submit ข้อมูล ทำการนำข้อมูลทั้ง 3 อย่างส่งผ่าน API เพื่อนำไปบันทึกข้อมูลต่อได้

จากโจทย์นี้ที่ Register.jsx code Component ก็จะหน้าตาประมาณนี้

Register.jsx
import { useState } from "react";
import axios from "axios";
export default function RegisterForm() {
const [formData, setFormData] = useState({
name: "",
email: "",
phoneNumber: "",
});
const [errors, setErrors] = useState({});
const validate = (values) => {
let errors = {};
if (!values.name) {
errors.name = "Name is required";
}
if (!values.email) {
errors.email = "Email is required";
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = "Email is invalid";
}
if (!values.phoneNumber) {
errors.phoneNumber = "Phone number is required";
} else if (!/^\d{10}$/.test(values.phoneNumber)) {
errors.phoneNumber = "Invalid phone number, should be 10 digits";
}
return errors;
};
const handleChange = (event) => {
const { name, value } = event.target;
setFormData({
...formData,
[name]: value,
});
};
const handleSubmit = async (event) => {
event.preventDefault();
const newErrors = validate(formData);
if (Object.keys(newErrors).length === 0) {
try {
const response = await axios.post(
"https://65a25d5342ecd7d7f0a771bd.mockapi.io/users",
formData,
);
if (!response.data) throw new Error("Error in form submission");
// Handle success here
alert("Register successful!");
} catch (error) {
// Handle errors here
console.log("error", error);
alert("Register fail!");
}
} else {
setErrors(newErrors);
}
};
return (
<>
<form onSubmit={handleSubmit} className="mx-auto my-8 max-w-sm">
<h1 className="mb-2 text-3xl">Register Form</h1>
<div className="mb-6">
<label htmlFor="name" className="mb-2 block text-sm font-medium text-gray-900">
Name
</label>
<input
type="text"
id="name"
name="name"
onChange={handleChange}
value={formData.name}
data-testid="name"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div>
<div className="mb-6">
<label htmlFor="email" className="mb-2 block text-sm font-medium text-gray-900">
Email
</label>
<input
type="text"
id="email"
name="email"
onChange={handleChange}
value={formData.email}
data-testid="email"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
</div>
<div className="mb-6">
<label htmlFor="phoneNumber" className="mb-2 block text-sm font-medium text-gray-900">
Phone Number
</label>
<input
type="text"
id="phoneNumber"
name="phoneNumber"
onChange={handleChange}
value={formData.phoneNumber}
data-testid="phoneNumber"
className="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
/>
{errors.phoneNumber && <p className="text-xs text-red-500">{errors.phoneNumber}</p>}
</div>
{/* Submit button */}
<button
type="submit"
className="w-full rounded-lg bg-blue-500 px-5 py-2.5 text-center text-sm font-medium text-white hover:bg-blue-700 focus:ring-4 focus:ring-blue-300"
>
Submit
</button>
</form>
</>
);
}

ใน code ของ RegisterForm Component นี้

  • เราใช้ Hook ของ React คือ useState เพื่อใช้ในการจัดการสถานะของฟอร์ม และใช้ axios library ในการทำ HTTP request สำหรับส่งข้อมูลผ่าน API
  • ในส่วนของ validate function เราใช้เพื่อตรวจสอบความถูกต้องของข้อมูลที่ผู้ใช้กรอกเข้ามา โดยตรวจสอบว่า name, email, และ phoneNumber ไม่เป็นค่าว่าง และตรวจสอบรูปแบบของ email ว่าถูกต้องหรือไม่ (ใช้ regular expression) และตรวจสอบรูปแบบของ phoneNumber ว่าเป็นตัวเลข 10 หลักหรือไม่
  • ในส่วนของ handleChange function เราใช้เพื่ออัปเดตข้อมูลในฟอร์มเมื่อผู้ใช้กรอกข้อมูลหรือเลือกตัวเลือกบนฟอร์ม
  • ในส่วนของ handleSubmit function เราใช้เพื่อจัดการเหตุการณ์เมื่อผู้ใช้กด Submit โดยเราจะทำการ validate ข้อมูลในฟอร์ม หากไม่มีข้อผิดพลาดจะทำการส่งข้อมูลผ่าน API และแสดงข้อความเมื่อสำเร็จหรือไม่สำเร็จ

ทีนี้ Component นี้แตกต่างกับ UI Component 2 ตัวก่อนหน้าคือ เป็น Component ที่ทำงานเฉพาะออกมา (คือไม่ใช่ Component ที่ถูกสร้างมาเพื่อ Recycle เป็น UI Component แต่ถูกสร้างขึ้นมาเพื่อใช้สำหรับหน้านั้นๆโดยเฉพาะ รวมถึงมีการส่งข้อมูลผ่าน API ด้วย)

ดังนั้นจึงจำเป็นต้องมีการเพิ่มสิ่งหนึ่งเข้ามาเพื่อให้สามารถ Mock การส่ง API ได้ (เพื่อทำ Interaction Test) ซึ่งจริงๆ จะใช้วิธีเดียวกันกับในหัวข้อ Frontend Testing ก็ได้ (อย่างที่บอกไว้ด้านบน เป็น Component ตัวเดียวกันกับหัวข้อนั้น) แต่เราจะขอแนะนำตัวที่ใช้สำหรับการ Mock Server โดยเฉพาะอีกตัวหนึ่ง ที่มี addons ใช้กับ storybook ได้นั่นคือ Mock Service Worker

Mock Service Worker (MSW) เป็นเครื่องมือที่ช่วยในการจำลองและทดสอบการเรียกใช้งาน API ใน application ด้วย MSW เราสามารถสร้างและกำหนดการ Response เทียบเท่ากับเซิร์ฟเวอร์จริงได้ ซึ่งช่วยให้เราสามารถทดสอบการทำงานของ application ของเราได้โดยไม่ต้องพึ่งพาเซิร์ฟเวอร์จริง นอกจากนี้ยังมีความสามารถในการกำหนดเงื่อนไขการ Response ที่หลากหลาย เช่น การจำลองการตอบสนองแบบค้างคาว (throttling) หรือการตอบสนองแบบล่าช้า (delay) เพื่อทดสอบภาวะเสียงสูง นอกจากนี้ยังสามารถใช้งานร่วมกับ Storybook เพื่อจำลองและทดสอบการเรียกใช้งาน API ใน Storybook ได้อีกด้วย (อ่านเพิ่มเติมเรื่อง Mock Service Worker ได้ที่ https://mswjs.io/ ได้เลย)

โดยขั้นแรกทำการลง MSW (ตามเอกสารนี้)

Terminal window
npm i msw msw-storybook-addon -D

หลังจากนั้นทำการ Generate service worker สำหรับใช้งานคู่กับ MSW ออกมาที่ folder public

Terminal window
npx msw init public/

เมื่อทำการลงทุกอย่างเสร็จให้ทำการเพิ่ม config ของ MSW เข้าไปได้ที่ .storybook/preview.js

/** @type { import('@storybook/react').Preview } */
import "../src/tailwind.css";
import { initialize, mswLoader } from "msw-storybook-addon";
// Initialize MSW
initialize();
const preview = {
parameters: {
/* เหมือนเดิม */
},
loaders: [mswLoader],
};
export default preview;

เพียงเท่านี้ก็จะเป็นการลง MSW เข้า project แล้วเป็นที่เรียบร้อย ทีนี้ จาก Register Form Component เราจะทำการทดลองเขียน Story (แบบ Interaction Test ทั้ง 3 story) ออกมาได้แก่

  1. Story ทดสอบการลองกรอกข้อมูลให้ครบ (ดูว่า input สามารถใช้งานได้ปกติก่อนไหม)
  2. Story ทดสอบว่าหากกรอกข้อมูลไม่ถูกต้อง (ตัวอย่างนี้เราจะลองกับ email) ดูว่า Error แสดงออกมาถูกต้องหรือไม่
  3. Story ทดสอบการยิง submit ว่า เมื่อมีการกรอกข้อมูลครบและยิง submit ไปสามารถส่งข้อมูลได้ (โดยในเคสนี้เราจะทำการ Mock API เพื่อไม่ให้เกิดการส่งจริงตอนกด submit)

เมื่อนำมาเขียน Storybook ที่ Register.stories.jsx ก็จะได้หน้าตาประมาณนี้ออกมา

Register.stories.jsx
import Register from "./Register";
import { within, userEvent, expect } from "@storybook/test";
import { rest } from "msw";
export default {
title: "RegisterForm",
component: Register,
tags: ["autodocs"],
parameters: {
msw: {
handlers: [
rest.post("https://65a25d5342ecd7d7f0a771bd.mockapi.io/users", (req, res, ctx) => {
return res(ctx.json({ message: "Register successful!" }), ctx.status(200));
}),
],
},
},
};
export const Default = {};
export const FilledState = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByTestId("name"), "John Doe", { delay: 100 });
await userEvent.type(canvas.getByTestId("email"), "[email protected]", {
delay: 100,
});
await userEvent.type(canvas.getByTestId("phoneNumber"), "1234567890", {
delay: 100,
});
},
};
export const ErrorState = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByTestId("email"), "invalid-email", {
delay: 100,
});
await userEvent.click(canvas.getByText("Submit"));
await expect(canvas.getByText("Email is invalid")).toBeInTheDocument();
await expect(canvas.getByText("Name is required")).toBeInTheDocument();
await expect(canvas.getByText("Phone number is required")).toBeInTheDocument();
},
};
export const SuccessSubmit = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByTestId("name"), "John Doe");
await userEvent.type(canvas.getByTestId("email"), "[email protected]");
await userEvent.type(canvas.getByTestId("phoneNumber"), "1234567890");
userEvent.click(canvas.getByText("Submit"));
},
};

ใน code ของ Storybook ของ Register Component นี้ เรามีการสร้าง Story ทั้งหมด 4 Stories คือ

  1. Default เป็น Story ที่ไม่มีการกระทำอะไรใดๆ แค่แสดง Component ออกมาเพื่อให้เห็นรูปแบบตัว Component
  2. FilledState เป็น Story ที่จะทดสอบการใส่ข้อมูลให้ครบทุก field ของ Form โดยการใส่ชื่อเป็น ‘John Doe’, อีเมลเป็น ‘[email protected]’, และหมายเลขโทรศัพท์เป็น ‘1234567890’
  3. ErrorState เป็น Story ที่จะทดสอบกรณีกรอกข้อมูลไม่ถูกต้อง โดยทดสอบกรณีกรอกอีเมลที่ไม่ถูกต้อง และไม่กรอกชื่อและหมายเลขโทรศัพท์
  4. SuccessSubmit เป็น Story ที่จะทดสอบกรณีการส่งข้อมูลเมื่อกรอกข้อมูลครบถ้วน โดยการกรอกข้อมูลและกด Submit ใน Story นี้เราใช้งาน Mock Service Worker (MSW) เพื่อจำลองและทดสอบการส่งข้อมูลผ่าน API ใน Storybook โดยการกำหนดการ Response สำหรับ API POST https://<mock api>/users ที่ใช้ใน Storybook โดยกำหนดผ่าน handlers ใน parameters ของ msw เป็น global ของ Component นั้นเอาไว้ ทำให้สามารถเรียกใช้งาน mock API จากในทุก Story ของ Component นั้นได้ ทำให้ Story นี้สามารถ Submit Form ได้โดยไม่ส่งข้อมูลจริงเข้าไปได้

และนี่คือตัวอย่าง storybook ของ Register component (ตัวอย่างการเล่น Interaction Test ของ Success submit) สังเกตว่าจะขึ้น popup ว่าส่งข้อมูล Success มาได้ แต่ข้อมูลจะไม่ส่งไปยัง API จริงๆออกมาได้

register-run-test.gif

สรุป

และนี่คือตัวอย่างการทำ Component Specs ผ่าน Storybook จะเห็นว่าเราสามารถสร้าง Component ไปพร้อมกับสร้าง Document specs พร้อมๆกันได้ด้วย library storybook โดยไม่จำเป็นต้องทำเอกสารอื่นๆเพิ่มเติม เพียงแค่ลง library และทำตาม specs ของ storybook เราก็จะได้ทั้ง code ที่สามารถนำมาทดสอบ Component ได้ และได้เอกสาร storybook ที่เป็นเอกสาร specs ออกมาได้เช่นเดียวกัน

สำหรับใครที่กำลังหาเครื่องมือสำหรับเขียนเอกสาร Frontend และ Component อยู่ ลองพิจารณา Storybook ให้เป็นอีกหนึ่งตัวที่ช่วยเสริมพลังของ Frontend กันดูนะครับ 😁


Related Post

Share on social media