#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

  1. ตั้งชื่อ Test ให้สื่อถึง Behavior ที่กำลังตรวจสอบ
  2. ใช้ AAA Pattern เพื่อให้ Test อ่านง่าย
  3. Test ทั้ง Happy Path และ Edge Case
  4. หลีกเลี่ยงการเรียก External Service จริงใน Unit Test
  5. ใช้ Mock เฉพาะเมื่อจำเป็น
  6. Reset Mock ระหว่าง Test
  7. อย่าพึ่งพาลำดับการรัน Test
  8. ใช้ Coverage เพื่อค้นหาช่องว่างของการทดสอบ
  9. เน้น Behavior มากกว่า Implementation Detail
  10. รัน 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