Skip to content
End To End Tester

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.

typescript
// 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();
});
typescript
// 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.

typescript
// 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

typescript
// 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);
});
typescript
// 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
});
typescript
// 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

typescript
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

typescript
// 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:

typescript
mount(OrderPage, {
  global: { stubs: { MapView: true } }      // the map SDK, and nothing else
});

#Routing

typescript
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.

typescript
// vite.config.ts
export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts']
  }
});
typescript
// 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 — ref and computed are 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 ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?