การทดสอบ Vue 3 ในปี 2026: Vitest, Vue Test Utils และคำถามสัมภาษณ์

คู่มือปฏิบัติสำหรับการทดสอบ Vue ในปี 2026: การตั้งค่า Vitest, การ mount คอมโพเนนต์ด้วย Vue Test Utils, การทดสอบ composable และ Pinia store, การ mock API, การวัด coverage และคำถามสัมภาษณ์ที่ทีมผู้ว่าจ้างถามจริง

เวิร์กโฟลว์การทดสอบ Vue ด้วย Vitest และ Vue Test Utils ในปี 2026

การทดสอบ Vue คือเส้นแบ่งระหว่างการ refactor ด้วยความมั่นใจกับการเดาสุ่มที่เปราะบาง และในปี 2026 ชุดเครื่องมือได้รวมตัวกันรอบสองไลบรารีหลัก ได้แก่ Vitest สำหรับเป็น test runner และ Vue Test Utils สำหรับการ mount คอมโพเนนต์ คู่มือนี้จะพาไล่ตั้งแต่การตั้งค่าชุดเครื่องมือ การทดสอบคอมโพเนนต์และ composable การ mock dependency ไปจนถึงการตอบคำถามสัมภาษณ์เรื่องการทดสอบ Vue ที่ทีมผู้ว่าจ้างถามกันจริง

ชุดเครื่องมือทดสอบ Vue ยุคใหม่ในปี 2026

คำแนะนำมาตรฐานจากเอกสาร Vue คือใช้ Vitest สำหรับ unit test และ component test ใช้ Vue Test Utils เป็น API ระดับล่างสำหรับการ mount และใช้ Playwright หรือ Cypress สำหรับการทดสอบแบบ end-to-end จุดเด่นคือ Vitest ใช้ config เดียวกันกับ Vite ที่แอปใช้อยู่ ดังนั้นเทสต์จึงวิ่งผ่าน transform pipeline ชุดเดียวกันเป๊ะ พร้อม hot reload ที่แทบจะทันที

การตั้งค่า Vitest สำหรับโปรเจกต์ Vue 3

Vitest ใช้ pipeline ร่วมกับ Vite หมายความว่าไฟล์ config เพียงไฟล์เดียวขับเคลื่อนทั้ง dev server และ test runner ตัวเลือก environment ต้องกำหนดเป็น jsdom หรือ happy-dom เพื่อให้ component test มี DOM ให้ render ลงไป ส่วนแฟลก globals: true จะเปิดให้ describe, it และ expect ใช้งานได้โดยไม่ต้อง import ในทุกไฟล์

vitest.config.tstypescript
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  test: {
    // jsdom provides document/window so components can mount
    environment: 'jsdom',
    // expose describe/it/expect globally
    globals: true,
    // run this before each test file (matchers, cleanup)
    setupFiles: ['./tests/setup.ts'],
    // only pick up *.spec.ts and *.test.ts files
    include: ['**/*.{test,spec}.ts'],
  },
  resolve: {
    alias: {
      // mirror the @ alias used across the app
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

สำหรับ Vitest 3 config ด้านบนใช้งานได้ทันทีหลังติดตั้ง vitest, @vue/test-utils, @vitejs/plugin-vue และ jsdom และเนื่องจาก alias สะท้อน config ของตัวแอป การ import แบบ @/composables/useCart จึงถูก resolve เหมือนกันทั้งในเทสต์และในโปรดักชัน

การ mount คอมโพเนนต์ด้วย Vue Test Utils

Vue Test Utils เปิดจุดเข้าใช้งานสองทาง ได้แก่ mount ที่ render ต้นไม้คอมโพเนนต์ทั้งหมด และ shallowMount ที่แทนคอมโพเนนต์ลูกด้วย stub สำหรับ unit test ส่วนใหญ่ควรเลือก mount เพราะมันทดสอบพฤติกรรมการ render ของจริง ค่า wrapper ที่คืนกลับมาจะมีตัวช่วยค้นหาอย่าง find, get และ text ให้ใช้ยืนยันผลลัพธ์ที่ render ออกมา

PriceTag.spec.tstypescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PriceTag from '@/components/PriceTag.vue'

describe('PriceTag', () => {
  it('formats the amount as currency', () => {
    const wrapper = mount(PriceTag, {
      // props are passed through the mounting options
      props: { amount: 4200, currency: 'EUR' },
    })

    // get() throws if the selector is missing, unlike find()
    expect(wrapper.get('[data-test="price"]').text()).toBe('€42.00')
  })

  it('applies a discount class when on sale', () => {
    const wrapper = mount(PriceTag, {
      props: { amount: 4200, currency: 'EUR', onSale: true },
    })

    // classes() returns the array of applied CSS classes
    expect(wrapper.get('[data-test="price"]').classes()).toContain('price--sale')
  })
})

การเจาะไปที่ element ผ่านแอตทริบิวต์ data-test แทนที่จะใช้ CSS class ช่วยให้เทสต์ทนทานกว่า เพราะการปรับสไตล์ของ .price--sale จะไม่ทำให้ selector พัง มีเพียงการเปลี่ยนพฤติกรรมจริงเท่านั้นที่ทำให้เทสต์ล้ม การแยกส่วนแบบนี้เป็นแนวคิดที่พบซ้ำ ๆ ในการเขียนเทสต์ Vue ที่ดูแลรักษาง่าย

การทดสอบ user event และ event ที่ถูก emit

คอมโพเนนต์ที่มีการโต้ตอบจำเป็นต้องมีการยืนยันผลว่าจะเกิดอะไรขึ้นหลังการคลิกหรือการพิมพ์ Vue Test Utils คืนค่า promise จาก trigger และการ await มันจะ flush คิว reactivity ของ Vue เพื่อให้ DOM สะท้อนการอัปเดต ส่วนตัวช่วย emitted จะบันทึกทุก custom event ที่คอมโพเนนต์ยิงออกมา ซึ่งเป็นวิธีตรวจสอบสัญญาระหว่าง parent กับ child

SearchBar.spec.tstypescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchBar from '@/components/SearchBar.vue'

describe('SearchBar', () => {
  it('emits search with the typed query', async () => {
    const wrapper = mount(SearchBar)

    // setValue writes to the input and fires an input event
    await wrapper.get('input').setValue('vitest')
    // awaiting trigger flushes the reactivity queue before assertions
    await wrapper.get('[data-test="submit"]').trigger('click')

    // emitted() returns a record of every event and its payloads
    const events = wrapper.emitted('search')
    expect(events).toHaveLength(1)
    // events[0] is the first emission, [0] its first argument
    expect(events![0][0]).toBe('vitest')
  })
})
อย่าลืม await การอัปเดต DOM เสมอ

Vue รวมการอัปเดต reactive เข้าด้วยกันแบบ asynchronous การลืม await การเรียก trigger หรือ setValue คือสาเหตุที่พบบ่อยที่สุดของเทสต์ Vue ที่ผลลัพธ์ไม่แน่นอน เพราะการยืนยันผลจะทำงานก่อนที่ DOM จะ re-render จึงอ่านค่าที่ยังไม่อัปเดต หากมีการเปลี่ยน state เกิดขึ้นนอกตัวช่วย event ให้ import nextTick จาก vue แล้ว await nextTick() ก่อนยืนยันผล

การทดสอบ Vue composable แบบแยกอิสระ

Composable ที่ใช้เพียง reactivity API (ref, computed, watch) ไม่จำเป็นต้องมีคอมโพเนนต์เลย เพราะมันคือฟังก์ชันธรรมดาที่คืน reactive state ออกมา ดังนั้นการเรียกใช้โดยตรงแล้วยืนยันผลบน .value ก็เพียงพอ นี่คือวิธีที่เร็วที่สุดและเจาะจงที่สุดในการครอบคลุมตรรกะทางธุรกิจ และเข้ากันได้ดีกับแพตเทิร์นในcomposable ขั้นสูงของ Vue 3

useCart.spec.tstypescript
import { describe, it, expect } from 'vitest'
import { useCart } from '@/composables/useCart'

describe('useCart', () => {
  it('tracks total price as items change', () => {
    // composables using only ref/computed run without mounting
    const { items, total, addItem } = useCart()

    expect(total.value).toBe(0)

    addItem({ id: 1, price: 1500, qty: 2 })
    // computed total recalculates synchronously on state change
    expect(total.value).toBe(3000)

    addItem({ id: 2, price: 500, qty: 1 })
    expect(items.value).toHaveLength(2)
    expect(total.value).toBe(3500)
  })
})

Composable ที่พึ่งพา lifecycle hook อย่าง onMounted เป็นข้อยกเว้น เพราะมันต้องทำงานภายใน instance ของคอมโพเนนต์ วิธีแก้ที่นิยมใช้คือสร้างคอมโพเนนต์ตัวช่วยเล็ก ๆ ที่เรียก composable ใน setup แล้วเปิดผลลัพธ์ออกมา จากนั้นจึง mount คอมโพเนนต์ตัวช่วยนั้นด้วย Vue Test Utils

พร้อมที่จะพิชิตการสัมภาษณ์ Vue.js / Nuxt.js แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

การ mock การเรียก API ด้วย vi.mock

คำขอผ่านเครือข่ายจริงทำให้เทสต์ช้าและไม่คงเส้นคงวา Vitest จึงแทนที่โมดูลด้วย vi.mock และสร้างฟังก์ชัน spy ด้วย vi.fn แพตเทิร์นด้านล่างจะ mock โมดูลที่ห่อ fetch ไว้ เพื่อให้ composable ที่กำลังทดสอบได้รับข้อมูลที่ควบคุมได้โดยไม่ต้องยิงไปที่เซิร์ฟเวอร์เลย

useProducts.spec.tstypescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useProducts } from '@/composables/useProducts'
import { getProducts } from '@/api/products'

// replace the whole module with mocked exports
vi.mock('@/api/products', () => ({
  getProducts: vi.fn(),
}))

describe('useProducts', () => {
  beforeEach(() => {
    // reset call history between tests to avoid leakage
    vi.mocked(getProducts).mockReset()
  })

  it('exposes fetched products and clears loading', async () => {
    // define what the mocked API returns for this test
    vi.mocked(getProducts).mockResolvedValue([
      { id: 1, name: 'Keyboard' },
    ])

    const { products, loading, load } = useProducts()
    await load()

    expect(getProducts).toHaveBeenCalledOnce()
    expect(loading.value).toBe(false)
    expect(products.value[0].name).toBe('Keyboard')
  })
})

การ mock ที่ขอบเขตของโมดูลช่วยให้ตรรกะภายในของ composable ไม่ถูกแตะต้อง ในขณะที่ให้แต่ละเทสต์ควบคุม response ของ API ได้เต็มที่ รวมถึงเส้นทางที่เกิด error ด้วย การเรียก mockRejectedValue แทนจะทำให้เทสต์เพียงตัวเดียวสามารถตรวจสอบได้ว่าเมื่อเกิดความล้มเหลว state ของ error จะถูกตั้งค่าและ spinner จะหยุดหมุน

การทดสอบ Pinia store ในปี 2026

Store เก็บ state ที่ใช้ร่วมกันข้ามคอมโพเนนต์ และแพ็กเกจทางการ @pinia/testing ทำให้ทดสอบได้ตรงไปตรงมา โดยค่าเริ่มต้น createTestingPinia จะ stub ทุก action ไว้ ดังนั้น component test จึงสามารถยืนยันได้ว่ามีการ dispatch action ออกไปโดยไม่ต้องรัน side effect ของมัน คู่มือการทดสอบ Pinia แนะนำแนวทางนี้สำหรับเทสต์ระดับคอมโพเนนต์

CheckoutButton.spec.tstypescript
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import CheckoutButton from '@/components/CheckoutButton.vue'
import { useCartStore } from '@/stores/cart'

describe('CheckoutButton', () => {
  it('dispatches checkout on click', async () => {
    const wrapper = mount(CheckoutButton, {
      global: {
        plugins: [
          // vi.fn spies on actions instead of executing them
          createTestingPinia({ createSpy: vi.fn }),
        ],
      },
    })

    const store = useCartStore()
    await wrapper.get('[data-test="checkout"]').trigger('click')

    // the action is stubbed, so only the dispatch is asserted
    expect(store.checkout).toHaveBeenCalledOnce()
  })
})

หากต้องการทดสอบตรรกะของ store เองแทนการทดสอบการผสานเข้ากับคอมโพเนนต์ ให้ส่ง stubActions: false เพื่อให้ action ทำงานตามปกติ แล้วจึงยืนยันผลบน state ที่ได้ การแยกระหว่างเทสต์คอมโพเนนต์ที่ stub action กับเทสต์ store ที่ใช้ action จริง ช่วยให้แต่ละ suite โฟกัสอยู่กับความรับผิดชอบเพียงอย่างเดียว

การรันเทสต์ใน watch mode และการวัด coverage

Vitest มาพร้อม watch mode ที่รันซ้ำเฉพาะเทสต์ที่ได้รับผลกระทบจากไฟล์ที่เพิ่งบันทึก ซึ่งย่นรอบ feedback ระหว่างการพัฒนาให้เหลือเพียงเสี้ยววินาที ส่วนการรายงาน coverage มีมาให้ในตัวผ่าน provider @vitest/coverage-v8 และผสานเข้ากับ pipeline ของ continuous integration ได้อย่างเรียบร้อย โดยเทสต์ที่ล้มเหลวหรือ coverage ที่ถดถอยลงสามารถบล็อกการ merge ได้

vitest.config.ts (coverage section)typescript
export default defineConfig({
  test: {
    coverage: {
      // v8 is the fastest provider and needs no instrumentation step
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      // fail CI when a threshold drops below the target
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
      },
      // exclude generated and config files from the report
      exclude: ['**/*.config.ts', '**/types/**'],
    },
  },
})

การรัน vitest เพียงอย่างเดียวจะเริ่ม watch mode บนเครื่องท้องถิ่น ในขณะที่ vitest run --coverage จะรันแบบผ่านครั้งเดียวซึ่งเหมาะกับ CI การกำหนด threshold อย่างชัดเจนเปลี่ยน coverage จากตัวเลขไว้อวดให้กลายเป็นด่านคุณภาพที่บังคับใช้จริง เพราะ pull request ที่ทำให้ branch coverage ตกลงต่ำกว่า 75 เปอร์เซ็นต์จะล้มเหลวอัตโนมัติ ซึ่งกระตุ้นให้ผู้พัฒนาทดสอบเส้นทางที่ตัวเองเพิ่มเข้ามา ไม่ใช่เพียงเส้นทางที่ทุกอย่างราบรื่นเท่านั้น

คำถามสัมภาษณ์เรื่องการทดสอบ Vue ที่พบบ่อยพร้อมคำตอบ

คำถามเรื่องการทดสอบปรากฏในการสัมภาษณ์ Vue ระดับ senior เกือบทุกครั้ง เพราะมันเผยให้เห็นว่าผู้สมัครคิดเรื่องการดูแลรักษาโค้ดอย่างไร คำตอบด้านล่างครอบคลุมแนวคิดที่ถูกถามบ่อยที่สุด และชุดคำถามฉบับเต็มมีให้ฝึกในโมดูลคำถามสัมภาษณ์การทดสอบ Vue

ควรใช้ shallowMount แทน mount เมื่อใด? เลือกใช้ shallowMount เมื่อคอมโพเนนต์ render ลูกที่หนักหรือถูกทดสอบไว้แล้ว และเทสต์สนใจเพียงตรรกะของ parent เท่านั้น การ stub ลูกจะแยก unit ให้อิสระและเร่งการ render ให้เร็วขึ้น แลกกับการที่ไม่ได้ตรวจสอบการผสานจริงระหว่างคอมโพเนนต์

Vue Test Utils จัดการการอัปเดตแบบ asynchronous อย่างไร? Vue นำการเปลี่ยนแปลง reactive ไปใช้ใน tick ถัดไป ตัวช่วย event อย่าง trigger และ setValue คืน promise ที่ resolve หลังจาก DOM อัปเดตแล้ว ดังนั้นการ await มันก็เพียงพอ ส่วน state ที่เปลี่ยนนอกตัวช่วยเหล่านั้น ให้ใช้ await nextTick() เพื่อบังคับให้คิว flush ก่อนที่การยืนยันผลจะทำงาน

find กับ get ต่างกันอย่างไร? find คืน wrapper เปล่าเมื่อไม่มี element ตรงกันและจะไม่ throw ซึ่งเหมาะกับการยืนยันผลแบบ expect(wrapper.find('.error').exists()).toBe(false) ส่วน get จะ throw ทันทีเมื่อไม่พบ element ทำให้เป็นตัวเลือกที่ดีกว่าเมื่อคาดว่า element นั้นต้องมีอยู่ และการหายไปของมันคือความล้มเหลวที่แท้จริง

ทำไมจึงควรใช้แอตทริบิวต์ data-test แทน class selector? ชื่อ class เปลี่ยนไปตามสไตล์และโครงสร้าง ดังนั้นเทสต์ที่ query จากมันจึงพังเมื่อมีการแก้ไขเชิงความสวยงาม การใช้ hook data-test โดยเฉพาะจะแสดงเจตนาของการทดสอบอย่างชัดเจนและอยู่รอดผ่านการ refactor ช่วยลด false negative

จะทดสอบ composable ที่พึ่งพา onMounted อย่างไร? lifecycle hook จะทำงานเฉพาะภายใน instance ของคอมโพเนนต์ ดังนั้นจึงเรียก composable โดยตรงไม่ได้ เทคนิคมาตรฐานคือสร้างคอมโพเนนต์ harness แบบใช้แล้วทิ้งที่เรียก composable ใน setup และเปิดค่าที่คืนออกมา จากนั้นจึง mount harness นั้นด้วย Vue Test Utils แล้วยืนยันผลผ่าน wrapper วิธีนี้ทำให้การเชื่อมต่อ reactivity และ lifecycle ยังคงสมบูรณ์ในขณะที่ยังแยกตรรกะที่กำลังทดสอบให้อิสระ คำถามเชิงแนวคิดและเชิงเขียนโค้ดเพิ่มเติมรวบรวมไว้ในคู่มือคำถามสัมภาษณ์ Vue.js ที่จำเป็น

บทสรุป

การตั้งค่าการทดสอบ Vue ที่เชื่อถือได้ในปี 2026 สรุปลงเหลือทางเลือกที่พิจารณาอย่างตั้งใจไม่กี่ข้อ:

  • รัน Vitest ด้วย environment: 'jsdom' และปลั๊กอิน Vue เพื่อให้เทสต์ใช้ transform pipeline ของ Vite ชุดเดียวกับตัวแอปเป๊ะ
  • เลือก mount มากกว่า shallowMount เว้นแต่คอมโพเนนต์ลูกจะหนักหรือถูกครอบคลุมไว้แล้วที่อื่น
  • await trigger, setValue และ nextTick เสมอก่อนยืนยันผล เพื่อกำจัดเทสต์ที่ผลลัพธ์ไม่แน่นอนและขึ้นกับจังหวะเวลา
  • ทดสอบ composable ที่ใช้เพียง reactivity API ด้วยการเรียกมันโดยตรงและยืนยันผลบน .value
  • mock ที่ขอบเขตของโมดูลด้วย vi.mock เพื่อให้พฤติกรรมเครือข่ายคงเส้นคงวาและทดสอบเส้นทาง error ได้
  • ใช้ createTestingPinia({ createSpy: vi.fn }) สำหรับเทสต์คอมโพเนนต์ และ stubActions: false เมื่อต้องทดสอบตรรกะของ store
  • query element ผ่านแอตทริบิวต์ data-test เพื่อให้เทสต์ล้มเฉพาะเมื่อพฤติกรรมจริงเปลี่ยน ไม่ใช่เพราะการปรับสไตล์

เมื่อเชี่ยวชาญแพตเทิร์นเหล่านี้ การทดสอบจะกลายเป็นเครื่องมือออกแบบมากกว่าภาระ ที่คอยดักจับ regression ก่อนจะหลุดไปถึงโปรดักชัน สำรวจเส้นทางการเรียนรู้ Vue และ Nuxt ฉบับเต็มเพื่อสร้างทักษะที่พร้อมสำหรับการสัมภาษณ์ต่อไป

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

ชาเลนจ์ประจำวัน

คุณหาบั๊กใน Vue.js / Nuxt.js เจอไหม

โค้ดจริงหนึ่งชิ้น บั๊กที่ซ่อนอยู่หนึ่งจุด วันละหนึ่งครั้ง ลองได้โดยไม่ต้องมีบัญชี

Anthony Fillion-Maillet

เขียนโดย

Anthony Fillion-Maillet

ผู้ก่อตั้ง SharpSkill

เป็นนักพัฒนาฟูลสแตกมากว่า 10 ปี ดูแล SharpSkill และรับผิดชอบทุกสิ่งที่เผยแพร่ที่นี่

อัปเดตเมื่อ 6 กรกฎาคม 2569

แท็ก

#vue
#testing
#vitest
#vue-test-utils
#interview

แชร์

บทความที่เกี่ยวข้อง