มาเรียนรู้การทำ Frontend Testing ผ่าน React กัน

/ 12 min read

Share on social media

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

การทำ Unit test ที่ฝั่ง Frontend คืออะไร ?

ในการทำ Unit test นั้นมันคือการ Test ใน layer ที่เป็นระดับ function ของการทำงาน ซึ่งตามปกติแล้วมันคือการนำ function แต่ละตัวมาทำการสร้าง unit test แต่ละ test case เพื่อดำเนินการ test แต่ละ function ว่าสามารถทำงานได้อย่างถูกต้องหรือไม่

แต่ทีนี้ในฝั่ง Frontend นั้น UI Flow ของฝั่งหน้าบ้านเป็นการประกอบออกมาจากหลายๆส่วนของ function รวมถึง ปัจจุบันหลายๆ Framework นั้นได้ทำการ design เว็บออกมาเป็น Component ต่างๆ ซึ่งเป็นส่วนที่ “เล็กที่สุดของเว็บไซต์นั้น” ออกมา ดังนั้น เวลาที่เราพูดถึง Unit test ฝั่ง Frontend นอกเหนือจากการพูดถึงตัว function แล้ว ยังเป็นการพูดถึงตัวที่เป็น Component Testing ด้วยเช่นเดียวกัน

Unit testing ใน Frontend developer จึงหมายรวมถึง process การ test แต่ละ unit function หรือ “components” ของ Web application code โดยมีจุดประสงค์เพื่อให้แน่ใจว่าแต่ละส่วนของ application (ไม่ว่าจะเป็นทั้ง function และ การทำงานใน component) เป็นไปตามที่เรา expected แล้วหรือไม่

โดยการทำ Component testing (unit test ฉบับ Frontend) เป็น 1 part ที่ถือว่ามีความสำคัญสำหรับงานของ Frontend testing มาก เนื่องจากเป็นการทดสอบ Bahaviour ของตัว Component นั้นด้วยว่า เมื่อส่งค่าเข้าไป หรือมี Event เกิดขึ้นใน Component นั้นยังคงสามารถได้ผลลัพธ์ตามที่เราคาดหวังไว้หรือไม่ออกมาได้

ดังนั้นในหัวข้อนี้ เราจะพูดถึงการทำ Component testing กัน โดยเราจะขอหยิบ Framework ที่ฮอตฮิตที่สุดหนึ่งตัวอย่าง “React” มาเป็นตัวอย่างให้เห็นภาพกันว่า ถ้าเราจะทำ Component testing นั้นเราสามารถทำอย่างไรได้บ้าง

(สำหรับใครที่อยากดูเรื่องของ Testing เพิ่มเติมสามารถดูหัวข้อ Software Testing ปกติต้องทำอะไรบ้าง ? ได้จาก ที่นี่)

รู้จักกับ Library Testing

ทีนี้ Frontend แต่ละตัวนั้นก็จะมี tool ที่เหมาะสำหรับการใช้ทำ Unit test แตกต่างกันไป แต่โดยไอเดียแล้ว Unit test tool จะมีเครื่องมือสำหรับการทำ 2 อย่างไว้เสมอคือ

  1. Library สำหรับการ run test : เป็น Library สำหรับการสร้างแต่ละ Test case ซึ่งจะรวมถึงการ mock test, spying และรวมคำสั่งสำหรับการตรวจสอบผล Test case เอาไว้ ตัวอย่างเช่น Jest, Sinon, Mocha รวมถึง Vitest ซึ่งจะเป็นตัวที่เราหยิบมาใช้
  2. Library สำหรับจัดการ DOM: เป็น Library ตัวช่วยสำหรับจัดการ render component, จัดการ Event รวมถึงใช้สำหรับการ search DOM ภายใน Component ออกมา เป็น Library ที่ช่วยจำลองการเกิด Event ของ user ออกมาได้ เพื่อให้สามารถได้ผลลัพธ์ของสิ่งๆนั้นออกมาได้ ซึ่งตัวนี้ก็จะเป็นไปตามแต่ละ Framework เลยว่ามีตัวไหนสนับสนุนอยู่บ้าง เช่น Enzyme, Cypress รวมถึง React Testing Library ที่เราจะใช้สำหรับการทำ unit test ของ React

1 - Vitest

Ref: https://vitest.dev/

Vitest คือ unit test framework ของ Javascript ที่ design อยู่บนพื้นฐานของ Jest (แบบ lightweight ออกมา) ซึ่งตามชื่อของมันเลย มันถูกสร้างโดยทีมเดียวกันกับ Vite ซึ่งเป็น popular frontend build tool ตัวหนึ่งของโลก web framework ในปัจจุบันเลยก็ว่าได้ โดย Vitest นั้นได้เตรียม feature สำหรับ support การทำ test environment ไว้แล้วเป็นที่เรียบร้อย โดยจุดเด่นๆของ Vitest คือ

  1. Integration with Vite Vitest นั้นทำการ integrated แบบ seamless ไว้คู่กับ Vite มาก หากใครใช้ Vite นั้นจะสามารถเพิ่ม config ผ่าน vite ได้เลย โดยจะได้คุณสมับัติการโหลด module ที่ไวและ bundling capabilities (support การ build ไปพร้อมๆกับการ run vite) ออกมาได้ ซึ่งหากใครเริ่ม project โดยใช้ Vite (ซึ่งเอาเข้าจริงๆหากใครเริ่ม project frontend ตัวใหม่ๆในยุคนี้ ส่วนใหญ่ก็จะเริ่มจาก config ของ Vite กัน) แนะนำให้ใช้ Vitest ได้เลย เพราะลงของไม่เยอะมาก ก็สามารถได้คุณสมับัติการทำ unit test ออกมาครบผ่าน Vitest ได้

  2. Fast Performance ไวครับ สั้นๆเลย เป็นหนึ่งใน library unit test ที่ run ไวมาก

  3. Compatibility with Jest support กับการใช้งานร่วมกับ Jest อยู่แล้ว สามารถ run ของส่วนใหญ่ที่ใช้งานผ่าน Jest ได้ผ่าน Vitest ได้เลย (Jest ก็ถือเป็น 1 ใน library ยอดฮิตสำหรับการทำ unit test เช่นเดียวกัน)

  4. Built-in Test Runners and Assertion Library มี library สำหรับการ run test ของตัวเอง (test runners) และ assertion สำหรับการตรวจสอบ test อยู่ในตัวอยู่แล้ว (assertion ให้อารมณ์เหมือนเครื่องมือสำหรับการตรวจสอบผล test ว่าออกมาถูกหรือไม่ ซึ่งถ้าออกมาไม่ถูกต้องก็จะสามารถ report กลับไปยังตัว test ที่กำลัง run อยู่ได้) รวมถึงมี feature อย่างการทำ mocking, spying และ snapshot testing ที่ช่วยทำให้สามารถ mock service ต่างๆเพื่ออำนวยความสะดวกในการ run unit test ออกมาได้

  5. Support test หลายประเภท นอกเหนือจาก unit test และ component test แล้ว Vitest ยัง support การทำแบบ end-to-end test เช่นเดียวกัน (จำลองเหมือน user กำลังเล่นจริง) ส่งผลทำให้สามารถเขียน Test case แบบครอบคลุมที่ฝั่ง Frontend ออกมาได้เลย

  6. UI integration / Code coverage มี feature ที่สามารถแสดงผลลัพธ์ผ่าน UI รวมถึง Code coverage ที่สามารถตรวจสอบได้ว่า code unit test นั้น cover code ทั้งหมดของ Frontend แล้วหรือไม่

ด้วยสิ่งที่เขียนมาทั้งหมดนี้ ทำให้ Vitest เป็นหนึ่งในเครื่องมือสำหรับการทำ unit test ที่ครอบคลุมในการทำ unit test และ component test อีกหนึ่งตัวของโลก Frontend เลยก็ว่าได้ เดี๋ยวเราจะมาใช้ตัวนี้สำหรับการทำ unit test กัน

2 - React Testing Library

Ref: https://testing-library.com/docs/react-testing-library/intro/

React Testing Library คือ library สำหรับการทำ testing React component ซึ่งเป็นส่วนหนึ่งที่ทำให้การเขียน test สามารถเชื่อมต่อกับ UI component เข้าไปได้ โดย จุดเด่นหลักของ React Testing Library คือ

  1. User Testing like จุดหลักของ React Testing Library คือการที่ สามารถ interact กับ UI Component เหมือนกับเป็น user คนหนึ่งใช้งานออกมาได้ (เช่น กดปุ่ม, พิมพ์ใส่ input) โดยที่ไม่ต้องจัดการอะไรเกี่ยวกับ state ภายใน Component เพื่อเป็นการทดสอบ bahaviors ของ user ไปพร้อมๆกันกับผลลัพธ์ผ่าน HTML (DOM) ออกมาได้

  2. Works with Real DOM ตัว React Testing Library ข้อดีใหญ่ๆอีกอย่างคือการ render Component จริงๆออกมาที่ test environment ได้จริงๆ (บางเจ้า จะใช้วิธี shallow rendering ซึ่งเป็นเพียงการ process คำสั่งภายใน Component แค่นั้น ไม่ได้มีการ render จริงออกมา) สิ่งนี้จึงส่งผลทำให้สามารถจัดการ Element และ Event เหมือนกับที่ User เห็น และกำลังทำบน Browser ออกมาได้ (เป็นที่มาของข้อดีในข้อที่ 1)

  3. Query Methods มีการเตรียมคำสั่ง query สำหรับการค้นหา element ภายใน page (คล้ายๆกับท่าของ CSS Selector) ซึ่ง query นี้ก็จะสามารถค้นหาได้ตั้งแต่ based on role, text, test IDs เป็นต้น เพื่อให้สามารถเข้าถึง UI จากทุกส่วนได้ดียิ่งขึ้น

  4. Integration with Other Testing Tools รวมถึงอีกอย่างหนึ่ง (ซึ่งเป็นจุดพิจารณาของเราด้วย) คือการใช้งานร่วมกับ Test runner library ตัวอื่นอย่าง Jest (หรือ Vitest) เพื่อให้สามารถเสริมกำลังในการทำ Unit test ของกันและกันออกมาได้

และนี่ก็คือ 2 Testing library ที่เราทำการเลือกมาเพื่อทำ unit test วันนี้ เดี๋ยวในหัวข้อนี้เราจะมาเล่นผ่านแต่ละตัวอย่างเพื่อให้เห็นภาพการใช้งานทั้ง 2 ตัวกันนะครับ

มาเริ่มทำ Test กัน

เราจะมาพาทำ Unit test ทั้ง 3 เคสกันคือ

  1. ทำกับ Counter component = แสดงผลออกมาและมีปุ่มสำหรับเพิ่ม Counter
  2. ทำกับ Uset List component = ดึงข้อมูลจาก User API แล้วเอามาแสดงพร้อมสามารถค้นหาผ่าน Search Box ได้
  3. ทำกับ Register component = มี Form สำหรับจัดการกรอก form และ สามารถ submit form เพื่อส่งข้อมูลผ่าน API ได้

คิดว่า 3 user cases นี้จะเพีิยงพอทำให้เราเห็นภาพการทำ Unit test ต่างๆในแต่ละเคสได้แล้วนะครับ

Setting project

เราจะทำการลง library ทั้ง 2 ตัวไว้คือ Vitest และ React Testing Library โดยที่

เราจะ start project จาก vite command ใน https://vitejs.dev/guide/ เป็นการเริ่มต้น project React ออกมา

Terminal window
npm create vite@latest react-test-app -- --template react

หลังจากนั้นทำการลง library ที่เกี่ยวข้องกับการ Test

Terminal window
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom

ที่ vite.config.js ทำการเพิ่ม config สำหรับการเรียกใช้งาน test

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./test-setup.js",
},
});

โดยตรงตำแหน่ง setupFiles เป็นการเรียกใช้งาน library @testing-library/jest-dom เพิ่มให้ทำการสร้างไฟล์ test-setup.js ที่ทำการ import library เพิ่มเข้ามา

test-setup.js
import "@testing-library/jest-dom";

หลังจากนั้นให้เราลอง run ด้วยคำสั่ง npx vitest ดู ถ้าสามารถ run ได้โดยบอกว่ายังไม่เจอ test file ใดๆ = เท่ากับว่าการ setup นี้ถูกต้องเรียบร้อย

setup-vitest

เราจะมาเริ่มทำ unit test แต่ละ Component กันโดยต่อจากนี้ที่เราจะวาง 3 Component นั้นเราจะวาง Structure กันตามนี้

Terminal window
.
├── src
├── App.jsx --> component หลักที่เรียก
├── components --> สำหรับเก็บ component ทั้งหมดเอาไว้
├── Counter.jsx
├── Counter.test.jsx
├── RegisterForm.jsx
├── RegisterForm.test.jsx
├── UserList.jsx
└── UserList.test.jsx
├── index.css
└── main.jsx
├── test-setup.js
└── vite.config.js

โดยเราจะวางไฟล์ test คู่กันกับ Component เช่น ถ้าเราจะสร้าง test สำหรับ Counter.jsx เราก็จะวาง Counter.test.jsx เป็นการเก็บ test case ของ Counter เอาไว้ เป็นต้น

มาเริ่มทำ Unit test กัน

1. Counter component

Terminal window
├── src
├── App.jsx --> component หลักที่เรียกใช้ Counter
├── components
├── Counter.jsx
├── Counter.test.jsx
└── main.jsx

สำหรับ Component แรก Counter.jsx สิ่งที่ Counter สามารถทำได้คือ

  1. สามารถแสดงผล counter ออกมาได้ (ผ่านตัวแปร count ที่จะผูกกับ React hook เอาไว้)
  2. สามารถเพิ่ม และ ลด Counter จากการคลิกปุ่มได้ (คลิกเพื่อเพิ่มและคลิกเพื่อลดได้)

หน้าตา code Component ก็จะมีประมาณนี้

import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Counter: {count}</h1>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
export default Counter;

ทีนี้เมื่อมาเขียนเป็น Unit test สิ่งที่เราจะต้องทดสอบคือ

  1. Counter render ออกมาได้จริงๆใช่ไหม (มั่นใจใช่ไหมว่าไม่มี error อะไรตอนจังหวะ render)
  2. ทดสอบว่าเพิ่มได้จริงไหม
  3. ทดสอบว่าลดได้จริงไหม

เราจึงสามารถเขียน unit test เป็นแบบนี้ออกมาได้

import { describe, it, expect, beforeEach } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import Counter from "./Counter"
describe("Counter Component", () => {
beforeEach(() => {
render(<Counter />)
})
it("should render counter", () => {
expect(screen.getByText(/Counter:/)).toBeInTheDocument()
})
it("increments counter", () => {
fireEvent.click(screen.getByText("Increment"))
expect(screen.getByText("Counter: 1")).toBeInTheDocument()
})
it("decrements counter", () => {
fireEvent.click(screen.getByText("Decrement"))
expect(screen.getByText("Counter: -1")).toBeInTheDocument()
})
})

อธิบายเพิ่มเติมจาก code

  • screen.getByText(/Counter:/) เป็นคำสั่งสำหรับการค้นหา DOM ที่อยู่บน Screen ที่ใส่คำนี้ไว้
  • .toBeInTheDocument() เป็นคำสั่งสำหรับการเช็คว่าสิ่งนั้นเจอหรือไม่เจอ (ใช้คู่กับคำสั่งค้นหา DOM)
  • อย่างเคสใน code เหล่านี้คือ เป็นการตรวจสอบว่า
    • หลังจาก render ออกมามีคำว่า Counter ไหม
    • หลังจากกดปุ่มที่มีคำว่า “Increment” > ได้ “Counter: 1” แสดงผลออกมาจริงไหม (เป็นหลักฐานว่ามันเพิ่มขึ้นมาแล้วจริงๆ)
    • หลังจากกดปุ่มที่มีคำว่า “Decrement” > ได้ “Counter: -1” แสดงผลออกมาจริงไหม (เป็นหลักฐานว่ามันลดแล้วจริงๆ)

2. User List component

Terminal window
├── src
├── App.jsx --> component หลักที่เรียกใช้ Counter
├── components
├── UserList.jsx
├── UserList.test.jsx
└── main.jsx

สำหรับ Component UserList.jsx โจทย์ของ Component นี้คือ

  1. ตอน render Component ขึ้นมาทำการดึงข้อมูล user ผ่าน API (ใช้คำสั่ง axios.get ในการดึงข้อมูล)
  2. นำข้อมูลมา render แสดงผลบนตารางทุก user
  3. มี input สำหรับการ search ให้สามารถค้นหา user จากการพิมพ์ใน input เข้ามาได้ (โดยจะทำการ search ตาม name หรือ email ของ user คนนั้น)

ตัว Component code ก็จะมีหน้าตาประมาณนี้ออกมาได้

import { useState, useEffect } from 'react'
import axios from 'axios'
export default function UserList() {
const [users, setUsers] = useState([])
const [searchTerm, setSearchTerm] = useState('')
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await axios.get(
'https://65a25d5342ecd7d7f0a771bd.mockapi.io/users'
)
setUsers(response.data)
} catch (error) {
console.error('Error fetching users:', error)
}
}
fetchUsers()
}, [])
const handleSearch = (event) => {
setSearchTerm(event.target.value)
}
const filteredUsers = users.filter(
(user) =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
)
return (
<div className="container mx-auto p-4">
<input
type="text"
className="p-2 border border-gray-300 rounded mb-4 w-full"
placeholder="Search by name or email"
value={searchTerm}
onChange={handleSearch}
/>
<table className="min-w-full table-auto">
<thead className="bg-gray-200">
<tr>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Email</th>
<th className="px-4 py-2">Phone Number</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id} className="bg-white border-b">
<td className="px-4 py-2">{user.name}</td>
<td className="px-4 py-2">{user.email}</td>
<td className="px-4 py-2">{user.phoneNumber}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

ทีนี้เมื่อมาเขียนเป็น Unit test สิ่งที่เราจะต้องทดสอบคือ

  1. ทำการเช็คก่อนว่าสามารถ render Component มาได้หรือไม่ตอนเรียกใช้ โดย สิ่งที่เพิ่มเติมเข้าไปคือ “การ mock API” ออกมา
  • เนื่องจาก Component นี้มีการเรียกใช้ข้อมูลภายนอก และโจทย์ของ unit test คือ “ต้องสามารถ run ได้โดยไม่เกี่ยวข้องกับ dependency ภายนอก”
  • ดังนั้น เพื่อให้เวลา run unit test ไม่โดนกระทบจากการที่มี network หรือ dependency ภายนอก = ต้องใช้วิธีการ mock service หรือ API นั้นๆเอาไว้
  • โดย vitest นั้นมีคำสั่งสำหรับ support การ mock ไว้เรียบร้อย สามารถอ่านเพิ่มเติมที่นี่ได้ https://vitest.dev/guide/mocking.html
  • ในเคสนี้คือ เราจะทำการ mock API axios ในการเรียก get มาโดย “สมมุติิว่า” การเรียกนั้น success และได้ผลลัพธ์เป็น JSON ตาม code นี้ออกมา (ที่หน้าตาเหมือนผลลัพธ์ที่ได้จากการดึง API) เพื่อเป็นการตรวจสอบว่า ถ้าได้ผลลัพธ์ผ่าน API แบบนี้มา จะสามารถ render component ออกมาได้หรือไม่
  1. ทดสอบว่า สามารถ search ข้อมูลออกมาถูกต้องได้หรือไม่ โดยตรวจสอบได้จากการจำลอง data ใน API และตรวจสอบว่าหลังจากพิมพ์เข้าไป ข้อมูลหนึ่งจะต้องแสดงออกมา และ ข้อมูลที่ไม่มีใน list จะต้องหายไป
  2. หาก API เกิด Error หน้าจอยังต้องสามารถ render ออกมาได้ (จริงๆเคสนี้สามารถ improve เพิ่มได้จากการเพิ่ม Error message)

code Unit test ก็จะมีหน้าตาประมาณนี้

import { describe, it, expect, vi } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import axios from 'axios'
import UserList from './UserList'
// Mock axios
vi.mock('axios')
describe('UserList component', () => {
const mockUsers = [
{
id: '1',
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '1234567890',
},
{
id: '2',
name: 'Jane Doe',
email: 'jane@example.com',
phoneNumber: '0987654321',
},
]
it('renders the table successfully when API call succeeds', async () => {
axios.get.mockResolvedValue({ data: mockUsers })
render(<UserList />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('jane@example.com')).toBeInTheDocument()
})
})
it('filters users based on search input', async () => {
axios.get.mockResolvedValue({ data: mockUsers })
render(<UserList />)
await waitFor(() => {
fireEvent.change(screen.getByPlaceholderText('Search by name or email'), {
target: { value: 'John' },
})
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument()
})
})
it('handles API failure without problems and still renders', async () => {
axios.get.mockRejectedValue(new Error('API call failed'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
render(<UserList />)
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Error fetching users:',
expect.any(Error)
)
expect(
screen.getByPlaceholderText('Search by name or email')
).toBeInTheDocument()
})
consoleSpy.mockRestore()
})
})

3. Register component

Terminal window
.
├── src
├── App.jsx --> component หลักที่เรียก
├── components --> สำหรับเก็บ component ทั้งหมดเอาไว้
├── RegisterForm.jsx
├── RegisterForm.test.jsx
└── main.jsx

สำหรับ Component RegisterForm.jsx โจทย์ของ Component นี้คือ

  1. มี Form มีสามารถกรอกข้อมูลได้ 3 อย่างคือ name (ชื่อจริง), email (อีเมล) และ phone number (เบอร์โทรศัพท์)
  2. สามารถ validation ได้โดย
  • validate name ว่าต้องกรอก
  • validate email ว่าต้องถูก format และต้องเป็น email ที่ถูกต้อง
  • validate phone number ว่าต้องมีตัวเลขครบ 10 ตัว
  1. เมื่อข้อมูลถูกต้อง ต้องสามารถ submit ข้อมูลไปยัง API ได้

ตัว Component code ก็จะมีหน้าตาประมาณนี้ออกมาได้

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="max-w-sm mx-auto my-8">
<h1 className="text-3xl mb-2">Register Form</h1>
<div className="mb-6">
<label
htmlFor="name"
className="block mb-2 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="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div>
<div className="mb-6">
<label
htmlFor="email"
className="block mb-2 text-sm font-medium text-gray-900"
>
Email
</label>
<input
type="email"
id="email"
name="email"
onChange={handleChange}
value={formData.email}
data-testid="email"
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
/>
{errors.email && (
<p className="text-xs text-red-500">{errors.email}</p>
)}
</div>
<div className="mb-6">
<label
htmlFor="phoneNumber"
className="block mb-2 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="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
/>
{errors.phoneNumber && (
<p className="text-xs text-red-500">{errors.phoneNumber}</p>
)}
</div>
{/* Submit button */}
<button
type="submit"
className="text-white bg-blue-500 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm w-full px-5 py-2.5 text-center"
>
Submit
</button>
</form>
</>
)
}

ทีนี้เมื่อมาเขียนเป็น Unit test สิ่งที่เราจะต้องทดสอบคือ

  1. ทดสอบก่อนว่าสามารถ render หน้า Form ออกมาได้ไหม
  2. หากยังไม่ได้กรอกอะไร สามารถแสดง Error ทั้งหมดออกมาได้ไหม (ต้องกรอกชื่อ, ต้องกรอก email, ต้องกรอก phone number)
  3. หากกรอก email มาผิด format > สามารถแสดง Error email ผิด format ออกมาได้ (จริงๆ ควรเพิ่มกับเคส phone number ด้วย แต่อันนี้เป็นตัวอย่างที่เพิ่มให้ดูกับเคส email)
  4. ถ้าข้อมูลทุกอย่างถูกต้อง function การ submit ต้องทำงานได้อย่างถูกต้องหาก API ไม่ได้มีปัญหาอะไร (และสามารถเพิ่มเคสในกรณีที่ API Error ออกมาได้)

ซึ่งในเคสนี้สิ่งที่เราต้องทำเพิ่มคือ

  • mock axios.post สำหรับจำลอง response หลังจากส่งข้อมูลไปแล้วให้สามารถตอบรับ success มาได้ (โดยไม่จำเป็นต้องส่ง Request จริงๆ)
  • mock window.alert เนื่องจากตอน run unit test เราไม่ได้มีการ run browser จริงๆออกมา ส่งผลให้ window.alert ไม่สามารถแสดงผลออกมาได้และ ตัวแปร window ไม่สามารถทำงานได้เนื่องจากเป็นตัวแปรที่ทำงานบน Browser

เพื่อให้การ run test ของเราสามารถ run test ในทุกๆรอบได้ โดยที่ไม่มี dependency มาเกี่ยวข้องได้

import { describe, it, expect, vi } from "vitest"
import { render, fireEvent, waitFor } from "@testing-library/react"
import RegisterForm from "./RegisterForm"
import axios from "axios"
// Mock axios to avoid real API calls
vi.mock("axios")
describe("App component", () => {
beforeAll(() => {
// Mock window.alert
global.window.alert = vi.fn()
})
it("renders the form", () => {
const { getByLabelText, getByText } = render(<RegisterForm />)
expect(getByLabelText(/name/i)).toBeInTheDocument()
expect(getByLabelText(/email/i)).toBeInTheDocument()
expect(getByLabelText(/phone number/i)).toBeInTheDocument()
expect(getByText(/submit/i)).toBeInTheDocument()
})
it("shows validation errors", () => {
const { getByText } = render(<RegisterForm />)
fireEvent.click(getByText(/submit/i))
expect(getByText(/name is required/i)).toBeInTheDocument()
expect(getByText(/email is required/i)).toBeInTheDocument()
expect(getByText(/phone number is required/i)).toBeInTheDocument()
})
it("shows validation email format errors", () => {
const { getByLabelText, getByText } = render(<RegisterForm />)
fireEvent.change(getByLabelText(/name/i), { target: { value: "John Doe" } })
fireEvent.change(getByLabelText(/email/i), {
target: { value: "johndoe@example" },
})
fireEvent.change(getByLabelText(/phone number/i), {
target: { value: "1234567890" },
})
fireEvent.click(getByText(/submit/i))
expect(getByText(/email is invalid/i)).toBeInTheDocument()
})
it("submits form successfully", async () => {
const { getByLabelText, getByText } = render(<RegisterForm />)
const mockResponse = {
data: {
id: 1,
name: "John Doe",
email: "johndoe@example.com",
phoneNumber: "1234567890",
},
}
axios.post.mockResolvedValue(mockResponse)
fireEvent.change(getByLabelText(/name/i), { target: { value: "John Doe" } })
fireEvent.change(getByLabelText(/email/i), {
target: { value: "johndoe@example.com" },
})
fireEvent.change(getByLabelText(/phone number/i), {
target: { value: "1234567890" },
})
fireEvent.click(getByText(/submit/i))
await waitFor(() => {
expect(axios.post).toHaveBeenCalledWith(
"https://65a25d5342ecd7d7f0a771bd.mockapi.io/users",
{
name: "John Doe",
email: "johndoe@example.com",
phoneNumber: "1234567890",
}
)
})
})
})

เพิ่มเติมเรื่อง code coverage

นอกเหนือจากการ run test แล้ว ยังมีสิ่งที่เรียกว่า Code coverage ที่จะเป็นการ confirm ด้วยว่า unit test cover หรือไม่

Code coverage คือ ตัวชี้วัดในการทำ software testing เพื่อเป็นการอธิบายว่า source นั้นได้ถูกทดสอบไปแล้วทั้งหมดเท่าใด และครอบคลุมส่วนของ source code มากน้อยเพียงใดออกมาได้

วิธีการตรวจสอบ สามารถทำได้ด้วยคำสั่ง

Terminal window
vitest --coverage
coverage

รวมถึงสามารถดูผลเทสทั้งหมดผ่าน UI ได้จากคำสั่งนี้เช่นเดียวกัน

Terminal window
vitest --ui
unit-test-ui

ดังนั้น การตรวจสอบ unit test สามารถตรวจสอบผ่าน command โดยตรงหรือสามารถตรวจสอบผ่าน UI ก็ได้เช่นกัน

สรุปหัวข้อ

และนี่คือตัวอย่างทั้ง 3 เคสของการทำ Unit test ซึ่งประกอบไปด้วย การ run component test แบบ function ปกติ, function ที่เกี่ยวกับการดึงข้อมูล และ function ที่เกี่ยวกับการ submit ข้อมูล ในเคสของการทำ component test ทั่วๆไปก็ไม่ต่างจากนี้มาก เพียงแต่จะต้อง mock แต่ละส่วนออกมาให้ถูกต้อง เพื่อให้สามารถจำลองการ run unit test ซ้ำในทุกๆรอบออกมาได้

Frontend testing นั้นถือเป็นหัวใจสำคัญอีกหนึ่งอย่างของ developer เพื่อทำให้คุณภาพของงานและประสบการณ์ ของการใช้งานออกมาได้อย่างถูกต้องตามที่ต้องการ ซึ่ง process นี้จะเป็นส่วนสำคัญที่ช่วยในการตรวจสอบทั้ง UI, interactive feature และ ภาพรวมของการใช้งานใน function ต่างๆด้วยว่าสามารถทำงานได้อย่างถูกต้องตามจุดประสงค์ไหม (นอกเหนือจากการเล่นแบบ End-to-End testing ที่จะต้องเล่นเหมือนกับ user เล่นออกมา) ดังนั้น หากทำ process นี้จนชำนาญเองก็สามารถช่วยเพิ่ม quality ให้กับ software และยังสามารถช่วยตรวจสอบปัญหาของตัว software ก่อนที่จะถึงมือของ user ได้ด้วยเช่นกัน

มาทำ Frontend Unit test กันนะครับ 😁

Related Post

Share on social media