Skip to main content
Technology & EngineeringVue367 lines

Vue Testing

Vue 3 component testing with Vitest and Vue Test Utils covering unit tests, integration tests, and async patterns

Quick Summary18 lines
You are an expert in testing Vue 3 components using Vitest and Vue Test Utils, covering unit tests, integration tests, mocking, and async testing patterns.

## Key Points

- **`mount` vs `shallowMount`** — `mount` renders the full component tree; `shallowMount` stubs child components
- **Wrapper API** — the object returned by `mount` provides methods to query, interact, and assert
- **`flushPromises`** — awaits all pending promise resolutions to test async behavior
- **`vi.mock`** — Vitest's module mocking for isolating dependencies
- **Component stubs and global config** — provide mocks, plugins, and stubs via mount options
1. **Use `data-testid` attributes** — query by test IDs rather than CSS classes or element structure, which change frequently.
2. **Prefer `mount` over `shallowMount`** — shallow mounting hides real integration issues. Use `mount` by default and only shallow-mount when child components are expensive or irrelevant.
3. **Test behavior, not implementation** — assert on rendered output, emitted events, and user-visible state rather than internal component data.
4. **Use `flushPromises` for async** — after any action that triggers async updates (fetch, nextTick), await `flushPromises()` before assertions.
5. **Use `createTestingPinia`** — it provides a real Pinia instance with pre-set state and optionally stubbed actions, making store-dependent tests clean.
6. **Keep tests fast** — avoid real network calls, use `vi.mock` or `vi.fn` for external dependencies.
7. **Test composables independently** — composables without lifecycle hooks can be tested as plain functions. Use the `withSetup` helper for those that need a component context.
skilldb get vue-skills/Vue TestingFull skill: 367 lines
Paste into your CLAUDE.md or agent config

Vue Component Testing — Vue.js

You are an expert in testing Vue 3 components using Vitest and Vue Test Utils, covering unit tests, integration tests, mocking, and async testing patterns.

Core Philosophy

Testing Vue components well requires a mindset shift: you are testing behavior, not implementation. The consumer of a component is the user who sees rendered output, clicks buttons, and reads text — not a developer who inspects internal data properties. Every test should be expressible as "when the user does X, they should see Y" or "when X happens, event Y is emitted." If a test can only be expressed in terms of internal component state, it is testing the wrong thing.

The testing pyramid applies to Vue applications with a specific twist. Composables and stores can be unit-tested as pure functions with fast, isolated tests. Components should be integration-tested with mount (not shallowMount by default) because the real value is verifying that the template, bindings, and child components work together. End-to-end tests cover critical user flows that span multiple pages. Over-investing in shallow-mount unit tests gives false confidence because they hide template binding bugs.

Speed is a feature of a test suite. Vitest's Vite-native architecture makes tests fast enough to run on every save, which changes developer behavior — tests become a development tool rather than a CI gate. The faster the feedback loop, the more developers write tests. Mock external dependencies aggressively (HTTP, timers, third-party services) but resist mocking Vue internals and child components unless there is a specific reason.

Anti-Patterns

  • Testing Internal Component State — accessing wrapper.vm.someData to assert on internal state instead of asserting on what the DOM renders. This couples tests to implementation and misses template binding bugs entirely.

  • Overusing shallowMount — stubbing all child components by default, which hides integration bugs between parent and child. Use mount as the default and only reach for shallowMount when child components are expensive or genuinely irrelevant.

  • Missing await on Trigger Calls — writing wrapper.find('button').trigger('click') without await, then immediately asserting on DOM state that has not updated yet. Vue updates the DOM asynchronously after user interactions.

  • Snapshot Testing Dynamic Content — using snapshot tests for components with dynamic data, timestamps, or generated IDs. These snapshots break on every run and train developers to blindly update them, defeating their purpose.

  • Mocking Everything — replacing every dependency with a mock until the test only verifies that mock wiring works correctly. If a test mocks the store, the router, the HTTP client, and all child components, it tests nothing meaningful about the component's actual behavior.

Overview

Vue component testing combines Vitest (a Vite-native test runner) with Vue Test Utils (the official testing library for Vue). Together they enable fast, reliable tests for components, composables, and stores. Tests mount components in isolation, simulate user interactions, and assert on rendered output and emitted events.

Core Concepts

  • mount vs shallowMountmount renders the full component tree; shallowMount stubs child components
  • Wrapper API — the object returned by mount provides methods to query, interact, and assert
  • flushPromises — awaits all pending promise resolutions to test async behavior
  • vi.mock — Vitest's module mocking for isolating dependencies
  • Component stubs and global config — provide mocks, plugins, and stubs via mount options

Implementation Patterns

Basic Component Test

// components/__tests__/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'

describe('Counter', () => {
  it('renders initial count', () => {
    const wrapper = mount(Counter, {
      props: { initial: 5 },
    })
    expect(wrapper.text()).toContain('5')
  })

  it('increments count on button click', async () => {
    const wrapper = mount(Counter)
    const button = wrapper.find('[data-testid="increment"]')

    await button.trigger('click')

    expect(wrapper.text()).toContain('1')
  })

  it('emits update event', async () => {
    const wrapper = mount(Counter)

    await wrapper.find('[data-testid="increment"]').trigger('click')

    expect(wrapper.emitted('update')).toHaveLength(1)
    expect(wrapper.emitted('update')![0]).toEqual([1])
  })
})

Testing with Props and Slots

import { mount } from '@vue/test-utils'
import Alert from '../Alert.vue'

it('renders slot content with correct variant class', () => {
  const wrapper = mount(Alert, {
    props: { variant: 'error' },
    slots: {
      default: 'Something went wrong',
      icon: '<span class="icon">!</span>',
    },
  })

  expect(wrapper.text()).toContain('Something went wrong')
  expect(wrapper.classes()).toContain('alert-error')
  expect(wrapper.find('.icon').exists()).toBe(true)
})

Testing Async Components

import { mount, flushPromises } from '@vue/test-utils'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import UserProfile from '../UserProfile.vue'

// Mock the fetch API
global.fetch = vi.fn()

describe('UserProfile', () => {
  beforeEach(() => {
    vi.mocked(fetch).mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ name: 'Alice', email: 'alice@example.com' }),
    } as Response)
  })

  it('shows loading then user data', async () => {
    const wrapper = mount(UserProfile, {
      props: { userId: '1' },
    })

    expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)

    await flushPromises()

    expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false)
    expect(wrapper.text()).toContain('Alice')
    expect(wrapper.text()).toContain('alice@example.com')
  })

  it('shows error message on fetch failure', async () => {
    vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'))

    const wrapper = mount(UserProfile, {
      props: { userId: '1' },
    })

    await flushPromises()

    expect(wrapper.find('[data-testid="error"]').text()).toContain('Network error')
  })
})

Testing with Pinia

import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import CartSummary from '../CartSummary.vue'
import { useCartStore } from '@/stores/useCartStore'

it('displays cart total from store', () => {
  const wrapper = mount(CartSummary, {
    global: {
      plugins: [
        createTestingPinia({
          initialState: {
            cart: {
              items: [
                { id: '1', name: 'Widget', price: 9.99, quantity: 2 },
                { id: '2', name: 'Gadget', price: 19.99, quantity: 1 },
              ],
            },
          },
          // Stub all actions by default
          stubActions: false,
        }),
      ],
    },
  })

  expect(wrapper.text()).toContain('$39.97')
})

it('calls removeItem when remove button is clicked', async () => {
  const wrapper = mount(CartSummary, {
    global: {
      plugins: [
        createTestingPinia({
          initialState: {
            cart: {
              items: [{ id: '1', name: 'Widget', price: 9.99, quantity: 1 }],
            },
          },
        }),
      ],
    },
  })

  const store = useCartStore()

  await wrapper.find('[data-testid="remove-1"]').trigger('click')

  expect(store.removeItem).toHaveBeenCalledWith('1')
})

Testing Vue Router Integration

import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from '../App.vue'

const router = createRouter({
  history: createMemoryHistory(),
  routes: [
    { path: '/', component: { template: '<div>Home</div>' } },
    { path: '/about', component: { template: '<div>About</div>' } },
  ],
})

it('navigates to about page', async () => {
  router.push('/')
  await router.isReady()

  const wrapper = mount(App, {
    global: { plugins: [router] },
  })

  await wrapper.find('a[href="/about"]').trigger('click')
  await flushPromises()

  expect(wrapper.text()).toContain('About')
})

Testing Composables

// composables/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('starts at initial value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('increments and decrements', () => {
    const { count, increment, decrement } = useCounter()
    expect(count.value).toBe(0)

    increment()
    expect(count.value).toBe(1)

    decrement()
    expect(count.value).toBe(0)
  })
})
// For composables that require a component context (lifecycle hooks):
import { withSetup } from '@/test-utils'

function withSetup<T>(composable: () => T): [T, any] {
  let result!: T
  const wrapper = mount({
    setup() {
      result = composable()
      return () => null
    },
  })
  return [result, wrapper]
}

it('composable with lifecycle hooks', () => {
  const [result, wrapper] = withSetup(() => useMouse())
  expect(result.x.value).toBe(0)
  wrapper.unmount()
})

Testing Form Interactions

it('submits form with input values', async () => {
  const wrapper = mount(LoginForm)

  await wrapper.find('input[name="email"]').setValue('user@example.com')
  await wrapper.find('input[name="password"]').setValue('secret')
  await wrapper.find('form').trigger('submit.prevent')

  expect(wrapper.emitted('submit')![0]).toEqual([
    { email: 'user@example.com', password: 'secret' },
  ])
})

it('shows validation errors', async () => {
  const wrapper = mount(LoginForm)

  // Submit empty form
  await wrapper.find('form').trigger('submit.prevent')

  expect(wrapper.find('[data-testid="email-error"]').text())
    .toBe('Email is required')
})

Vitest Configuration for Vue

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

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./test/setup.ts'],
    include: ['**/__tests__/**/*.test.ts', '**/*.spec.ts'],
    coverage: {
      provider: 'v8',
      include: ['src/**/*.{ts,vue}'],
      exclude: ['src/**/*.d.ts', 'src/**/__tests__/**'],
    },
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
})
// test/setup.ts
import { config } from '@vue/test-utils'

// Global stubs for common components
config.global.stubs = {
  // Stub router-link to avoid needing router in every test
  RouterLink: {
    template: '<a :href="to"><slot /></a>',
    props: ['to'],
  },
}

Best Practices

  1. Use data-testid attributes — query by test IDs rather than CSS classes or element structure, which change frequently.
  2. Prefer mount over shallowMount — shallow mounting hides real integration issues. Use mount by default and only shallow-mount when child components are expensive or irrelevant.
  3. Test behavior, not implementation — assert on rendered output, emitted events, and user-visible state rather than internal component data.
  4. Use flushPromises for async — after any action that triggers async updates (fetch, nextTick), await flushPromises() before assertions.
  5. Use createTestingPinia — it provides a real Pinia instance with pre-set state and optionally stubbed actions, making store-dependent tests clean.
  6. Keep tests fast — avoid real network calls, use vi.mock or vi.fn for external dependencies.
  7. Test composables independently — composables without lifecycle hooks can be tested as plain functions. Use the withSetup helper for those that need a component context.

Common Pitfalls

  • Forgetting await on trigger/setValue — Vue updates the DOM asynchronously. Without await, assertions run before the DOM updates.
  • Testing implementation details — accessing wrapper.vm.internalData couples tests to implementation. Prefer wrapper.text(), wrapper.find(), and wrapper.emitted().
  • Not cleaning up — if tests modify global state (stores, mocks), use beforeEach/afterEach to reset. createTestingPinia creates a fresh store per test.
  • Mocking too much — over-mocking gives false confidence. If a test mocks everything the component depends on, it tests nothing.
  • Missing global.plugins — components that use router, Pinia, or i18n will throw without providing those plugins in mount options.
  • Snapshot overuse — snapshot tests are brittle for component markup. Use them sparingly for stable output; prefer targeted assertions for dynamic content.

Install this skill directly: skilldb add vue-skills

Get CLI access →