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 coverageFor the Vitest UI:
npm install --save-dev @vitest/uiSetup 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/testingimport { 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 --coverageOutput:
% 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 fileCI 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.xmlProduction 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.