Vue.js: Unit Testing & E2E

Last updated: 2026-10-20

Unit testing is the cornerstone of guaranteeing code quality — you can refactor a component without fear of breaking it, CI runs the tests automatically, and new teammates dare to touch the code. Vue 3 recommends Vitest (a next-generation test framework that is 10x faster than Jest) plus Vue Test Utils (the official component testing tool).

E2E testing simulates real user interactions — Playwright is the most popular E2E tool today (faster than Cypress and cross-browser). This lesson covers the complete testing system: unit tests + E2E.

1. What You'll Learn


2. The Pain of a Team with "Refactoring Phobia"

(1) Pain point: one broken component crashes the whole site

Alice's team maintains an e-commerce admin with 100+ components:

JS
// ❌ The broken version: no tests, so Alice doesn't dare to refactor
// One line changed in ProductCard.vue, and the entire home page goes blank
// Nobody notices until users complain the next day
// The team: "Don't touch this component, we don't know what it will break"

Product manager Charlie:

"Alice, we need tests. We need to be able to refactor with confidence. We need 1) unit tests for components, 2) E2E tests for the critical flows."

(2) The Vitest + Vue Test Utils solution

JS
// tests/unit/ProductCard.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ProductCard from '@/components/ProductCard.vue'

describe('ProductCard', () => {
  it('renders product name and price', () => {
    const product = { id: 1, name: 'iPhone', price: 999 }
    const wrapper = mount(ProductCard, { props: { product } })
    
    expect(wrapper.text()).toContain('iPhone')
    expect(wrapper.text()).toContain('$999')
  })
  
  it('emits add-to-cart event', async () => {
    const wrapper = mount(ProductCard, { 
      props: { product: { id: 1, name: 'iPhone', price: 999 } } 
    })
    
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('add-to-cart')).toBeTruthy()
    expect(wrapper.emitted('add-to-cart')[0]).toEqual([1])
  })
})

Run the tests:

BASH
$ npx vitest run
✓ ProductCard > renders product name and price
✓ ProductCard > emits add-to-cart event
2 passed

Runs in 5 seconds, zero false alarms. The moment you finish a change, you know whether you broke something.

(3) The payoff

After adding tests:


3. Vitest Configuration

(1) Installation

BASH
npm install -D vitest @vue/test-utils @vitest/coverage-v8 jsdom

(2) Basic configuration

TS
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  
  test: {
    // Test environment
    environment: 'jsdom',  // or 'happy-dom' / 'node'
    
    // Files to match
    include: ['tests/**/*.test.ts', 'src/**/*.test.ts'],
    
    // Coverage
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json'],
      exclude: ['node_modules/', 'tests/']
    },
    
    // Global setup
    setupFiles: ['./tests/setup.ts']
  },
  
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
})

(3) The 4 core APIs

TS
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

// 1. describe: a test suite
describe('Calculator', () => { })

// 2. it: a single test
it('adds 1 + 1 to equal 2', () => { })

// 3. expect: assertions
expect(1 + 1).toBe(2)
expect(arr).toContain('apple')
expect(obj).toMatchObject({ name: 'Alice' })

// 4. vi: mock / spy
vi.fn()                  // mock function
vi.mock('./api')         // mock a module
vi.spyOn(obj, 'method')  // spy on a method

(4) Vitest vs Jest

Dimension Jest Vitest
Startup time 3-5s < 1s
Watch mode 2-3s < 100ms
ESM support Needs configuration Native
TypeScript Needs ts-jest Native
Compatibility Huge Jest-compatible API
Recommendation Legacy ⭐⭐⭐⭐⭐

4. Component Testing with Vue Test Utils

(1) The 5 core APIs

JS
import { mount, shallowMount, RouterLinkStub } from '@vue/test-utils'

// 1. mount: full mount (child components are mounted too)
const wrapper = mount(Component, { props: { ... } })

// 2. shallowMount: shallow mount (child components are not mounted, they are stubbed)
const wrapper = shallowMount(Component, { props: { ... } })

// 3. Finding elements
wrapper.find('button')              // first match
wrapper.findAll('li')              // all matches
wrapper.findComponent(MyComponent)  // find a child component
wrapper.get('#submit')             // must exist (throws if not found)

// 4. Triggering events
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('hello')

// 5. Assertions
expect(wrapper.text()).toContain('Hello')
expect(wrapper.find('h1').text()).toBe('Title')
expect(wrapper.emitted('add-to-cart')).toBeTruthy()

(2) The 5 test scenarios

JS
// 1. props test
it('renders props correctly', () => {
  const wrapper = mount(MyComponent, { props: { title: 'Hello' } })
  expect(wrapper.text()).toContain('Hello')
})

// 2. emit test
it('emits event on click', async () => {
  const wrapper = mount(MyComponent)
  await wrapper.find('button').trigger('click')
  expect(wrapper.emitted('submit')).toBeTruthy()
})

// 3. slot test
it('renders default slot', () => {
  const wrapper = mount(MyComponent, {
    slots: { default: '<p>Slot content</p>' }
  })
  expect(wrapper.html()).toContain('Slot content')
})

// 4. async test
// Note: flushPromises must be defined yourself or imported from a testing utility library
// const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
it('loads data on mount', async () => {
  const wrapper = mount(MyComponent)
  await flushPromises()  // wait for the async work to finish
  expect(wrapper.text()).toContain('Loaded')
})

// 5. provide/inject test
it('uses provided value', () => {
  const wrapper = mount(MyComponent, {
    provide: { theme: 'dark' }
  })
  expect(wrapper.find('.dark').exists()).toBe(true)
})

(3) The 5 assertion techniques

JS
// 1. Text contains
expect(wrapper.text()).toContain('Hello')

// 2. Class exists
expect(wrapper.find('.active').exists()).toBe(true)

// 3. Element exists
expect(wrapper.find('button').exists()).toBe(true)

// 4. Event emitted
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')[0]).toEqual([arg1, arg2])

// 5. Async operation finished
await wrapper.vm.$nextTick()
await flushPromises()

▶ Example: Verifying props and emit with a Mini Assertion Helper (browser-runnable)

💡 This is a browser-runnable equivalent demo: no Vitest install, just a 20-line expect / test replacement. The component is mounted into real DOM, clicked, and we assert both the rendered text and the value it emits to the parent.

▶ Expand runnable example (75 lines)
▶ Try it Yourself

Output:

TEXT
First render: the page shows the "Hits: 3" button, the hint "Last value received by the parent: (not clicked yet)", and the list shows "running…".
After the script clicks the sandbox button once, the list becomes ✓ props drive the initial render, ✓ a click increments and re-renders, ✓ emit passes the new value to the parent, ✓ 3 passed.
The console prints the same 4 lines; clicking the visible Hits button yourself keeps incrementing it and updates the parent value to 4, 5, 6…

5. Playwright E2E Testing

(1) Installation

BASH
npm install -D @playwright/test
npx playwright install

(2) Configuration

TS
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry'
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } }
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI
  }
})

(3) The 5 E2E scenarios

TS
// 1. Login flow
import { test, expect } from '@playwright/test'

test('user can login', async ({ page }) => {
  await page.goto('/login')
  await page.fill('input[name="username"]', 'alice@example.com')
  await page.fill('input[name="password"]', 'password123')
  await page.click('button[type="submit"]')
  
  await expect(page).toHaveURL('/dashboard')
  await expect(page.locator('h1')).toContainText('Welcome, Alice')
})

// 2. Add to cart
test('add product to cart', async ({ page }) => {
  await page.goto('/products/1')
  await page.click('button:has-text("Add to Cart")')
  
  await expect(page.locator('.cart-count')).toHaveText('1')
})

// 3. Form submission
test('submit form with validation', async ({ page }) => {
  await page.goto('/contact')
  await page.fill('input[name="email"]', 'invalid-email')
  await page.click('button[type="submit"]')
  
  await expect(page.locator('.error')).toBeVisible()
})

// 4. Route navigation
test('navigate to products page', async ({ page }) => {
  await page.goto('/')
  await page.click('a:has-text("Products")')
  
  await expect(page).toHaveURL('/products')
})

// 5. Async loading
test('loads data after navigation', async ({ page }) => {
  await page.goto('/dashboard')
  
  // Wait for the data to load
  await page.waitForSelector('.chart-loaded')
  await expect(page.locator('.chart-loaded')).toBeVisible()
})

(4) Running the E2E tests

BASH
# Run all tests
npx playwright test

# Run a specific file
npx playwright test tests/e2e/login.spec.ts

# Debug mode (opens a browser)
npx playwright test --debug

# UI mode (visual)
npx playwright test --ui

▶ Example: Rehearsing an E2E User Flow (browser-runnable)

💡 This is a browser-runnable equivalent demo: Playwright does "click → wait → assert" in a real browser, and we do exactly the same inside this page, replacing expect(locator) with a hand-written check().

▶ Expand runnable example (68 lines)
▶ Try it Yourself

Output:

TEXT
First render: the status line reads "Logged out", both "Add to cart" and "Checkout" are greyed out, and the list shows "running…".
The script runs 4 steps automatically (login, add 2 items, checkout), so the list shows 4 ✓ lines plus "✓ E2E flow passed: 4 / 4 steps", and the console prints the same 5 lines.
Afterwards you can keep clicking "Add to cart" (login first) by hand: the cart and the checkout button state stay in sync.

6. Complete Example: The 5 Test Scenarios

▶ Example: The 5 Test Scenarios (⚠️ Requires Vite + Vitest)

⚠️ The code below runs inside a Vite project; the CDN global build does not support Vitest. It shows the core APIs:

JS
// Unit tests: Vitest + @vue/test-utils
// 1. props
it('renders props', () => { /* ... */ })

// 2. emit
it('emits event', async () => { /* ... */ })

// 3. slot
it('renders slot', () => { /* ... */ })

// 4. async
it('loads data', async () => { /* ... */ })

// 5. provide
it('uses provide', () => { /* ... */ })
▶ Try it Yourself
JS
// The 5 assertion techniques
// 1. Text
expect(wrapper.text()).toContain('Hello')

// 2. Class
expect(wrapper.find('.active').exists()).toBe(true)

// 3. Element
expect(wrapper.find('button').exists()).toBe(true)

// 4. Event
expect(wrapper.emitted('click')).toBeTruthy()

// 5. Async
await wrapper.vm.$nextTick()
TS
// E2E tests: Playwright (5 scenarios)
// 1. Login
test('login', async ({ page }) => { /* ... */ })

// 2. Cart
test('add to cart', async ({ page }) => { /* ... */ })

// 3. Form
test('form submit', async ({ page }) => { /* ... */ })

// 4. Routing
test('navigate', async ({ page }) => { /* ... */ })

// 5. Async
test('async load', async ({ page }) => { /* ... */ })

Output:

TEXT
Once the placeholders are filled in, Vitest reports all ten unit tests as passed, and Playwright reports the five E2E scenarios as passed for each configured browser:
TEXT
 ✓ tests/unit/component.test.js (5)
   ✓ renders props
   ✓ emits event
   ✓ renders slot
   ✓ loads data
   ✓ uses provide
 ✓ tests/unit/assertions.test.js (5)

 Test Files  2 passed (2)
      Tests  10 passed (10)
   Duration  ~1.2s

Running 5 tests using 3 workers
 ✓ 5 [chromium] › flows.spec.ts › login
 ✓ 5 [chromium] › flows.spec.ts › add to cart
 ✓ 5 [chromium] › flows.spec.ts › form submit
 ✓ 5 [chromium] › flows.spec.ts › navigate
 ✓ 5 [chromium] › flows.spec.ts › async load
 5 passed

(1) 📊 Quick Reference for 5 Common Mistakes

Error Symptom Solution
Test cannot find the component Import error Check the path alias
Async test does not wait Always fails Use await + flushPromises
Triggering an event has no effect Listener never fires await after trigger
E2E times out Element not found waitForSelector
Low coverage No tests written Enforce a coverage threshold

Output:

TEXT
The block renders as a five-row troubleshooting table that you scan top to bottom when a test misbehaves:
TEXT
Error                              Symptom                  Solution
---------------------------------  -----------------------  ----------------------------
Test cannot find the component     Import error             Check the path alias
Async test does not wait           Always fails             Use await + flushPromises
Triggering an event has no effect  Listener never fires     await after trigger
E2E times out                      Element not found        waitForSelector
Low coverage                       No tests written         Enforce a coverage threshold

(2) 📊 Comparison of the 5 Testing Tools

Tool Speed Best for
Vitest ⭐⭐⭐⭐⭐ Unit testing
Vue Test Utils ⭐⭐⭐⭐⭐ Component testing
Playwright ⭐⭐⭐⭐ E2E testing
Cypress ⭐⭐⭐ E2E (slow)
Testing Library ⭐⭐⭐⭐ Component testing

Output:

TEXT
The block renders as a five-row comparison table sorted from fastest to slowest:
TEXT
Tool               Speed       Best for
-----------------  ----------  ------------------
Vitest             ⭐⭐⭐⭐⭐  Unit testing
Vue Test Utils     ⭐⭐⭐⭐⭐  Component testing
Playwright         ⭐⭐⭐⭐    E2E testing
Cypress            ⭐⭐⭐      E2E (slow)
Testing Library    ⭐⭐⭐⭐    Component testing

❓ FAQ

Q Vitest or Jest?
A Vitest. Use Vitest for every new project (10x faster, native ESM/TS support). Keep Jest for maintaining legacy projects.
Q mount or shallowMount?
A Use shallowMount for unit tests (faster, isolated). Use mount for integration tests (realistic). Recommendation: shallowMount for component tests, mount for a handful of critical components.
Q What test coverage is appropriate?
A 80%+ for core components, 90%+ for utils, 60%+ overall. Don't chase 100% (it isn't practical).
Q Playwright or Cypress?
A Playwright. Twice as fast, cross-browser (Chromium / Firefox / WebKit), and a better API. Cypress only supports Chromium-based browsers.
Q What ratio of unit tests to E2E tests?
A 70% unit + 20% integration + 10% E2E. E2E is slow and fragile; unit tests give the best return.
Q Should tests mock every dependency?
A Unit tests mock external APIs (fetch, localStorage). Integration tests use real dependencies. E2E is entirely real.
Q How do I run tests in CI?
A Add a npm test step to GitHub Actions / GitLab CI. Block PR merges when it fails.

📖 Summary


📝 Exercises

  1. Basic (Difficulty: ⭐) Write 3 tests for ProductCard:

    • props rendering
    • emit event
    • Edge case (stock is 0)
  2. Intermediate (Difficulty: ⭐⭐) Write complete tests for the cart store:

    • 5 actions (addItem / removeItem / clear / load / save)
    • 3 getters (itemCount / totalPrice / isEmpty)
    • Pinia test setup
  3. Challenge (Difficulty: ⭐⭐⭐) Build a complete "testing system":

    1. 10 component unit tests (3-5 tests per component)
    2. 5 composable tests
    3. 5 E2E tests (login / register / search / cart / checkout)
    4. 80% coverage threshold
    5. CI integration (GitHub Actions)
    6. Vitest + Playwright cross-browser testing
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏