From vue-plugin
Testing Vue 3 SPAs: Vitest + @vue/test-utils, RTL-style alternatives, Pinia testing via createTestingPinia, composable testing, msw for network mocks, Cypress component testing, Playwright e2e. Use this skill to: - Set up Vitest for Vue 3 with jsdom + @vue/test-utils. - Pick mount vs shallowMount. - Test components by props/emits/slots contract. - Mock Pinia stores in tests. - Test composables in isolation. - Set up Playwright or Cypress for end-to-end. Do NOT use this skill for: - General Vue conventions (see vue-conventions). - State patterns (see vue-state-management). - Form-specific patterns (see vue-forms).
How this skill is triggered — by the user, by Claude, or both
Slash command
/vue-plugin:vue-testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
| Layer | Framework |
| Layer | Framework |
|---|---|
| Component, composable, plain TS unit | Vitest + @vue/test-utils (preferred for Vite projects) |
| Component (alt) | @testing-library/vue (RTL-style API) |
| Component in browser | Cypress component testing (slower, more realistic) |
| End-to-end | Playwright (preferred) or Cypress |
For Vue 3 + Vite, Vitest is the modern default. Match what's installed.
vite.config.ts:
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import path from 'path';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
coverage: {
reporter: ['text', 'html'],
exclude: ['**/*.config.*', '**/*.spec.*', 'src/main.ts'],
},
},
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
});
vitest.setup.ts:
// Add custom matchers if needed
import { afterEach } from 'vitest';
import { config } from '@vue/test-utils';
afterEach(() => {
// cleanup mounted components
});
// Stub global components if needed
config.global.stubs = {
RouterLink: true,
RouterView: true,
};
Install: pnpm add -D vitest @vue/test-utils @vitejs/plugin-vue jsdom.
mount vs shallowMountimport { mount, shallowMount } from '@vue/test-utils';
// mount renders ALL children real
const wrapper = mount(MyComponent, { props: { name: 'Alice' } });
// shallowMount stubs ALL child components (renders <ChildComponent-stub />)
const wrapper = shallowMount(MyComponent, { props: { name: 'Alice' } });
Prefer mount — catches integration bugs (prop passing, slot rendering). Use shallowMount only for very large component trees where rendering full subtrees is slow.
// src/components/UserCard.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';
describe('UserCard', () => {
it('renders user name and email', () => {
const wrapper = mount(UserCard, {
props: { user: { id: '1', name: 'Alice', email: '[email protected]' } },
});
expect(wrapper.text()).toContain('Alice');
expect(wrapper.text()).toContain('[email protected]');
});
it('emits "delete" when delete button clicked', async () => {
const wrapper = mount(UserCard, {
props: { user: { id: '1', name: 'Alice', email: '[email protected]' } },
});
await wrapper.find('[data-testid="delete-btn"]').trigger('click');
expect(wrapper.emitted('delete')).toEqual([['1']]);
});
it('renders header slot when provided', () => {
const wrapper = mount(UserCard, {
props: { user: { id: '1', name: 'Alice', email: '[email protected]' } },
slots: { header: '<h2>Custom Header</h2>' },
});
expect(wrapper.find('h2').text()).toBe('Custom Header');
});
});
wrapper.find('selector') // CSS selector
wrapper.findAll('selector') // all matching
wrapper.findComponent(Foo) // by component
wrapper.findByText('text') // not built-in; use @testing-library/vue or .text() check
wrapper.text() // rendered text content
wrapper.html() // rendered HTML
wrapper.attributes('aria-invalid') // attribute value
wrapper.classes() // CSS classes array
wrapper.props() // props object
wrapper.emitted() // map of emitted events
wrapper.vm // component instance (use sparingly)
await wrapper.find('button').trigger('click');
await wrapper.find('input').trigger('input');
await wrapper.find('input').setValue('hello'); // shortcut for v-model inputs
await wrapper.find('select').setValue('option-value');
await wrapper.find('input[type=checkbox]').setChecked(true);
ALWAYS await — Vue's reactivity is async; assertions before nextTick() see stale state.
@testing-library/vue (RTL-style alternative)import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import UserCard from './UserCard.vue';
it('calls onDelete when delete clicked', async () => {
const user = userEvent.setup();
render(UserCard, { props: { user: { id: '1', name: 'Alice', email: '[email protected]' } } });
await user.click(screen.getByRole('button', { name: /delete/i }));
// emitted events accessed via wrapper.emitted() in test-utils;
// RTL approach: pass spies as props OR mock store
});
Query priority same as React Testing Library: getByRole > getByLabelText > getByText > getByTestId.
Pick @vue/test-utils for Vue-idiomatic API; @testing-library/vue for cross-framework consistency.
import { setActivePinia, createPinia } from 'pinia';
import { beforeEach, describe, it, expect } from 'vitest';
import { useUserStore } from '@/stores/users';
describe('userStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('starts empty', () => {
const store = useUserStore();
expect(store.items).toEqual([]);
expect(store.count).toBe(0);
});
it('adds users via fetchUsers', async () => {
const store = useUserStore();
await store.fetchUsers();
expect(store.items.length).toBeGreaterThan(0);
});
});
pnpm add -D @pinia/testing
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import UserList from './UserList.vue';
import { useUserStore } from '@/stores/users';
it('renders users from store', () => {
const wrapper = mount(UserList, {
global: {
plugins: [createTestingPinia({
initialState: {
users: { items: [{ id: '1', name: 'Alice', email: '[email protected]' }] },
},
createSpy: vi.fn, // for mocking actions
})],
},
});
expect(wrapper.text()).toContain('Alice');
const store = useUserStore();
expect(store.fetchUsers).toBeDefined();
// store.fetchUsers is a vi.fn() — assert it's called
});
createTestingPinia stubs all actions by default — they don't run real logic. Useful for component tests that just need the state shape.
Composables that don't touch DOM can be tested directly:
import { describe, it, expect } from 'vitest';
import { ref } from 'vue';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('increments', () => {
const { count, increment } = useCounter(0);
expect(count.value).toBe(0);
increment();
expect(count.value).toBe(1);
});
it('resets', () => {
const { count, increment, reset } = useCounter(5);
increment();
increment();
expect(count.value).toBe(7);
reset();
expect(count.value).toBe(5);
});
});
For composables that use lifecycle hooks (onMounted, etc.), wrap in a test component:
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { useUsers } from './useUsers';
it('fetches users on mount', async () => {
let result: ReturnType<typeof useUsers>;
mount(defineComponent({
setup() {
result = useUsers();
return () => null;
},
}));
await flushPromises();
expect(result!.users.value.length).toBeGreaterThan(0);
});
// vitest.setup.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { afterAll, afterEach, beforeAll } from 'vitest';
const server = setupServer(
http.get('/api/users', () => HttpResponse.json([{ id: '1', name: 'Alice' }])),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '2', ...body }, { status: 201 });
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
export { server };
Per-test override:
import { server } from '../vitest.setup';
import { http, HttpResponse } from 'msw';
server.use(http.get('/api/users', () => HttpResponse.error()));
import { mount } from '@vue/test-utils';
import { createRouter, createMemoryHistory } from 'vue-router';
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
{ path: '/users', component: { template: '<div>Users</div>' } },
],
});
it('navigates on click', async () => {
router.push('/');
await router.isReady();
const wrapper = mount(MyNav, {
global: { plugins: [router] },
});
await wrapper.find('a[href="/users"]').trigger('click');
await router.isReady();
expect(router.currentRoute.value.path).toBe('/users');
});
import { mount, flushPromises } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import LoginForm from './LoginForm.vue';
it('shows validation errors and submits valid data', async () => {
const wrapper = mount(LoginForm);
// Submit empty
await wrapper.find('form').trigger('submit.prevent');
await flushPromises();
expect(wrapper.text()).toContain('Invalid email');
// Fill correctly
await wrapper.find('input[type=email]').setValue('[email protected]');
await wrapper.find('input[type=password]').setValue('longenough');
await wrapper.find('form').trigger('submit.prevent');
await flushPromises();
expect(wrapper.emitted('login')).toBeTruthy();
});
flushPromises from @vue/test-utils waits for all pending Promises — necessary after async validation.
Slower but more realistic — runs actual browser DOM, native events, real CSS.
// cypress/component/UserCard.cy.ts
import UserCard from '@/components/UserCard.vue';
describe('UserCard', () => {
it('renders and emits delete', () => {
const onDelete = cy.spy().as('onDelete');
cy.mount(UserCard, {
props: { user: { id: '1', name: 'Alice', email: '[email protected]' } },
attrs: { onDelete },
});
cy.contains('Alice').should('be.visible');
cy.get('[data-testid=delete-btn]').click();
cy.get('@onDelete').should('have.been.calledWith', '1');
});
});
Setup: cypress.config.ts with component: { devServer: { framework: 'vue', bundler: 'vite' } }.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
use: { baseURL: 'http://localhost:5173', trace: 'on-first-retry' },
});
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/email/i).fill('[email protected]');
await page.getByLabel(/password/i).fill('longenough');
await page.getByRole('button', { name: /log in/i }).click();
await expect(page).toHaveURL('/dashboard');
});
Target ≥80% on:
lib/).Skip / lower bar:
App.vue / route layouts (mostly composition).main.ts.// vite.config.ts test block
test: {
coverage: {
exclude: [
'src/main.ts',
'src/App.vue',
'src/router/**',
'**/*.config.*',
'**/types/**',
],
},
}
The qa-engineer agent has a hard 3-attempt cap on fixing failing tests. After attempt #3, mark it.skip(...) with a comment and report in QA summary.
await before wrapper.find('input').setValue('x') — Vue's reactivity is async.wrapper.vm.someRef — test the public contract (props/emits/slots/rendered HTML).getByTestId everywhere instead of accessible queries.flushPromises() after async operations.setActivePinia(createPinia()) skipped in beforeEach — store state leaks between tests.mount with stubs that hide real bugs — use shallowMount deliberately, not as default.npx claudepluginhub aratkruglik/claude-sdlc --plugin vue-pluginReviews Vue 3 testing patterns with Vitest and Vue Test Utils for components, composables, Pinia stores, Vue Router, async/Suspense, Teleport, forms, accessibility, and Playwright E2E strategy. Advisory only.
Covers Vue.js testing best practices: Vitest, Vue Test Utils, Playwright E2E, and patterns for mocking, async, composables, and snapshots.
Provides Vue.js testing best practices covering Vitest, Vue Test Utils, component testing, mocking, and Playwright for E2E tests.