- Unit Testing ด้วย Vitest
- Vitest คืออะไร?
- การติดตั้ง Vitest
- โครงสร้าง Project ตัวอย่าง
- เขียน Unit Test แรก
- Assertion ด้วย expect()
- ทดสอบ Error Handling
- Test Lifecycle
- Async Testing
- Mock Function ด้วย vi.fn()
- Mock Module ด้วย vi.mock()
- Spy ด้วย vi.spyOn()
- Parameterized Testing ด้วย it.each()
- Snapshot Testing
- การตั้งค่า Vitest
- Unit Testing React ด้วย Vitest
- ตัวอย่าง React Component Testing
- ทดสอบ User Interaction
- Code Coverage
- Watch Mode
- คำสั่ง Vitest ที่ใช้บ่อย
- ใช้ Vitest กับ GitHub Actions
- AAA Pattern
- FIRST Principles
- สิ่งที่ควรหลีกเลี่ยง
- Testing Pyramid
- Vitest กับ Jest
- Best Practices
- สรุป
#Unit Testing ด้วย Vitest
การเขียนโปรแกรมให้ “ทำงานได้” เพียงอย่างเดียวอาจยังไม่เพียงพอ เพราะเมื่อระบบมีขนาดใหญ่ขึ้น การแก้ไขโค้ดเพียงจุดเดียวอาจส่งผลกระทบต่อส่วนอื่นโดยไม่รู้ตัว
หนึ่งในแนวทางสำคัญสำหรับลดปัญหานี้คือ Unit Testing ซึ่งเป็นการทดสอบหน่วยย่อยของโปรแกรม เช่น Function, Class, Module, Service หรือ Component เพื่อยืนยันว่าแต่ละส่วนทำงานตรงตามที่คาดหวัง
สำหรับโครงการ JavaScript และ TypeScript สมัยใหม่ โดยเฉพาะโครงการที่พัฒนาด้วย Vite, React, Vue หรือ Svelte เครื่องมือที่เหมาะอย่างมากคือ Vitest
Vitest เป็น Testing Framework ที่ออกแบบมาให้ทำงานร่วมกับ Vite ได้อย่างใกล้ชิด ทำให้สามารถใช้ Configuration, TypeScript, JSX/TSX และ Module Resolution แบบเดียวกับ Application ได้
#Unit Testing คืออะไร?
Unit Testing คือการทดสอบส่วนย่อยที่สุดของโปรแกรมโดยแยกออกจาก Dependency อื่นให้มากที่สุด
ตัวอย่าง:
export function add(a: number, b: number) {
return a + b
}
เราต้องการตรวจสอบว่า:
add(2, 3)
ให้ผลลัพธ์เป็น:
5
แนวคิดพื้นฐาน:
Input
↓
Unit / Function
↓
Expected Output
Unit Test ที่ดีควรรันเร็ว เป็นอิสระจาก Test อื่น ให้ผลลัพธ์ซ้ำได้ และตรวจสอบ Pass/Fail ได้อัตโนมัติ
#Vitest คืออะไร?
Vitest คือ Testing Framework สำหรับ JavaScript และ TypeScript ที่ทำงานร่วมกับ Vite ได้โดยตรง
จุดเด่นสำคัญ:
- รองรับ JavaScript และ TypeScript
- รองรับ JSX และ TSX
- API คล้าย Jest
- มี Assertion API ด้วย
expect - รองรับ Mock และ Spy ผ่าน
vi - รองรับ Snapshot Testing
- รองรับ Async Testing
- รองรับ Code Coverage
- รองรับ DOM Environment
- ใช้ร่วมกับ React Testing Library ได้
- ใช้กับ CI/CD ได้ง่าย
- มี Watch Mode สำหรับ Development
#การติดตั้ง Vitest
ติดตั้ง Vitest เป็น Development Dependency
npm install -D vitest
เพิ่ม Script ใน package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
รัน Test แบบ Watch Mode
npm run test
รันเพียงครั้งเดียว
npm run test:run
#โครงสร้าง Project ตัวอย่าง
my-project/
├── src/
│ ├── math.ts
│ ├── math.test.ts
│ ├── user-service.ts
│ └── user-service.test.ts
├── package.json
└── vitest.config.ts
การวาง Test ใกล้ Source Code ช่วยให้ค้นหาและ Refactor ได้ง่าย
#เขียน Unit Test แรก
ไฟล์ src/math.ts
export function add(a: number, b: number) {
return a + b
}
ไฟล์ src/math.test.ts
import { describe, expect, it } from 'vitest'
import { add } from './math'
describe('add()', () => {
it('should add two numbers correctly', () => {
expect(add(2, 3)).toBe(5)
})
})
รันด้วย:
npm run test
#Assertion ด้วย expect()
ตัวอย่าง Matcher ที่ใช้บ่อย:
expect(result).toBe(10)
expect(user).toEqual({ id: 1, name: 'Alice' })
expect(isActive).toBe(true)
expect(message).toContain('success')
expect(users).toHaveLength(3)
expect(result).toBeNull()
expect(result).toBeUndefined()
ตรวจสอบ Exception:
expect(() => {
throw new Error('Invalid input')
}).toThrow('Invalid input')
#ทดสอบ Error Handling
export function divide(a: number, b: number) {
if (b === 0) {
throw new Error('Cannot divide by zero')
}
return a / b
}
Test:
import { describe, expect, it } from 'vitest'
import { divide } from './divide'
describe('divide()', () => {
it('should divide numbers correctly', () => {
expect(divide(10, 2)).toBe(5)
})
it('should throw error when divided by zero', () => {
expect(() => divide(10, 0))
.toThrow('Cannot divide by zero')
})
})
ควรทดสอบทั้ง Happy Path, Edge Case และ Error Case
#Test Lifecycle
Vitest รองรับ:
beforeEach()
afterEach()
beforeAll()
afterAll()
ตัวอย่าง:
import { beforeEach, describe, expect, it } from 'vitest'
let counter: number
beforeEach(() => {
counter = 0
})
describe('counter', () => {
it('should start at zero', () => {
expect(counter).toBe(0)
})
it('should increase value', () => {
counter++
expect(counter).toBe(1)
})
})
#Async Testing
export async function getUser() {
return {
id: 1,
name: 'Alice'
}
}
Test:
import { expect, it } from 'vitest'
import { getUser } from './user'
it('should load user', async () => {
const user = await getUser()
expect(user.name).toBe('Alice')
})
หรือ:
await expect(getUser()).resolves.toEqual({
id: 1,
name: 'Alice'
})
กรณี Reject:
await expect(loadData())
.rejects
.toThrow('Network Error')
#Mock Function ด้วย vi.fn()
import { expect, it, vi } from 'vitest'
it('should call callback', () => {
const callback = vi.fn()
callback('hello')
expect(callback).toHaveBeenCalled()
expect(callback).toHaveBeenCalledWith('hello')
})
เหมาะกับ Dependency เช่น API, Database, Email Service และ Payment Gateway
#Mock Module ด้วย vi.mock()
// api.ts
export async function fetchUser() {
const response = await fetch('/api/user')
return response.json()
}
// user-service.ts
import { fetchUser } from './api'
export async function getUsername() {
const user = await fetchUser()
return user.name
}
Test:
import { beforeEach, expect, it, vi } from 'vitest'
import { fetchUser } from './api'
import { getUsername } from './user-service'
vi.mock('./api', () => ({
fetchUser: vi.fn()
}))
beforeEach(() => {
vi.clearAllMocks()
})
it('should return username', async () => {
vi.mocked(fetchUser).mockResolvedValue({
id: 1,
name: 'Alice'
})
const name = await getUsername()
expect(name).toBe('Alice')
})
#Spy ด้วย vi.spyOn()
const userService = {
getName() {
return 'Alice'
}
}
import { expect, it, vi } from 'vitest'
it('should call getName()', () => {
const spy = vi.spyOn(userService, 'getName')
userService.getName()
expect(spy).toHaveBeenCalled()
})
#Parameterized Testing ด้วย it.each()
import { expect, it } from 'vitest'
import { add } from './math'
it.each([
[1, 2, 3],
[5, 5, 10],
[10, -5, 5]
])(
'add(%i, %i) should equal %i',
(a, b, expected) => {
expect(add(a, b)).toBe(expected)
}
)
เหมาะกับ Validation, Calculation, Boundary Testing และหลายชุด Input
#Snapshot Testing
import { expect, it } from 'vitest'
it('user snapshot', () => {
const user = {
id: 1,
name: 'Alice',
role: 'admin'
}
expect(user).toMatchSnapshot()
})
Snapshot เหมาะกับ UI Output, Serialized Data, Object ขนาดใหญ่ และ Component Rendering แต่ไม่ควรใช้แทน Assertion ทุกกรณี
#การตั้งค่า Vitest
ไฟล์ vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: true
}
})
Environment ที่ใช้บ่อย:
node
jsdom
happy-dom
node เหมาะกับ Business Logic และ Service ส่วน jsdom หรือ happy-dom เหมาะกับ Frontend และ DOM Testing
#Unit Testing React ด้วย Vitest
ติดตั้ง:
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Config:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts'
}
})
ไฟล์ src/test/setup.ts
import '@testing-library/jest-dom/vitest'
#ตัวอย่าง React Component Testing
type CounterProps = {
count: number
}
export function Counter({ count }: CounterProps) {
return <div>Count: {count}</div>
}
Test:
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Counter } from './Counter'
describe('Counter', () => {
it('should display count', () => {
render(<Counter count={5} />)
expect(
screen.getByText('Count: 5')
).toBeInTheDocument()
})
})
#ทดสอบ User Interaction
import { useState } from 'react'
export function CounterButton() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}
Test:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, it } from 'vitest'
import { CounterButton } from './CounterButton'
it('should increment count when button is clicked', async () => {
const user = userEvent.setup()
render(<CounterButton />)
await user.click(screen.getByRole('button'))
expect(
screen.getByText('Count: 1')
).toBeInTheDocument()
})
#Code Coverage
ติดตั้ง V8 Coverage Provider
npm install -D @vitest/coverage-v8
รัน:
npm run test:coverage
หรือ:
npx vitest run --coverage
Metrics ที่ใช้บ่อย:
Statements
Branches
Functions
Lines
กำหนด Threshold:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
})
Coverage สูงไม่ได้แปลว่าไม่มี Bug ควรใช้ Coverage เพื่อช่วยค้นหาส่วนที่ยังไม่ได้รับการทดสอบ
#Watch Mode
npm run test
Workflow:
เขียน Code
↓
เขียน Test
↓
Vitest
↓
แก้ Code
↓
Run Test อีกครั้ง
#คำสั่ง Vitest ที่ใช้บ่อย
# Watch Mode
npm run test
# Run ครั้งเดียว
npx vitest run
# Run เฉพาะไฟล์
npx vitest src/math.test.ts
# Run ตามชื่อ Test
npx vitest -t "should add"
# Run พร้อม Coverage
npx vitest run --coverage
#ใช้ Vitest กับ GitHub Actions
สร้างไฟล์ .github/workflows/test.yml
name: Unit Test
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:run
- name: Run coverage
run: npm run test:coverage
Pipeline:
Developer
↓
Git Push
↓
GitHub
↓
GitHub Actions
↓
npm ci
↓
Vitest
↓
Pass / Fail
#AAA Pattern
AAA = Arrange, Act, Assert
it('should calculate discounted price', () => {
// Arrange
const price = 1000
const discount = 10
// Act
const result = applyDiscount(price, discount)
// Assert
expect(result).toBe(900)
})
#FIRST Principles
F = Fast
I = Independent
R = Repeatable
S = Self-validating
T = Timely
Test ที่ดีควรรวดเร็ว เป็นอิสระ รันซ้ำได้ ตรวจสอบตัวเองได้ และเขียนในช่วงเวลาที่เหมาะสม
#สิ่งที่ควรหลีกเลี่ยง
- Test Implementation Detail มากเกินไป
- Mock ทุก Dependency โดยไม่จำเป็น
- ทดสอบเฉพาะ Happy Path
- ให้ Test แต่ละ Case พึ่งพากัน
- ใช้ Coverage เป็นเป้าหมายแทนคุณภาพ Test
ควรเพิ่ม Edge Case เช่น:
null
undefined
empty string
empty array
0
negative value
invalid input
API error
timeout
#Testing Pyramid
E2E Test
▲
/ / Integration Test
/ / Unit Test
ตัวอย่าง Toolchain:
Unit Test
↓
Vitest
Component Test
↓
Vitest + Testing Library
E2E Test
↓
Playwright
#Vitest กับ Jest
Vitest และ Jest มี API ใกล้เคียงกัน เช่น:
describe()
test()
it()
expect()
Vitest ใช้:
vi.fn()
vi.mock()
vi.spyOn()
ในขณะที่ Jest ใช้:
jest.fn()
jest.mock()
jest.spyOn()
Vitest เด่นในโครงการที่ใช้ Vite เพราะสามารถใช้ Toolchain และ Configuration ร่วมกับ Application ได้อย่างสะดวก
#Best Practices
- ตั้งชื่อ Test ให้สื่อถึง Behavior ที่กำลังตรวจสอบ
- ใช้ AAA Pattern เพื่อให้ Test อ่านง่าย
- Test ทั้ง Happy Path และ Edge Case
- หลีกเลี่ยงการเรียก External Service จริงใน Unit Test
- ใช้ Mock เฉพาะเมื่อจำเป็น
- Reset Mock ระหว่าง Test
- อย่าพึ่งพาลำดับการรัน Test
- ใช้ Coverage เพื่อค้นหาช่องว่างของการทดสอบ
- เน้น Behavior มากกว่า Implementation Detail
- รัน Unit Test ใน CI/CD ทุกครั้งที่ Push หรือ Pull Request
#สรุป
Vitest เป็น Testing Framework ที่เหมาะอย่างมากสำหรับ Modern JavaScript และ TypeScript Application โดยเฉพาะ Project ที่ใช้ Vite
Vitest
├── Unit Testing
├── Assertions
├── Async Testing
├── Mocking
├── Spy
├── Snapshot Testing
├── React Component Testing
├── DOM Testing
├── Code Coverage
└── CI/CD Integration
สำหรับ React Application สามารถใช้:
Vitest
+
React Testing Library
+
user-event
+
jsdom
+
Playwright
แบ่งหน้าที่ได้เป็น:
Vitest
↓
Unit Test
Testing Library
↓
Component Behavior
Playwright
↓
End-to-End Test
การมี Unit Test ที่ดีช่วยลด Regression เพิ่มความมั่นใจในการ Refactor และทำให้ CI/CD ตรวจสอบคุณภาพ Software ได้โดยอัตโนมัติก่อนนำขึ้น Production
Write tests. Refactor with confidence. Build better software.
#References
- Vitest: https://vitest.dev/
- Vitest Guide: https://vitest.dev/guide/
- Vitest Coverage: https://vitest.dev/guide/coverage
- Testing Library: https://testing-library.com/
- React Testing Library: https://testing-library.com/docs/react-testing-library/intro/