info-vitest-snapshot-testing-workshop

ระดับ: เริ่มต้น–กลาง | กลุ่มเป้าหมาย: นักพัฒนา JavaScript/TypeScript และผู้ทดสอบซอฟต์แวร์
ผลลัพธ์การเรียนรู้: สร้าง snapshot, วิเคราะห์ diff, อัปเดต baseline อย่างถูกวิธี และประยุกต์ใช้ใน CI/CD ได้

#1. Snapshot Testing คืออะไร?

Snapshot Testing คือเทคนิคการทดสอบที่บันทึกผลลัพธ์จากโปรแกรมเป็นค่าอ้างอิง (baseline / snapshot) แล้วนำผลลัพธ์ในการรันทดสอบครั้งถัดไปมาเปรียบเทียบ หากแตกต่างกัน การทดสอบจะล้มเหลวเพื่อให้ผู้พัฒนาตรวจสอบว่าเป็นการเปลี่ยนแปลงที่ตั้งใจหรือเป็น regression

Snapshot สามารถใช้ตรวจสอบออบเจ็กต์ JavaScript, ข้อความ, HTML, SVG, รูปแบบ error และผลลัพธ์ที่ serialize ได้ ไม่ใช่การถ่ายภาพหน้าจอเสมอไป

flowchart LR
    A[Run Test] --> B[Generate Actual Output]
    B --> C{Existing Baseline?}
    C -->|No: local run| D[Create Snapshot]
    C -->|Yes| E{Compare Actual / Expected}
    E -->|Same| F[PASS]
    E -->|Different| G[FAIL + Diff]
    G --> H{Change Intended?}
    H -->|No| I[Fix Code]
    H -->|Yes| J[Review & Update Baseline]
    J --> E

แนวคิดสำคัญ: Snapshot บอกว่าผลลัพธ์ เปลี่ยนไป แต่ไม่ได้ยืนยันโดยลำพังว่า ผลลัพธ์ถูกต้องตามความต้องการทางธุรกิจ ดังนั้นควรใช้ควบคู่กับ assertion ที่ตรวจสอบพฤติกรรมเฉพาะจุด

#2. รูปแบบ Snapshot ที่ควรรู้

รูปแบบ Matcher จัดเก็บค่าอ้างอิง เหมาะสำหรับ
External Snapshot toMatchSnapshot() ไฟล์ __snapshots__/*.snap Object/Array หรือผลลัพธ์หลายส่วน
Inline Snapshot toMatchInlineSnapshot() ในไฟล์ทดสอบโดยตรง ค่าสั้น อ่านง่าย
File Snapshot toMatchFileSnapshot() ไฟล์ปลายทาง เช่น .html, .svg, .txt ข้อความยาวหรือรูปแบบไฟล์ที่ต้องการ syntax highlighting
Error Snapshot toThrowErrorMatchingInlineSnapshot() ในไฟล์ทดสอบ รูปแบบข้อความข้อผิดพลาด
Visual Screenshot toMatchScreenshot() ไฟล์ภาพ baseline (ต้องตั้งค่า Browser Mode) การเปลี่ยนแปลงด้านภาพของ UI

หมายเหตุ: Snapshot ของข้อมูล ≠ Visual Regression Testing การเปรียบเทียบ HTML string ไม่สามารถตรวจพบทุกความแตกต่างในการเรนเดอร์จริง เช่น ฟอนต์ สี หรือการจัดวางองค์ประกอบ

#3. เตรียม Workshop

อ้างอิงเอกสาร Getting Started ของ Vitest รุ่นปัจจุบัน (ตรวจสอบวันที่ 17 ตุลาคม 2026): แนะนำ Node.js 22.12+ และ Vite 6.4+ สำหรับรุ่นปัจจุบัน หากใช้งานโปรเจ็กต์เดิมให้ตรวจสอบข้อกำหนดของเวอร์ชันที่ติดตั้งด้วย

mkdir vitest-snapshot-workshop
cd vitest-snapshot-workshop
npm init -y
npm install -D vitest
mkdir -p src tests/fixtures

แก้ package.json ให้มี type และ scripts (เก็บ dependencies ที่ npm สร้างไว้):

{
  "type": "module",
  "scripts": {
    "test": "vitest",
    "test:ci": "vitest run",
    "test:update": "vitest run --update"
  }
}

JSON ข้างต้นเป็นส่วนที่ต้องเพิ่ม/ปรับ ไม่ใช่ไฟล์ package.json ทั้งหมด โปรดคง devDependencies ที่สร้างจาก npm install ไว้

โครงสร้างสุดท้ายหลังรันทดสอบครั้งแรก:

vitest-snapshot-workshop/
├── package.json
├── package-lock.json
├── src/
│   └── product.js
├── tests/
│   ├── product.test.js
│   ├── __snapshots__/
│   │   └── product.test.js.snap
│   └── fixtures/
│       └── product-card.html
└── .gitignore

#4. เขียนฟังก์ชันที่ต้องการทดสอบ

สร้างไฟล์ src/product.js:

export function summarizeProduct(product) {
  return {
    sku: product.sku,
    name: product.name,
    price: product.price,
    availability: product.stock > 0 ? 'in-stock' : 'out-of-stock',
    tags: [...product.tags].sort(),
  }
}

export function renderProductCard(product) {
  return `<article class="product-card">
  <h2>${product.name}</h2>
  <p class="price">${product.price} THB</p>
</article>`
}

export function createOrderReceipt(orderId) {
  return {
    orderId,
    generatedAt: new Date(),
    receiptNumber: Math.floor(Math.random() * 1_000_000),
    status: 'issued',
  }
}

export function validateQuantity(quantity) {
  if (quantity <= 0) {
    throw new Error('Quantity must be greater than zero')
  }
  return true
}

ข้อควรระวัง: ฟังก์ชัน renderProductCard ใช้ค่าตัวอย่างที่ควบคุมได้เท่านั้น หากนำไปใช้งานจริงต้อง escape ข้อมูลที่ไม่เชื่อถือก่อนแทรกลงใน HTML เพื่อป้องกัน XSS

#5. External Snapshot ด้วย toMatchSnapshot()

สร้างไฟล์ tests/product.test.js:

import { describe, expect, test } from 'vitest'
import {
  summarizeProduct,
  renderProductCard,
  createOrderReceipt,
  validateQuantity,
} from '../src/product.js'

const product = {
  sku: 'P-001',
  name: 'Mechanical Keyboard',
  price: 1290,
  stock: 8,
  tags: ['accessory', 'keyboard'],
}

describe('Vitest Snapshot Workshop', () => {
  test('product summary: external snapshot', () => {
    expect(summarizeProduct(product)).toMatchSnapshot()
  })

  test('product title: inline snapshot', () => {
    expect(product.name.toUpperCase()).toMatchInlineSnapshot()
  })

  test('HTML output: file snapshot', async () => {
    const html = renderProductCard(product)
    await expect(html).toMatchFileSnapshot('./tests/fixtures/product-card.html')
  })

  test('dynamic order fields: property matchers', () => {
    const receipt = createOrderReceipt('ORD-1001')
    expect(receipt).toMatchSnapshot({
      generatedAt: expect.any(Date),
      receiptNumber: expect.any(Number),
    })
  })

  test('validation error: inline snapshot', () => {
    expect(() => validateQuantity(0)).toThrowErrorMatchingInlineSnapshot()
  })

  test('critical business rule: direct assertion', () => {
    expect(summarizeProduct({ ...product, stock: 0 }).availability)
      .toBe('out-of-stock')
  })
})

ทดสอบครั้งแรก:

npm run test:ci

Vitest จะสร้าง baseline สำหรับ External/Inline/File Snapshots ในการรัน local ที่อนุญาตให้สร้าง snapshot ใหม่ จากนั้นตรวจสอบไฟล์ที่ถูกสร้างหรือแก้ไข และรันอีกครั้งเพื่อตรวจผลการเปรียบเทียบ

npm run test:ci

ตัวอย่างลักษณะเนื้อหาในไฟล์ tests/__snapshots__/product.test.js.snap (รูปแบบจริงขึ้นอยู่กับเวอร์ชันและการจัดรูปแบบ):

// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Vitest Snapshot Workshop > product summary: external snapshot 1`] = `
{
  "availability": "in-stock",
  "name": "Mechanical Keyboard",
  "price": 1290,
  "sku": "P-001",
  "tags": [
    "accessory",
    "keyboard",
  ],
}
`;

นี่คือตัวอย่าง เพื่ออธิบายรูปแบบ ไฟล์ snapshot ที่ Vitest สร้างจริงเป็นแหล่งอ้างอิงของโครงการนั้น ๆ

#6. Inline Snapshot และ Error Snapshot

หลังรันทดสอบครั้งแรก Vitest จะเติมค่าที่ capture ได้ลงในไฟล์ทดสอบ เช่น:

test('product title: inline snapshot', () => {
  expect(product.name.toUpperCase())
    .toMatchInlineSnapshot('"MECHANICAL KEYBOARD"')
})

ข้อดีคืออ่านค่า expected ได้ในจุดเดียวกับโค้ดทดสอบ แต่ถ้าผลลัพธ์ยาวมาก ให้ย้ายไป External หรือ File Snapshot จะดูแลรักษาได้ง่ายกว่า

สำหรับ Error Snapshot ต้องระวังข้อความที่ผันแปรตาม runtime, stack trace, path หรือเวอร์ชัน dependencies ซึ่งอาจทำให้เกิดการเปลี่ยนแปลงโดยไม่จำเป็น

#7. File Snapshot: HTML, SVG และข้อความ

ตัวอย่างใน Workshop:

test('HTML output: file snapshot', async () => {
  const html = renderProductCard(product)
  await expect(html).toMatchFileSnapshot('./tests/fixtures/product-card.html')
})

หลักการสำคัญ:

  • toMatchFileSnapshot() เป็น asynchronous matcher ต้องใช้ await
  • ให้ใช้ตำแหน่งไฟล์ baseline ที่กำหนดเองอย่างชัดเจน
  • ไม่ควรเขียน File Snapshot ไปทับพาธที่ Vitest ดูแลสำหรับ External Snapshot เช่น __snapshots__/*.snap
  • ตรวจสอบไฟล์ .html หรือ .svg ด้วย diff และเครื่องมือเฉพาะรูปแบบไฟล์เพิ่มเติมได้

#8. จัดการ Dynamic Values

ข้อมูลอย่าง Date, UUID, random number และค่า timestamp ทำให้ snapshot เปลี่ยนทุกครั้งได้

วิธีที่ 1: Property Matchers ตรวจสอบชนิดข้อมูลเฉพาะ field ที่ผันแปร:

expect(receipt).toMatchSnapshot({
  generatedAt: expect.any(Date),
  receiptNumber: expect.any(Number),
})

วิธีที่ 2: Normalize ค่าก่อน Snapshot เหมาะกับข้อมูลที่ไม่ต้องสนใจค่าเฉพาะ:

const normalized = {
  ...receipt,
  generatedAt: '<dynamic-date>',
  receiptNumber: '<dynamic-number>',
}
expect(normalized).toMatchSnapshot()

วิธีที่ 3: ควบคุมเวลา / Random ด้วย fake timers หรือ dependency injection เมื่อการทดสอบจำเป็นต้องยืนยันค่าจริง (ต้องระมัดระวัง cleanup เพื่อลดผลกระทบข้าม test)

#9. ทดลองให้ Snapshot Fail แล้ววิเคราะห์ Diff

  1. แก้ไข price: 1290 เป็น price: 1490 ในข้อมูล product ภายในไฟล์ทดสอบ
  2. รัน npm run test:ci
  3. สังเกตผล FAIL และ diff ที่เปรียบเทียบ 1290 กับ 1490
  4. ตรวจว่าเป็นการเปลี่ยนแปลงตาม requirement หรือเป็น regression
  5. ถ้าตั้งใจเปลี่ยน ให้อัปเดต snapshot หลัง code review เท่านั้น
# อัปเดตเฉพาะเมื่อยืนยันผลลัพธ์ใหม่ว่าถูกต้องแล้ว
npm run test:update

# รันตรวจสอบอีกครั้งโดยไม่อัปเดตอัตโนมัติ
npm run test:ci

สำหรับ watch mode (npm test) สามารถกด u เพื่ออัปเดต snapshot ที่ล้มเหลวได้

อย่าอัปเดต snapshot เพื่อให้ test กลับมาเขียวโดยไม่ตรวจ diff เพราะอาจยอมรับ regression เป็น baseline ใหม่โดยไม่รู้ตัว

#10. Git และ CI/CD กับ GitHub Actions

ไฟล์ .gitignore ตัวอย่าง:

node_modules/
.vitest/
coverage/

ควร commit __snapshots__/*.snap, ไฟล์ baseline ภายใต้ tests/fixtures และไฟล์ที่มี Inline Snapshots เพื่อให้ทีมสามารถ review การเปลี่ยนแปลงได้

ตัวอย่าง .github/workflows/test.yml:

name: Vitest Snapshot Tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm
      - name: Install dependencies
        run: npm ci
      - name: Run tests (no snapshot updates)
        run: npm run test:ci
        env:
          CI: 'true'

สำคัญ: ต้องมี package-lock.json ใน repository จึงใช้ npm ci ได้ และควรหลีกเลี่ยงการตั้งค่า CI ให้อัปเดต baseline โดยอัตโนมัติ

พฤติกรรมปกติของ Vitest เมื่อ CI=true คือ ไม่เขียน snapshot ใหม่ และให้การรันทดสอบล้มเหลวเมื่อ baseline หาย ไม่ตรง หรือกลายเป็น obsolete snapshot (ยกเว้นมีการตั้งค่า override)

#11. ใช้ Snapshot กับ UI อย่างไร?

#11.1 HTML Snapshot

ทดสอบผลลัพธ์ markup หรือ serialized component เพื่อจับความเปลี่ยนแปลงในโครงสร้างของข้อความ/DOM ที่ serialize แล้ว แต่จะไม่ได้ทดสอบ CSS layout จริง

#11.2 Visual Snapshot / Screenshot

ถ้าเป้าหมายคือการตรวจสี ระยะห่าง layout หรือภาพของหน้าเว็บ ให้ใช้ Vitest Browser Mode และ toMatchScreenshot() ซึ่งเป็น workflow แยกจากตัวอย่าง Node-only ในบทความนี้:

import { expect, test } from 'vitest'
import { page } from 'vitest/browser'

test('button visual baseline', async () => {
  // ต้องเตรียม Browser Mode และเรนเดอร์ UI ก่อน assertion นี้
  await expect(page.getByRole('button')).toMatchScreenshot('primary-button')
})

การทดสอบลักษณะนี้ต้องควบคุม viewport, browser, fonts, animations และ environment เพื่อไม่ให้ผลลัพธ์ผันผวน ตัวอย่างข้างต้นเป็น แนวทางการใช้ assertion ไม่ใช่ตัวอย่างพร้อมรันในโปรเจ็กต์ Node-only

#11.3 Accessibility (ARIA) Snapshot

Vitest Browser Mode ยังรองรับการเปรียบเทียบ accessibility tree ด้วย toMatchAriaSnapshot() หรือ toMatchAriaInlineSnapshot() ช่วยตรวจสอบโครงสร้าง role/accessible name บางส่วน แต่ไม่ได้ทดแทนการตรวจ accessibility แบบครบถ้วน

#12. Snapshot Testing เทียบกับ Unit Testing แบบ Assertion

ประเด็น Snapshot Testing Direct Assertion
ตรวจการเปลี่ยนแปลงหลาย field พร้อมกัน เหมาะมาก ต้องเขียนหลาย assertion
ระบุ business rule ชัดเจน ไม่ชัดเท่า ชัดเจนกว่า
ผลลัพธ์ที่เปลี่ยนบ่อย เสี่ยง flaky / review noise เลือกตรวจเฉพาะ field ได้
Review ผลลัพธ์ที่ตั้งใจเปลี่ยน ตรวจ diff baseline ตรวจ expectation ในโค้ด
การบำรุงรักษา อาจแพงหาก baseline ใหญ่ เหมาะสำหรับ requirement เฉพาะ

แนวทางที่เหมาะสม: ใช้ Snapshot เพื่อปกป้องภาพรวมของผลลัพธ์ที่ค่อนข้างเสถียร และใช้ Direct Assertion ตรวจสอบ invariant, edge cases และกฎธุรกิจสำคัญ

#13. Best Practices และ Anti-patterns

ควรทำ

  • สร้าง snapshot จากผลลัพธ์ที่ deterministic หรือ normalize ค่าที่ผันแปร
  • ตั้งชื่อ test ให้บอกพฤติกรรมและความคาดหวัง
  • เก็บ baseline ใน version control และตรวจ git diff ทุกครั้งที่อัปเดต
  • ทำ snapshot ให้เล็กพอสำหรับ human review
  • เพิ่ม direct assertions ในส่วนสำคัญของ business logic
  • ใช้ vitest run ใน CI และตรวจสอบว่ามี snapshot ครบถ้วน
  • ระวัง serializer, locale, timezone และ environment ที่ทำให้ output เปลี่ยน

ไม่ควรทำ

  • snapshot ทั้งหน้าเว็บขนาดใหญ่อย่างไม่มีจุดประสงค์
  • รวม fields ที่สร้างค่าใหม่ทุกครั้งไว้ใน baseline แบบ literal
  • กด u โดยไม่ดู diff
  • ใช้ Snapshot แทน end-to-end, accessibility หรือ visual testing ทุกกรณี
  • อนุมานว่า Snapshot PASS แปลว่าตรงตาม requirement เสมอ

#14. แบบฝึกหัด Workshop

  1. เพิ่มสินค้า P-002 ที่ stock: 0 แล้วสร้าง Snapshot ของ summarizeProduct()
  2. เพิ่ม field discountPrice และสังเกต snapshot diff
  3. เปลี่ยนค่า generatedAt จาก property matcher เป็นค่า literal แล้วอธิบายว่าทำไม test จึงล้มเหลวบ่อย
  4. เพิ่ม HTML data-testid แล้วทดสอบ File Snapshot
  5. จัดทำ Pull Request ที่มี snapshot diff พร้อมคำอธิบายว่าเปลี่ยน baseline เพราะอะไร
  6. เพิ่ม assertion ทดสอบว่า discountPrice <= price โดยไม่ใช้ Snapshot

#คำถามท้ายบท

  • เหตุใด Snapshot Testing จึงไม่สามารถยืนยันความถูกต้องตาม requirement ได้เพียงลำพัง?
  • External Snapshot กับ Inline Snapshot มีข้อดีต่างกันอย่างไร?
  • จะจัดการกับวันที่และ Random ID เพื่อไม่ให้ snapshot flaky อย่างไร?
  • ทำไม CI ไม่ควรอัปเดต Snapshot อัตโนมัติ?
  • เมื่อไรจึงควรใช้ Visual Screenshot แทน HTML Snapshot?

#15. สรุป

Vitest Snapshot Testing เป็นเครื่องมือที่ช่วยตรวจจับ unexpected changes ของผลลัพธ์ซอฟต์แวร์ได้รวดเร็ว รูปแบบที่ใช้งานบ่อย ได้แก่ toMatchSnapshot(), toMatchInlineSnapshot() และ toMatchFileSnapshot() แต่คุณภาพของการทดสอบไม่ได้เกิดจากจำนวน snapshot เพียงอย่างเดียว—ต้องมี baseline ที่ผ่านการตรวจสอบ, ผลลัพธ์ที่เสถียร และ assertion ที่สื่อถึงข้อกำหนดของระบบด้วย

#เอกสารอ้างอิง

  1. Vitest, Snapshot Guide — https://vitest.dev/guide/snapshot.html
  2. Vitest, Snapshot Testing Tutorial — https://vitest.dev/guide/learn/snapshots.html
  3. Vitest, Getting Started — https://vitest.dev/guide/
  4. Vitest, CLI / Update Option — https://vitest.dev/guide/cli
  5. Vitest, Visual Regression Testing — https://vitest.dev/guide/browser/visual-regression-testing
  6. Vitest, ARIA Snapshots — https://vitest.dev/guide/browser/aria-snapshots