Vue testing utils
QA Skills Directory QA Skills is a curated directory of testing-specific skills for AI coding agents (Claude Code, Cursor, Copilot, etc.).
npx -y skills add PramodDutta/qaskills --skill vue-testing-utilsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
What its author says it does
Copied from the file, not written here
Test Vue 3 components with Vue Test Utils and Vitest — mount vs shallowMount, finding and triggering DOM, asserting props and emitted events, awaiting async updates, and mocking Pinia stores and Vue Router.
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
10.1 KB, ~2.4k tokens by cl100k_base, as published. Nobody here has run it
Vue Testing Utils
This skill makes an AI agent write Vue 3 component tests with @vue/test-utils on Vitest: mounting with props and slots, querying via data-testid, triggering events and awaiting the render queue, asserting emitted() payloads, and wiring createTestingPinia and mocked routers through global.plugins. Trigger it on any Vue 3 + Vite project where components need unit or integration tests.
Core Principles
- Default to
mount, reach forshallowMountrarely. Stubbing all children tests a skeleton, not the component. Shallow-render only when a child is genuinely heavy (charts, maps) — and stub that one child explicitly instead. - Test the rendered contract: props in, DOM and emitted events out. Never reach into
wrapper.vminternals or assert onrefvalues; those tests survive refactors only by accident. awaitevery interaction. Vue batches DOM updates;trigger,setValue, andsetPropsall return promises that resolve after the next tick. A missingawaitasserts against the stale DOM.- Use
data-testidor roles, not class selectors. Tailwind/scoped-CSS classes change with styling work; test IDs change only when behavior does. - Emitted events are the component's API — assert names and payloads.
wrapper.emitted('save')returns the calls array; check both that it fired and what it carried. - Real Pinia logic, fake server. With
createTestingPinia({ stubActions: false })plus mocked HTTP you test store-component integration honestly; stubbed actions are for pure-render tests only.
Setup
npm install --save-dev @vue/test-utils vitest jsdom @pinia/testing @vitejs/plugin-vue
// vitest.config.ts
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
restoreMocks: true,
},
});
A component worth testing:
<!-- src/components/QuantityPicker.vue -->
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{ modelValue: number; max?: number }>(), { max: 10 });
const emit = defineEmits<{ 'update:modelValue': [value: number] }>();
const atMax = computed(() => props.modelValue >= props.max);
function increment(): void {
if (!atMax.value) emit('update:modelValue', props.modelValue + 1);
}
</script>
<template>
<div>
<span data-testid="qty">{{ modelValue }}</span>
<button data-testid="inc" :disabled="atMax" @click="increment">+</button>
</div>
</template>
The test:
// src/components/QuantityPicker.test.ts
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import QuantityPicker from './QuantityPicker.vue';
describe('QuantityPicker', () => {
it('emits update:modelValue with the incremented quantity', async () => {
const wrapper = mount(QuantityPicker, { props: { modelValue: 2 } });
await wrapper.find('[data-testid="inc"]').trigger('click');
expect(wrapper.emitted('update:modelValue')).toEqual([[3]]);
});
it('disables the button at max and emits nothing on click', async () => {
const wrapper = mount(QuantityPicker, { props: { modelValue: 5, max: 5 } });
const button = wrapper.find('[data-testid="inc"]');
expect(button.attributes('disabled')).toBeDefined();
await button.trigger('click');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
});
it('re-renders when the parent updates the prop', async () => {
const wrapper = mount(QuantityPicker, { props: { modelValue: 1 } });
await wrapper.setProps({ modelValue: 7 });
expect(wrapper.get('[data-testid="qty"]').text()).toBe('7');
});
});
Patterns
Forms with setValue and v-model
import { mount } from '@vue/test-utils';
import { expect, it } from 'vitest';
import LoginForm from './LoginForm.vue';
it('submits trimmed credentials as the submit event payload', async () => {
const wrapper = mount(LoginForm);
await wrapper.get('[data-testid="email"]').setValue(' [email protected] ');
await wrapper.get('[data-testid="password"]').setValue('hunter2hunter2');
await wrapper.get('form').trigger('submit.prevent');
expect(wrapper.emitted('submit')).toEqual([
[{ email: '[email protected]', password: 'hunter2hunter2' }],
]);
});
Async Components: flushPromises after Mocked Fetch
import { flushPromises, mount } from '@vue/test-utils';
import { expect, it, vi } from 'vitest';
import SkillList from './SkillList.vue';
import * as api from '../api/skills';
it('renders fetched skills after the loading state', async () => {
vi.spyOn(api, 'fetchSkills').mockResolvedValue([
{ slug: 'vitest-testing', name: 'Vitest' },
{ slug: 'msw-mocking', name: 'MSW' },
]);
const wrapper = mount(SkillList);
expect(wrapper.find('[data-testid="spinner"]').exists()).toBe(true);
await flushPromises(); // resolves the fetch AND the subsequent render
expect(wrapper.find('[data-testid="spinner"]').exists()).toBe(false);
expect(wrapper.findAll('[data-testid="skill-row"]')).toHaveLength(2);
expect(wrapper.text()).toContain('Vitest');
});
Pinia Stores with createTestingPinia
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
import { expect, it, vi } from 'vitest';
import CartBadge from './CartBadge.vue';
import { useCartStore } from '../stores/cart';
it('shows the item count from the store and calls clear on click', async () => {
const wrapper = mount(CartBadge, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: { cart: { items: [{ sku: 'A1' }, { sku: 'B2' }] } },
}),
],
},
});
const store = useCartStore(); // the same instance the component uses
expect(wrapper.get('[data-testid="count"]').text()).toBe('2');
await wrapper.get('[data-testid="clear"]').trigger('click');
expect(store.clear).toHaveBeenCalledOnce(); // actions auto-stubbed as spies
});
Router: Mock It, Do Not Mount It
import { mount } from '@vue/test-utils';
import { expect, it, vi } from 'vitest';
import SkillCard from './SkillCard.vue';
it('navigates to the skill detail page on card click', async () => {
const push = vi.fn();
const wrapper = mount(SkillCard, {
props: { slug: 'supertest-api', name: 'SuperTest' },
global: {
mocks: { $router: { push } },
stubs: { RouterLink: { template: '<a><slot /></a>' } },
},
});
await wrapper.get('[data-testid="card"]').trigger('click');
expect(push).toHaveBeenCalledWith({ name: 'skill-detail', params: { slug: 'supertest-api' } });
});
Slots and Scoped Slots
import { mount } from '@vue/test-utils';
import { expect, it } from 'vitest';
import DataTable from './DataTable.vue';
it('renders the scoped row slot with each item', () => {
const wrapper = mount(DataTable, {
props: { items: [{ id: 1, name: 'alpha' }] },
slots: {
row: `<template #row="{ item }"><td data-testid="cell">{{ item.name }}</td></template>`,
},
});
expect(wrapper.get('[data-testid="cell"]').text()).toBe('alpha');
});
Best Practices
- Use
wrapper.get()when the element must exist (throws with a clear message);wrapper.find()+.exists()only for asserting absence. - Share mount defaults via a factory:
const factory = (props = {}) => mount(Comp, { props: { ...defaults, ...props } })— not via a mutable module-level wrapper. - Assert
emitted()payload equality on the whole calls array (toEqual([[3]])) to catch double-fires for free. - For components using
<Teleport>, target the teleport destination withdocument.querySelectoror stub teleport withglobal.stubs: { teleport: true }. - Test accessibility-relevant output:
attributes('aria-expanded'),attributes('disabled')— these are behavior, not styling. - Keep one component per test file mounted fresh per test;
restoreMocks: trueplus fresh mounts eliminates 90% of cross-test pollution.
Anti-Patterns
wrapper.vm.someRef = 5to set state. Mutating internals bypasses the component contract; drive state through props, interactions, or store initial state.- Missing
awaitontrigger/setValue/setProps. The assertion sees the previous DOM and passes or fails for the wrong reason. shallowMountas the default everywhere. Stub names in snapshots (<child-component-stub>) verify nothing about integration.- Mounting the full real router and
await router.isReady()for a unit test. Mock$router.pushinstead; real-router tests belong in a small dedicated navigation suite. - Asserting CSS classes as behavior (
expect(wrapper.classes()).toContain('text-red-500')). Assert the state that drives the class (aria-invalid, emitted validation event) instead. - One
beforeEachthat mounts with a kitchen-sink global config for every test in the file — slot, store, and router config should appear in the tests that need them.
When to Trigger This Skill
- A Vue 3 project needs component tests, or
@vue/test-utilsis indevDependencies. - The user asks how to test props, emits, v-model, slots, or async data fetching in a Vue component.
- Tests are flaky from missing
await/flushPromisesor pollute each other through shared wrappers. - Components depend on Pinia or Vue Router and the user needs them mocked or stubbed in tests.
- Migrating Vue 2 (
createLocalVue,propsData) tests to the Vue 3global/propsAPI.