Testing Vue.js
Vue Test Utils and Testing Library, nextTick and reactivity timing, Pinia stores, composables and the mount-versus-shallowMount decision.
1 min read · updated 19 September 2026
Vue's reactivity settles asynchronously, and that single fact accounts for most confusing Vue test failures.
#The two libraries
Vue Test Utils is the official one: mounting, props, emitted events, slots, stubs.
@testing-library/vue wraps it with the shared
query API, and is the better default for
testing behaviour.
// Testing Library — reads like the React and Angular equivalents
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import BasketLine from './BasketLine.vue';
test('recalculates the line total when the quantity changes', async () => {
const user = userEvent.setup();
render(BasketLine, { props: { sku: 'book-1', unitCents: 1_200, quantity: 1 } });
await user.clear(screen.getByRole('spinbutton', { name: 'Quantity' }));
await user.type(screen.getByRole('spinbutton', { name: 'Quantity' }), '3');
expect(await screen.findByText('£36.00')).toBeVisible();
});// Vue Test Utils — when you need the Vue-specific surface
import { mount } from '@vue/test-utils';
test('emits change with the new quantity', async () => {
const wrapper = mount(BasketLine, { props: { sku: 'book-1', unitCents: 1_200, quantity: 1 } });
await wrapper.find('input[name=quantity]').setValue('3'); // await: returns nextTick
expect(wrapper.emitted('change')).toEqual([[{ sku: 'book-1', quantity: 3 }]]);
});#nextTick
Vue queues DOM updates and flushes them on the next microtask.
// Fails: the DOM has not been patched yet.
wrapper.vm.quantity = 3;
expect(wrapper.text()).toContain('£36.00');
// Works: await the flush.
wrapper.vm.quantity = 3;
await nextTick();
expect(wrapper.text()).toContain('£36.00');
// Also works: VTU's mutating methods return nextTick, so awaiting them
// is enough. This is the idiomatic form.
await wrapper.setProps({ quantity: 3 });
await wrapper.find('button').trigger('click');await wrapper.trigger(...) rather than wrapper.trigger(...) is the single
most common fix for a mysteriously failing Vue test.
#Composables
// A pure composable needs no component at all.
import { usePagination } from './usePagination';
test('clamps to the last page', () => {
const { page, goTo, isLast } = usePagination({ total: 42, perPage: 20 });
goTo(99);
expect(page.value).toBe(3);
expect(isLast.value).toBe(true);
});// One that uses lifecycle hooks needs a host component.
import { withSetup } from './test-utils';
test('useWindowSize stops listening on unmount', () => {
const [result, app] = withSetup(() => useWindowSize());
expect(result.width.value).toBeGreaterThan(0);
app.unmount(); // assert the listener was removed, if you expose that
});// test-utils.ts
export function withSetup<T>(composable: () => T): [T, App] {
let result!: T;
const app = createApp({ setup() { result = composable(); return () => {}; } });
app.mount(document.createElement('div'));
return [result, app];
}#Pinia
import { setActivePinia, createPinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
// Store logic: a real store, tested directly.
beforeEach(() => setActivePinia(createPinia()));
test('the basket total sums its lines', () => {
const basket = useBasketStore();
basket.add({ sku: 'book-1', quantity: 2, unitCents: 1_200 });
expect(basket.totalCents).toBe(2_400);
});
// Component with a store: a testing pinia with known initial state.
test('shows the basket count from the store', () => {
render(BasketBadge, {
global: {
plugins: [createTestingPinia({
initialState: { basket: { lines: [{ sku: 'a', quantity: 2, unitCents: 100 }] } },
stubActions: false // let real actions run; stub only what does I/O
})]
}
});
expect(screen.getByRole('status')).toHaveTextContent('2');
});stubActions: false is worth knowing: the default stubs every action, which
means a test can pass while the action it exercises does nothing.
#mount versus shallowMount
// Everything renders — the assembled component is what is tested.
mount(OrderPage);
// Every child is stubbed. Fast, and tests very little.
shallowMount(OrderPage);shallowMount produces tests that pass while the page is broken, because
the children that would fail were replaced with stubs. Use mount by
default and stub individual children where one is genuinely a problem:
mount(OrderPage, {
global: { stubs: { MapView: true } } // the map SDK, and nothing else
});#Routing
import { createRouter, createMemoryHistory } from 'vue-router';
const router = createRouter({ history: createMemoryHistory(), routes });
test('navigates to the order on click', async () => {
render(OrderList, { global: { plugins: [router] } });
await router.isReady();
await userEvent.click(screen.getByRole('link', { name: 'ORD-1' }));
expect(router.currentRoute.value.path).toBe('/orders/ORD-1');
});A memory history is essential — a real one touches window.location and
leaks between tests.
#Setup
Vitest is the natural runner for a Vue project: the same Vite config, the same aliases, the same plugins.
// vite.config.ts
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts']
}
});// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { config } from '@vue/test-utils';
config.global.mocks = { $t: (key: string) => key }; // i18n stub#What not to test
- Reactivity itself —
refandcomputedare Vue's tests - Template compilation
- That a prop reached a child, unless the child renders it visibly
- Component internals; assert on what is rendered and what is emitted
Common questions
- Why does my assertion fail immediately after changing a prop in Vue?
- Vue batches DOM updates and flushes them on the next microtask. Await nextTick(), or await the wrapper method that returns a promise (setProps, setValue, trigger), before asserting on the DOM.
- Should I use mount or shallowMount?
- mount, by default. shallowMount stubs every child component, which makes tests that pass while the assembled page is broken. Reach for shallowMount only when a child is genuinely expensive or does something untestable, such as hitting a map SDK.
- Vue Test Utils or Testing Library?
- Testing Library for component behaviour, because its queries survive refactoring and match the rest of your front-end tests. Vue Test Utils when you need Vue-specific access — emitted events, slot rendering, or a directive.
Runnable samples for this page
last test results ↗- TypeScript (Vitest)
vitest/src/web-frameworks/vuejs-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Testing LibraryThe query API shared across React, Vue, Angular and Svelte — its guiding principle, the queries, user-event, and the mistakes it is designed to prevent.
- DOM TestingTesting the layer every front-end framework produces — queries by role and accessible name, event simulation, jsdom's limits, and why selector choice decides maintainability.
- VitestA Vite-native test runner with a Jest-compatible API — faster startup, native ESM and TypeScript, and the cases where it is the better default.
- Testing ReactComponents, hooks, context, server components and async state — what to test in a React application, what to leave alone, and how to avoid act warnings.
- JestThe default JavaScript test runner — configuration, projects, snapshot testing, fake timers, and the choices that keep a large Jest suite fast.
- Testing AngularTestBed, the change detection model that confuses everyone, HttpTestingController, signals and standalone components — testing Angular without fighting it.
- Testing Knockout.jsKnockout view models are plain objects and observables are plain functions, which makes most of a Knockout application testable with no DOM at all.