Testing Vue components

Vitest and Vue Test Utils for components, router and store mocking, testing a composable in isolation, and a small end-to-end suite.

Vitest and Vue Test Utils

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

describe('Counter', () => {
  it('increments when the button is clicked', async () => {
    const wrapper = mount(Counter, { props: { start: 1 } })

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

    await wrapper.get('button').trigger('click')

    expect(wrapper.text()).toContain('2')
    expect(wrapper.emitted('change')[0]).toEqual([2])
  })
})
  • mount renders the component with its real children; shallowMount stubs them. Prefer mount unless a child is genuinely slow or reaches the network.
  • trigger is asynchronous: await it, otherwise the next assertion runs before Vue has flushed the update.
  • Query by role, label or text where the markup allows it, and by a data-test attribute otherwise. A test bound to a class name breaks the first time you restyle.
  • Assert on rendered output and emitted events, not on setup variables. A test that reaches into an internal ref locks in the implementation.
  • setProps and setValue also await the update; use them instead of mutating the component's instance directly.
⚠️
An assertion immediately after a click fails in confusing ways because Vue updates asynchronously. Await the trigger or call nextTick — a missing await is the most common cause of a component test that passes locally and fails in CI.

Router, store and module mocks

import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, vi } from 'vitest'
import CartView from '@/views/CartView.vue'
import { useCart } from '@/stores/cart'

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

function mountView(component) {
  return mount(component, {
    global: { plugins: [router, createTestingPinia({ stubActions: false })] }
  })
}

beforeEach(() => { vi.clearAllMocks() })

it('shows the cart total', async () => {
  const wrapper = mountView(CartView)
  const cart = useCart()
  cart.items = [{ id: 'a', price: 10, qty: 2 }]
  await wrapper.vm.$nextTick()
  expect(wrapper.text()).toContain('20')
})
  • createTestingPinia stubs actions by default so a test does not hit the network. Pass stubActions: false when you want the real action to run against state you seeded.
  • Use createMemoryHistory for the router in tests: no URL manipulation, and you can push a route to set up the params a component reads.
  • Anything the component expects from the app — router, store, plugin, global component — must be provided through the global mount option or it renders as a warning and an empty slot.
  • vi.mock replaces a module, which is the right tool for the fetch layer or an API wrapper: the component is then tested against a controlled response.
  • Reset mocks between tests, or one test's recorded calls leak into the next and the assertion you write is about the wrong run.

Composables and end-to-end tests

// tests/helpers.js — give a composable a component scope
import { defineComponent, h } from 'vue'
import { mount } from '@vue/test-utils'

export function withSetup(composable) {
  let result
  const wrapper = mount(defineComponent({
    setup() { result = composable(); return () => h('div') }
  }))
  return { result, unmount: () => wrapper.unmount() }
}
// e2e/checkout.spec.js — Playwright against a preview build
import { test, expect } from '@playwright/test'

test('a visitor can add an item and see the total', async ({ page }) => {
  await page.goto('/')
  await page.getByRole('button', { name: 'Add to cart' }).click()
  await page.getByRole('link', { name: 'Cart' }).click()
  await expect(page.getByText('Total')).toContainText('20')
})
  • Unit tests cover logic and one component's contract. They cannot tell you that the app boots, that routing works, or that the API contract still matches.
  • Keep a small end-to-end suite for the flows that matter — sign in, search, add to cart, checkout — and run it against a preview build rather than the dev server.
  • Test a composable through the helper above, or with effectScope when it only needs reactivity. That is what makes onUnmounted-based cleanup testable.
  • Coverage shows what code ran, not what was verified. Chasing a percentage produces tests of getters and very little confidence.
  • Fix flakiness at the source: wait for a visible condition instead of sleeping for a fixed number of milliseconds.

FAQ

mount or shallowMount?
Start with mount. It exercises the real composition, which is where most bugs live. Use shallowMount only when a child is slow, network-bound, or already covered by its own test and you want the parent isolated.
Do I need to test the store separately?
Yes, for the parts worth testing: pure getters and the branching inside an action. That is cheap, fast and independent of any component. Component tests then cover the wiring, not the store logic again.

Pinia in depth and data fetching patterns Production builds, environments and deployment

Last refreshed 2026-09-18.