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])
})
})mountrenders the component with its real children;shallowMountstubs them. Prefermountunless a child is genuinely slow or reaches the network.triggeris 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-testattribute 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.
setPropsandsetValuealso 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')
})createTestingPiniastubs actions by default so a test does not hit the network. PassstubActions: falsewhen you want the real action to run against state you seeded.- Use
createMemoryHistoryfor 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
globalmount option or it renders as a warning and an empty slot. vi.mockreplaces 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
effectScopewhen it only needs reactivity. That is what makesonUnmounted-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.
Related
Pinia in depth and data fetching patterns Production builds, environments and deployment
Last refreshed 2026-09-18.