info-vitest-mock-functions-vifn-vispyon-vimock-workshop

กลุ่มผู้อ่าน: ผู้พัฒนา JavaScript/TypeScript, ผู้ทดสอบซอฟต์แวร์ และผู้เรียน Unit Testing
หมายเหตุเวอร์ชัน: ตัวอย่างหลักใช้ API พื้นฐานของ Vitest; หัวข้อ vi.when() ต้องใช้ Vitest 5.0.0 ขึ้นไป

#1. Mock Functions คืออะไร?

Mock Function คือฟังก์ชันที่ใช้แทนฟังก์ชันจริงระหว่างทดสอบ เพื่อควบคุมผลลัพธ์และตรวจสอบการเรียกใช้งานโดยไม่จำเป็นต้องเรียกบริการจริง เช่น Payment Gateway, ฐานข้อมูล, Email API หรือ HTTP API

Mock Function ตอบคำถามสำคัญได้ 3 ด้าน:

  1. Control: เมื่อถูกเรียกแล้วควรคืนค่า/โยนข้อผิดพลาดอะไร?
  2. Observe: ฟังก์ชันถูกเรียกกี่ครั้ง และได้รับอาร์กิวเมนต์อะไร?
  3. Isolate: สามารถทดสอบ Business Logic โดยไม่พึ่ง External Service ได้หรือไม่?
flowchart LR
    T[Unit Test] --> S[System Under Test]
    S --> D{Dependency}
    D -->|Production| R[Real API / Database]
    D -->|Testing| M[Mock Function]
    M --> A[Controlled Result]
    M --> O[Call History & Assertions]

#Mock กับ Spy ต่างกันอย่างไร?

เครื่องมือ ลักษณะ พฤติกรรมเริ่มต้น ใช้เมื่อ
vi.fn() สร้าง Mock Function ใหม่ คืน undefined หากไม่ได้กำหนด implementation ฉีด Dependency, Callback, สร้าง Stub
vi.spyOn(object, 'method') ครอบเมธอดที่มีอยู่แล้ว เรียกเมธอดจริง พร้อมบันทึกการเรียก อยากตรวจสอบการเรียกเมธอดโดยยังคงพฤติกรรมจริง
vi.mock(import('./module.js'), factory) แทนที่ Export ของโมดูล ตามที่กำหนดใน factory หรือการ automock ตัดการเชื่อมต่อกับ API, DB หรือโมดูลภายนอก

vi.fn() และ vi.spyOn() ใช้ API สำหรับตรวจสอบการเรียกและปรับพฤติกรรมหลายรายการร่วมกันได้ เช่น mockReturnValue, mockImplementation, .mock.calls


#2. ติดตั้ง Vitest

ตรวจสอบ Node.js ก่อน โดยเอกสาร Getting Started ของ Vitest เวอร์ชันปัจจุบันระบุ Node.js 22.12.0 ขึ้นไป และ Vite 6.4.0 ขึ้นไป สำหรับสภาพแวดล้อมที่เกี่ยวข้อง

node --version
npm --version

mkdir vitest-mock-workshop
cd vitest-mock-workshop
npm init -y
npm install -D vitest

เพิ่ม Scripts ลงใน package.json:

{
  "type": "module",
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

ตัวอย่างนี้แสดงเฉพาะส่วนของ package.json ที่เกี่ยวข้อง ไม่จำเป็นต้องลบฟิลด์อื่นที่ npm init สร้างไว้

สร้างไฟล์ example.test.js:

import { expect, test, vi } from 'vitest'

test('should call callback', () => {
  const callback = vi.fn()
  callback('hello')

  expect(callback).toHaveBeenCalledOnce()
  expect(callback).toHaveBeenCalledWith('hello')
})

สั่งทดสอบ:

npm run test:run

#3. เริ่มต้น Mock ด้วย vi.fn()

import { expect, test, vi } from 'vitest'

test('create a mock function', () => {
  const sendEmail = vi.fn()

  const result = sendEmail('student@example.com')

  expect(result).toBeUndefined()
  expect(sendEmail).toHaveBeenCalledTimes(1)
  expect(sendEmail).toHaveBeenCalledWith('student@example.com')
})

จุดสำคัญ: vi.fn() เริ่มต้นโดย ไม่ทำงานจริง และบันทึกประวัติการเรียกใช้งานอัตโนมัติ

#3.1 กำหนดผลลัพธ์ด้วย mockReturnValue()

const getStock = vi.fn().mockReturnValue(10)

expect(getStock('book')).toBe(10)
expect(getStock('pen')).toBe(10)

ผลลัพธ์ไม่เปลี่ยนตามอาร์กิวเมนต์ เนื่องจากใช้ค่า Return แบบคงที่

#3.2 กำหนดผลลัพธ์ครั้งถัดไปด้วย mockReturnValueOnce()

const getStatus = vi.fn()
  .mockReturnValue('READY')
  .mockReturnValueOnce('PENDING')
  .mockReturnValueOnce('PROCESSING')

expect(getStatus()).toBe('PENDING')
expect(getStatus()).toBe('PROCESSING')
expect(getStatus()).toBe('READY')

เหมาะสำหรับจำลอง State Transition หรือ Retry Logic ตามลำดับการเรียก

#3.3 สร้าง Logic จำลองด้วย mockImplementation()

const calculateDiscount = vi.fn()
  .mockImplementation((price, percent) => price * (percent / 100))

expect(calculateDiscount(1000, 10)).toBe(100)
expect(calculateDiscount).toHaveBeenCalledWith(1000, 10)

สามารถเขียนสั้นด้วย vi.fn((price, percent) => ...) ได้เช่นกัน

#3.4 ใช้ mockImplementationOnce()

const search = vi.fn(() => 'NORMAL')
  .mockImplementationOnce(() => 'SPECIAL')

expect(search()).toBe('SPECIAL')
expect(search()).toBe('NORMAL')

#4. การ Mock ฟังก์ชันแบบ Asynchronous

สำหรับฟังก์ชันที่คืน Promise ให้ใช้ mockResolvedValue() และ mockRejectedValue() แทนการคืนค่าด้วย mockReturnValue() เมื่ออยากสื่อความหมายของผลลัพธ์แบบ Async ให้ชัดเจน

import { expect, test, vi } from 'vitest'

test('simulate successful API response', async () => {
  const fetchUser = vi.fn().mockResolvedValue({
    id: 101,
    name: 'Mali',
  })

  await expect(fetchUser(101)).resolves.toEqual({
    id: 101,
    name: 'Mali',
  })
})

test('simulate API failure', async () => {
  const fetchUser = vi.fn()
    .mockRejectedValue(new Error('Service unavailable'))

  await expect(fetchUser(101)).rejects.toThrow('Service unavailable')
})

ตัวอย่าง Retry Scenario:

const request = vi.fn()
  .mockResolvedValue({ ok: true })
  .mockRejectedValueOnce(new Error('Temporary failure'))

await expect(request()).rejects.toThrow('Temporary failure')
await expect(request()).resolves.toEqual({ ok: true })
expect(request).toHaveBeenCalledTimes(2)

ฟังก์ชันสำคัญ:

API ความหมาย
mockResolvedValue(data) คืน Promise ที่สำเร็จด้วยค่า data
mockRejectedValue(error) คืน Promise ที่ล้มเหลวด้วย error
mockResolvedValueOnce(data) สำเร็จเฉพาะการเรียกครั้งถัดไป
mockRejectedValueOnce(error) ล้มเหลวเฉพาะการเรียกครั้งถัดไป

#5. ตรวจสอบการเรียกใช้งานด้วย Assertions

import { expect, test, vi } from 'vitest'

test('inspect call history', () => {
  const logger = vi.fn((message) => `logged:${message}`)

  logger('started')
  logger('finished')

  expect(logger).toHaveBeenCalled()
  expect(logger).toHaveBeenCalledTimes(2)
  expect(logger).toHaveBeenNthCalledWith(1, 'started')
  expect(logger).toHaveBeenLastCalledWith('finished')
  expect(logger).toHaveReturnedWith('logged:started')

  expect(logger.mock.calls).toEqual([
    ['started'],
    ['finished'],
  ])
  expect(logger.mock.results[0]).toEqual({
    type: 'return',
    value: 'logged:started',
  })
})
Assertion / Property ตรวจสอบ
toHaveBeenCalled() มีการเรียกอย่างน้อยหนึ่งครั้ง
toHaveBeenCalledOnce() ถูกเรียกหนึ่งครั้ง
toHaveBeenCalledTimes(n) ถูกเรียกทั้งหมด n ครั้ง
toHaveBeenCalledWith(...) เคยเรียกด้วยอาร์กิวเมนต์ที่ระบุ
toHaveBeenNthCalledWith(n, ...) การเรียกครั้งที่ n
toHaveBeenLastCalledWith(...) การเรียกครั้งล่าสุด
toHaveReturnedWith(value) เคยคืนค่าที่ระบุแบบ synchronous
.mock.calls อาร์เรย์รายการอาร์กิวเมนต์ของทุกการเรียก
.mock.results สถานะและค่าที่คืนหรือข้อผิดพลาดที่โยน

ระวัง: .mock.calls เก็บ reference ของออบเจ็กต์ที่ส่งเข้าไป ไม่ได้ Snapshot ไว้ หากออบเจ็กต์ถูกแก้ไขภายหลัง ประวัติการเรียกจะสะท้อนสถานะใหม่ หากต้องตรวจสอบข้อมูล ณ เวลาที่เรียก ให้ clone ค่าใน mock implementation หรือ Assert ก่อนแก้ไขออบเจ็กต์


#6. ใช้ vi.spyOn() เพื่อตรวจสอบเมธอดจริง

import { expect, test, vi } from 'vitest'

test('spy on existing method', () => {
  const calculator = {
    add(a, b) {
      return a + b
    },
  }

  const spy = vi.spyOn(calculator, 'add')

  expect(calculator.add(4, 5)).toBe(9) // ทำงานจริงตามเดิม
  expect(spy).toHaveBeenCalledWith(4, 5)

  spy.mockReturnValue(100)             // เปลี่ยนพฤติกรรม
  expect(calculator.add(4, 5)).toBe(100)

  spy.mockRestore()                    // คืนเมธอดต้นฉบับ
  expect(calculator.add(4, 5)).toBe(9)
})

ข้อควรจำ: vi.spyOn() เริ่มติดตามการเรียกได้หลังสร้าง Spy เท่านั้น การเรียกก่อนหน้าไม่ถูกบันทึก ใน Vitest Browser Mode ไม่สามารถใช้ Spy กับ Export ของ Native ESM Module โดยตรงได้ทุกกรณี; ใช้ vi.mock('./module.js', { spy: true }) แทนสำหรับกรณีดังกล่าว


#7. ใช้ vi.mock() จำลองทั้ง Module

สมมติว่ามีโมดูล src/user-api.js:

export async function fetchUser(id) {
  // ในระบบจริงอาจใช้ fetch() หรือฐานข้อมูล
  throw new Error('External API should not be called in unit tests')
}

และ Service ที่ใช้โมดูลดังกล่าว src/user-greeter.js:

import { fetchUser } from './user-api.js'

export async function greeting(userId) {
  const user = await fetchUser(userId)
  return `สวัสดี ${user.name}`
}

สร้างไฟล์ tests/user-greeter.test.js:

import { expect, test, vi } from 'vitest'
import { fetchUser } from '../src/user-api.js'
import { greeting } from '../src/user-greeter.js'

vi.mock(import('../src/user-api.js'), () => ({
  fetchUser: vi.fn(),
}))

test('greeting uses mocked API data', async () => {
  vi.mocked(fetchUser).mockResolvedValue({ id: 7, name: 'มะลิ' })

  await expect(greeting(7)).resolves.toBe('สวัสดี มะลิ')
  expect(fetchUser).toHaveBeenCalledWith(7)
})

ทำไมต้อง vi.mocked(...)? เป็น TypeScript-friendly helper สำหรับเข้าถึงชนิดข้อมูลของ Mock Function อย่างถูกต้อง; ไม่ได้สร้าง Mock ขึ้นมาเอง จึงต้องใช้คู่กับฟังก์ชันที่ถูก Mock อยู่แล้ว

Hoisting: vi.mock() ถูกย้ายขึ้นไปรันก่อนการ Import ในกระบวนการแปลงไฟล์ของ Vitest จึงห้ามอ้างตัวแปรที่ประกาศไว้ภายนอก Factory แบบทั่วไป หากจำเป็นให้ใช้ vi.hoisted() หรือ vi.doMock() ตามบริบท แนะนำให้ส่ง import('./module.js') เพื่อให้ TypeScript ตรวจสอบชนิดข้อมูลของ Factory

ตัวอย่างการ Mock บาง Export แต่คงส่วนอื่นไว้:

vi.mock(import('../src/math.js'), async (importOriginal) => {
  const actual = await importOriginal()
  return {
    ...actual,
    expensiveCalculation: vi.fn(() => 42),
  }
})

หมายเหตุ: ตัวอย่าง Partial Mock นี้เป็น Pattern สำหรับไฟล์ math.js ที่มีอยู่จริง ไม่ได้รวมอยู่ในโครงงาน Workshop ที่ให้ดาวน์โหลด


#8. เปรียบเทียบ mockClear(), mockReset() และ mockRestore()

คำสั่ง ล้างประวัติการเรียก Reset implementation ยกเลิก Spy และคืนเมธอดต้นฉบับ
mockClear() ✅ ❌ ❌
mockReset() ✅ ✅ ❌
mockRestore() ✅ ✅ ✅ เฉพาะ Spy ที่สร้างด้วย vi.spyOn()

รายละเอียดสำคัญ: vi.fn() ที่สร้างโดยไม่มี implementation เมื่อ mockReset() จะกลับมาคืน undefined ส่วน vi.fn(originalImpl) เมื่อ Reset จะกลับไปใช้ originalImpl ที่ใส่ไว้ตอนสร้าง ไม่ใช่คืน undefined เสมอไป

import { expect, test, vi } from 'vitest'

test('reset to initial implementation', () => {
  const double = vi.fn((n) => n * 2)
  double.mockReturnValue(99)
  expect(double(3)).toBe(99)

  double.mockClear()
  expect(double(3)).toBe(99) // implementation ยังอยู่

  double.mockReset()
  expect(double(3)).toBe(6)  // กลับเป็น implementation เริ่มต้น
})

แบบทั้ง Test File:

import { afterEach, vi } from 'vitest'

afterEach(() => {
  vi.clearAllMocks()   // ล้างประวัติการเรียก
  vi.restoreAllMocks() // คืนเมธอดที่เคย spyOn
})

ค่าตั้งต้นใน Vitest รุ่นปัจจุบัน clearMocks คือ true, restoreMocks คือ false และ mockReset คือ false ควรตรวจสอบ Config ที่ใช้จริงเสมอ โดยเฉพาะกรณีรัน Test แบบ Concurrent

// vitest.config.js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    clearMocks: true,
    restoreMocks: true,
  },
})

อย่าสับสน: vi.restoreAllMocks() คืนเมธอดของ Spy ที่สร้างด้วย vi.spyOn() เป็นหลัก และ ไม่ใช่ การ Reset ทุก vi.fn() หรือทุก Module Mock นอกจากนี้ไม่ล้างประวัติของตัว Spy ตามลักษณะเดียวกับการเรียก spy.mockRestore() โดยตรง


#9. Workshop: ทดสอบ Checkout Service โดยไม่เรียก Payment Gateway จริง

#9.1 สถาปัตยกรรมตัวอย่าง

flowchart TB
    T[checkout.test.js] -->|เรียก checkout| S[Checkout Service]
    S -->|charge| P[Payment Gateway Dependency]
    S -->|notify| N[Notification Dependency]
    T -->|vi.fn: success / decline / reject| P
    T -->|vi.fn + assert calls| N

#9.2 ไฟล์ src/checkout.js

export async function checkout({ charge, notify }, order) {
  if (!order?.id || !Number.isFinite(order.total) || order.total <= 0) {
    throw new Error('Invalid order')
  }

  const payment = await charge({
    orderId: order.id,
    amount: order.total,
  })

  if (!payment.ok) {
    return { status: 'declined', orderId: order.id }
  }

  await notify(order.id)

  return {
    status: 'paid',
    transactionId: payment.transactionId,
  }
}

Service นี้ใช้หลักการ Dependency Injection โดยรับ charge() และ notify() จากภายนอก ทำให้ทดสอบโดยไม่ต้องใช้ Gateway หรือบริการแจ้งเตือนจริง

#9.3 ไฟล์ tests/checkout.test.js

import { describe, expect, test, vi } from 'vitest'
import { checkout } from '../src/checkout.js'

describe('checkout', () => {
  const order = { id: 'ORD-001', total: 1250 }

  test('successful payment sends notification', async () => {
    const charge = vi.fn().mockResolvedValue({
      ok: true,
      transactionId: 'TXN-900',
    })
    const notify = vi.fn().mockResolvedValue(undefined)

    await expect(checkout({ charge, notify }, order)).resolves.toEqual({
      status: 'paid',
      transactionId: 'TXN-900',
    })

    expect(charge).toHaveBeenCalledExactlyOnceWith({
      orderId: 'ORD-001', amount: 1250,
    })
    expect(notify).toHaveBeenCalledWith('ORD-001')
  })

  test('declined payment does not send notification', async () => {
    const charge = vi.fn().mockResolvedValue({ ok: false })
    const notify = vi.fn()

    await expect(checkout({ charge, notify }, order)).resolves.toEqual({
      status: 'declined',
      orderId: 'ORD-001',
    })
    expect(notify).not.toHaveBeenCalled()
  })

  test('gateway failure propagates without notification', async () => {
    const charge = vi.fn().mockRejectedValue(new Error('Gateway timeout'))
    const notify = vi.fn()

    await expect(checkout({ charge, notify }, order))
      .rejects.toThrow('Gateway timeout')
    expect(notify).not.toHaveBeenCalled()
  })

  test('invalid input does not charge customer', async () => {
    const charge = vi.fn()
    const notify = vi.fn()

    await expect(checkout({ charge, notify }, { id: 'X', total: 0 }))
      .rejects.toThrow('Invalid order')
    expect(charge).not.toHaveBeenCalled()
  })
})

สิ่งที่ควรตรวจสอบใน Workshop: Happy Path, Declined Payment, API Error และ Invalid Input พร้อมตรวจสอบว่าไม่มี Side Effect เกิดขึ้นโดยไม่จำเป็น

#9.4 รันทดสอบ

npm run test:run
# หรือ
npx vitest run

ตัวอย่างชุดไฟล์ในไฟล์ ZIP ที่จัดเตรียมให้:

vitest-mock-workshop/
├── package.json
├── vitest.config.js
├── src/
│   ├── checkout.js
│   ├── user-api.js
│   └── user-greeter.js
└── tests/
    ├── basic-mocks.test.js
    ├── checkout.test.js
    └── user-greeter.test.js

#10. เพิ่มเติมสำหรับ Vitest 5+: vi.when() เพื่อ Mock ตามอาร์กิวเมนต์

ตั้งแต่ Vitest 5.0.0 มี vi.when() สำหรับกำหนดพฤติกรรมแยกตาม Argument โดยไม่ต้องเขียน if/else ใน mockImplementation() เอง

import { expect, test, vi } from 'vitest'

test('mock different user IDs', async () => {
  const findById = vi.fn()

  vi.when(findById)
    .calledWith(1)
    .thenResolve({ id: 1, name: 'Alice' })
    .calledWith(2)
    .thenResolve({ id: 2, name: 'Bob' })

  await expect(findById(1)).resolves.toEqual({ id: 1, name: 'Alice' })
  await expect(findById(2)).resolves.toEqual({ id: 2, name: 'Bob' })
})

เมื่อใช้ vi.when() ต้องระวังลำดับของ calledWith() โดย Matcher แบบเฉพาะเจาะจงควรอยู่ก่อน Matcher แบบกว้าง และดูรายละเอียดการ Cleanup ในเอกสาร API


#11. ข้อผิดพลาดที่พบบ่อยและแนวทางแก้ไข

  1. สร้าง Spy หลังจากฟังก์ชันถูกเรียกไปแล้ว: Spy จะไม่เห็นการเรียกย้อนหลัง จึงควรติดตั้ง Spy ก่อน Trigger ระบบที่ต้องการทดสอบ
  2. ลืม Reset หรือ Restore Mock: ค่า Implementation และประวัติการเรียกอาจรั่วข้าม Test Cases ให้ใช้ Lifecycle Hooks หรือ Config อย่างเหมาะสม
  3. คิดว่า vi.mocked() สร้าง Mock: จริง ๆ เป็น Type Helper สำหรับค่าที่เป็น Mock อยู่แล้ว
  4. อ้างตัวแปรภายนอกใน vi.mock() Factory: อาจเกิดปัญหา Hoisting ให้ใช้ vi.hoisted() หรือปรับโครงสร้างโค้ด
  5. Mock ทุกอย่างจน Test ไม่มีความหมาย: ควร Mock เฉพาะ Boundary ของระบบ เช่น HTTP, DB, เวลา หรือบริการภายนอก และทดสอบ Business Logic จริง
  6. อาศัย .mock.calls เพื่อตรวจสอบ Snapshot ของ Object ที่มีการเปลี่ยนค่า: ต้อง Clone ณ เวลาเรียกหรือ Assert ก่อนเกิด Mutation
  7. ใช้ vi.spyOn() กับ ESM Exports ใน Browser Mode: ใช้ Module Mock แบบ { spy: true } แทนตามข้อจำกัดของ Runtime
  8. เข้าใจผิดว่า vi.resetAllMocks() = vi.restoreAllMocks(): อันแรก Reset Implementation และประวัติ; อันหลังคืนเมธอดของ Spy ให้ Object ดั้งเดิม
  9. ทดสอบ Asynchronous Code แต่ลืม await: ผลการทดสอบอาจผ่านผิด ๆ ทั้งที่ Assertion ของ Promise ยังไม่เสร็จ
  10. พยายาม Mock ฟังก์ชันที่เรียกกันภายในไฟล์เดียวกันผ่าน Module Boundary: Vitest อาจไม่สามารถแทรกแซง Internal Reference ได้ ควรแยกโมดูลหรือใช้ Dependency Injection

#12. สรุปแบบ Cheat Sheet

ต้องการทำอะไร คำสั่งที่ควรใช้
สร้าง Mock Function vi.fn()
ติดตามเมธอดเดิม vi.spyOn(obj, 'method')
แทนโมดูลภายนอก vi.mock(import('./api.js'), () => ({ ... }))
คืนค่าคงที่ .mockReturnValue(value)
คืนค่าเพียงหนึ่งครั้ง .mockReturnValueOnce(value)
จำลอง Logic .mockImplementation(fn)
Mock Async Success .mockResolvedValue(value)
Mock Async Error .mockRejectedValue(error)
ตรวจสอบจำนวนครั้ง expect(fn).toHaveBeenCalledTimes(n)
ตรวจสอบอาร์กิวเมนต์ expect(fn).toHaveBeenCalledWith(...)
อ่านประวัติการเรียก fn.mock.calls
ล้างประวัติ .mockClear() หรือ vi.clearAllMocks()
Reset การทำงาน .mockReset() หรือ vi.resetAllMocks()
คืนเมธอดต้นฉบับ .mockRestore() หรือ vi.restoreAllMocks() สำหรับ Spies
คืนค่าตามอาร์กิวเมนต์ (Vitest 5+) vi.when(mock).calledWith(...).thenReturn(...)

#13. แหล่งอ้างอิง


บทสรุป: ใช้ vi.fn() เมื่อต้องสร้าง Dependency จำลอง, ใช้ vi.spyOn() เมื่อต้องการสังเกตเมธอดที่มีอยู่แล้ว และใช้ vi.mock() เมื่อจำเป็นต้องแทนโมดูลภายนอกทั้งหมดหรือบางส่วน โดยให้ความสำคัญกับ Assertions และ Mock Cleanup เพื่อให้ Unit Tests มีความน่าเชื่อถือ