Testing

This section documents the current Vue 3.x release line as published at the official Vue.js documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. The Composition API with <script setup> is the authoring style used throughout; the Options API is shown only as a contrast.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Vue and its ecosystem iterate quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Vue tests split into unit tests (plain functions and composables), component tests (a component mounted in isolation), and end-to-end tests (the real app in a real browser). The Vue guide’s Testing page frames the choices; this page is a practical tour.

The testing pyramid

Write many fast unit tests, a moderate number of component tests, and a few slow end-to-end tests. Unit and component tests run in milliseconds under Node and catch logic and rendering bugs; end-to-end tests are the only ones that exercise routing, real network calls, and the browser, so keep them focused on critical user journeys.

Vitest

Vitest is the recommended runner for a Vite project: it reuses your vite.config (aliases, plugins, @vitejs/plugin-vue) so components compile the same way in tests as in the app. create-vue can add it; manually:

npm i -D vitest @vue/test-utils happy-dom
// vitest.config.js
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(viteConfig, defineConfig({
  test: {
    environment: 'happy-dom',   // a DOM implementation for component tests
    globals: true,              // describe/it/expect without imports
  },
}))

A test file:

import { describe, it, expect } from 'vitest'
import { add } from '@/utils/math'

describe('add', () => {
  it('sums two numbers', () => {
    expect(add(2, 3)).toBe(5)
  })
})

Component testing with Vue Test Utils

Vue Test Utils mounts a component and returns a wrapper with a query and interaction API.

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

it('increments and emits the new value', async () => {
  const wrapper = mount(Stepper, {
    props: { modelValue: 1, max: 3 },
  })

  await wrapper.find('[data-test="inc"]').trigger('click')

  expect(wrapper.text()).toContain('2')
  expect(wrapper.emitted('update:modelValue')).toEqual([[2]])
})

Key wrapper methods:

  • mount renders the component and its children; shallowMount stubs child components so the test is isolated to this component’s own template.

  • find / findAll take a CSS selector and return DOM wrappers; findComponent / findAllComponents take a component and return component wrappers.

  • trigger('click'), trigger('keydown.enter') dispatch DOM events; setValue('text') sets an input’s value and fires input.

  • emitted() records every custom event — emitted('submit') is an array of argument arrays, one per emission.

  • props(), text(), html(), classes(), isVisible() inspect the render.

Waiting for updates

Vue applies DOM updates asynchronously. trigger, setValue, and setProps return promises you can await; for state changed directly, await nextTick(); for pending microtasks such as resolved fetches, await flushPromises():

import { flushPromises, mount } from '@vue/test-utils'

const wrapper = mount(UserCard, { props: { id: 7 } })
await flushPromises()               // let the mocked API promise settle
expect(wrapper.text()).toContain('Ada Lovelace')

Stubs, mocks, and slots

Provide global.stubs to replace heavy children, global.mocks for injected globals, and slots to pass slot content:

const wrapper = mount(Layout, {
  slots: {
    default: '<p>body</p>',
    header: HeaderStub,
  },
  global: {
    stubs: { RouterLink: true },
    mocks: { $t: (key) => key },
  },
})

Mock modules (an API client, vue-router) with vi.mock('@/api') from Vitest.

Testing composables

A composable that uses no lifecycle hooks or injection can be called directly in a test:

import { useCounter } from '@/composables/useCounter'

it('counts', () => {
  const { count, inc } = useCounter()
  inc()
  expect(count.value).toBe(1)
})

If it calls onMounted, provide/inject, or getCurrentInstance, mount it inside a throwaway host component so it runs in a real component context:

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

function withSetup(composable) {
  let result
  const Comp = { setup() { result = composable(); return () => {} } }
  const wrapper = mount(Comp)
  return { result, wrapper }
}

Testing with Router and Pinia

Install a real router with in-memory history and await router.isReady(); install a fresh Pinia per test. @pinia/testing provides createTestingPinia(), which stubs actions by default so you can assert they were called:

import { createTestingPinia } from '@pinia/testing'
import { createRouter, createMemoryHistory } from 'vue-router'

const router = createRouter({ history: createMemoryHistory(), routes })

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

Snapshot testing

expect(wrapper.html()).toMatchSnapshot() records rendered markup and flags future diffs. Useful for stable presentational components; avoid it for components that change often, where snapshots become noise that gets blindly updated.

Component testing vs. end-to-end

Component tests mount one component under a simulated DOM — fast, but not a real browser. End-to-end tests drive the built app in an actual browser and are the place to verify navigation, focus management, real CSS, and integration with a backend. Cypress and Playwright also offer a component testing mode that mounts a Vue component in a real browser, a middle ground when jsdom/happy-dom fidelity is not enough.

End-to-end with Cypress or Playwright

Cypress and Playwright both start the dev server, open a browser, and script user interactions. They auto-wait for elements and let you intercept HTTP so tests stay deterministic:

// Playwright
import { test, expect } from '@playwright/test'

test('adds a todo', async ({ page }) => {
  await page.route('**/api/todos', (route) =>
    route.fulfill({ json: [] }),
  )
  await page.goto('/')
  await page.getByPlaceholder('What needs doing?').fill('Write tests')
  await page.getByRole('button', { name: 'Add' }).click()
  await expect(page.getByText('Write tests')).toBeVisible()
})
// Cypress
it('adds a todo', () => {
  cy.intercept('GET', '/api/todos', [])
  cy.visit('/')
  cy.get('[data-test="new-todo"]').type('Write tests{enter}')
  cy.contains('Write tests').should('be.visible')
})

create-vue can scaffold either one.

See also