Vitest for Vue: Setup, Coverage, Mocking Vue Router and Composables

Vitest for Vue: Setup, Coverage, Mocking Vue Router and Composables

Vitest is the fastest testing framework for Vue 3 projects. Because it shares the same Vite build pipeline as your application, it understands Vue SFCs natively, resolves aliases automatically, and runs significantly faster than Jest. This guide covers configuring Vitest for Vue, mocking Vue Router and Pinia, and setting up coverage reporting.

Key Takeaways

Vitest reuses your vite.config.ts. The same plugins, aliases, and transforms that work in development work in tests. No separate Jest config to maintain alongside your Vite config.

vi.mock() is hoisted like jest.mock(). Calls are moved to the top of the file by the Vitest transformer — even if you write them inside describe blocks. Place them at module level for clarity.

Use createTestingPinia() from @pinia/testing. It creates a Pinia instance with all stores mocked by default. You control initial state via initialState and decide which actions are real or spied.

vi.stubGlobal() replaces global APIs cleanly. Use it instead of window.X = jest.fn() — it resets automatically between tests when you call vi.unstubAllGlobals() in afterEach.

@vitest/coverage-v8 is faster than Istanbul. The v8 coverage provider uses Node's native coverage and is significantly faster for large codebases. Istanbul (@vitest/coverage-istanbul) is more accurate for edge cases.

Why Vitest for Vue

The traditional Vue + Jest setup requires babel-jest or ts-jest for TypeScript, vue-jest for SFC transformation, and manual alias configuration that duplicates your vite.config.ts. Every time you update your Vite configuration, you need to update your Jest configuration to match.

Vitest eliminates this:

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/tests/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{ts,vue}'],
      exclude: ['src/tests/**', 'src/**/*.d.ts'],
    },
  },
});

The @ alias defined in resolve.alias works automatically in tests. No extra configuration.

Installation

npm install --save-dev vitest @vue/test-utils @vitejs/plugin-vue jsdom
npm install --save-dev @vitest/coverage-v8  # for coverage

For the Vitest UI:

npm install --save-dev @vitest/ui

Setup File

// src/tests/setup.ts
import { config } from '@vue/test-utils';
import { vi } from 'vitest';

// Global stubs for components you don't want to render
config.global.stubs = {
  Teleport: true,  // stub <Teleport> in tests
};

// Mock matchMedia (not available in jsdom)
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

Testing Vue Components

import { mount, flushPromises } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import TodoList from '@/components/TodoList.vue';

describe('TodoList', () => {
  it('renders an empty list initially', () => {
    const wrapper = mount(TodoList);
    expect(wrapper.findAll('[data-testid="todo-item"]')).toHaveLength(0);
    expect(wrapper.text()).toContain('No todos yet');
  });

  it('adds a todo when form is submitted', async () => {
    const wrapper = mount(TodoList);
    
    await wrapper.get('[data-testid="todo-input"]').setValue('Buy groceries');
    await wrapper.get('[data-testid="add-btn"]').trigger('click');
    
    expect(wrapper.findAll('[data-testid="todo-item"]')).toHaveLength(1);
    expect(wrapper.text()).toContain('Buy groceries');
  });

  it('removes a todo when delete is clicked', async () => {
    const wrapper = mount(TodoList, {
      props: {
        initialTodos: [{ id: '1', text: 'Buy groceries', done: false }],
      },
    });

    await wrapper.get('[data-testid="delete-btn"]').trigger('click');
    
    expect(wrapper.findAll('[data-testid="todo-item"]')).toHaveLength(0);
  });
});

Mocking Vue Router

import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
import { describe, it, expect } from 'vitest';
import UserNav from '@/components/UserNav.vue';

const routes = [
  { path: '/', component: { template: '<div/>' } },
  { path: '/users', component: { template: '<div/>' } },
  { path: '/users/:id', component: { template: '<div/>' } },
];

describe('UserNav', () => {
  it('renders navigation links', async () => {
    const router = createRouter({
      history: createMemoryHistory(),
      routes,
    });

    const wrapper = mount(UserNav, {
      global: {
        plugins: [router],
      },
    });

    await router.isReady();

    expect(wrapper.get('[data-testid="users-link"]').attributes('href')).toBe('/users');
  });

  it('highlights the active route', async () => {
    const router = createRouter({ history: createMemoryHistory(), routes });

    await router.push('/users');

    const wrapper = mount(UserNav, {
      global: { plugins: [router] },
    });

    await router.isReady();

    expect(wrapper.get('[data-testid="users-link"]').classes()).toContain('active');
  });
});

Mocking useRouter and useRoute

import { vi } from 'vitest';

const mockPush = vi.fn();
const mockRoute = { params: { id: '123' }, query: {} };

vi.mock('vue-router', () => ({
  useRouter: () => ({ push: mockPush }),
  useRoute: () => mockRoute,
}));

it('navigates to user detail on click', async () => {
  const wrapper = mount(UserListItem, {
    props: { userId: '123' }
  });

  await wrapper.get('[data-testid="view-btn"]').trigger('click');

  expect(mockPush).toHaveBeenCalledWith('/users/123');
});

Mocking Pinia with @pinia/testing

npm install --save-dev @pinia/testing
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { useUserStore } from '@/stores/user';
import UserDashboard from '@/views/UserDashboard.vue';

describe('UserDashboard', () => {
  it('renders users from the store', () => {
    const wrapper = mount(UserDashboard, {
      global: {
        plugins: [
          createTestingPinia({
            initialState: {
              user: {
                users: [
                  { id: '1', name: 'Alice' },
                  { id: '2', name: 'Bob' },
                ],
                loading: false,
              },
            },
          }),
        ],
      },
    });

    expect(wrapper.text()).toContain('Alice');
    expect(wrapper.text()).toContain('Bob');
  });

  it('calls fetchUsers on mount', () => {
    const wrapper = mount(UserDashboard, {
      global: {
        plugins: [createTestingPinia({ createSpy: vi.fn })],
      },
    });

    const store = useUserStore();
    expect(store.fetchUsers).toHaveBeenCalledOnce();
  });
});

createTestingPinia stubs all store actions by default. Pass createSpy: vi.fn to make the stubs Vitest-compatible spy functions with .toHaveBeenCalledOnce() etc.

Mocking Composables

// src/composables/useAuth.ts
export function useAuth() {
  const user = ref<User | null>(null);
  const isAuthenticated = computed(() => user.value !== null);
  
  async function login(email: string, password: string) { /* ... */ }
  function logout() { /* ... */ }
  
  return { user, isAuthenticated, login, logout };
}
vi.mock('@/composables/useAuth', () => ({
  useAuth: () => ({
    user: ref({ id: '1', name: 'Alice', email: 'alice@example.com' }),
    isAuthenticated: computed(() => true),
    login: vi.fn(),
    logout: vi.fn(),
  }),
}));

it('shows user name when authenticated', () => {
  const wrapper = mount(UserHeader);
  expect(wrapper.text()).toContain('Alice');
});

Code Coverage

npx vitest run --coverage

Output:

 % Coverage report from v8
-----------------------------|---------|----------|---------|---------|
File                         | % Stmts | % Branch | % Funcs | % Lines |
-----------------------------|---------|----------|---------|---------|
All files                    |   87.14 |    82.35 |   91.67 |   87.14 |
 components/UserCard.vue     |   100   |   100    |   100   |   100   |
 composables/useCounter.ts   |   100   |   100    |   100   |   100   |
 stores/user.ts              |   75.00 |    66.67 |   83.33 |   75.00 |
-----------------------------|---------|----------|---------|---------|

Set thresholds in vite.config.ts:

test: {
  coverage: {
    provider: 'v8',
    thresholds: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
},

Tests fail if coverage drops below these thresholds.

Running Vitest

npx vitest              # watch mode (default)
npx vitest run          <span class="hljs-comment"># single run (CI)
npx vitest --ui         <span class="hljs-comment"># browser UI at localhost:51204
npx vitest --reporter=verbose  <span class="hljs-comment"># per-test detail
npx vitest related src/components/UserCard.vue  <span class="hljs-comment"># tests related to a file

CI Setup

# .github/workflows/test.yml
- name: Run tests
  run: npx vitest run --coverage --reporter=junit --outputFile=test-results.xml

- name: Upload test results
  uses: mikepenz/action-junit-report@v4
  with:
    report_paths: test-results.xml

Production Coverage Gap

Vitest covers your component logic in isolation. It doesn't test how components behave once deployed — with real network responses, browser quirks, and actual user sessions.

HelpMeTest fills that gap with 24/7 monitoring of your live Vue application. The free tier runs 10 tests every 5 minutes, no infrastructure required.

Read more