seed-skills/vue-test-utils/SKILL.md
Vue.js component testing using Vue Test Utils with mount/shallow mount, event simulation, Vuex/Pinia store testing, and composition API testing.
npx skillsauth add PramodDutta/qaskills Vue Test Utils TestingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill makes the agent write Vue 3 component tests with Vue Test Utils (VTU) + Vitest that assert on rendered output and user-observable behavior, not implementation internals. Trigger it whenever you see .vue SFCs, @vue/test-utils, mount/shallowMount, Pinia/Vuex stores under test, or a Vitest config in a Vue project.
mount over shallowMount. Full mount renders children so you test real behavior. Reach for shallowMount only to isolate a component from an expensive/irrelevant child — and know that stubbing children hides integration bugs.data-testid, not by CSS classes. Classes are styling and churn constantly; find('[data-testid="submit"]') and getByRole survive refactors and assert what users actually see.await every state change. Vue's DOM updates are asynchronous. After trigger, setValue, setProps, or a store mutation you must await wrapper.vm.$nextTick() (or await trigger(...), which returns nextTick) before asserting, or you assert against stale DOM.wrapper.emitted(), assert rendered text/attributes, and pass props. Do not assert on private data/refs or call internal methods.createTestingPinia, not hand-mocked stores. It gives you real getters, auto-spied actions, and initialState — far more faithful than stubbing the store object.fetch/axios at the module boundary with vi.mock; never let component tests hit a live API.npm install -D vitest @vue/test-utils@2 @vitest/coverage-v8 jsdom \
@pinia/testing pinia
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true, // describe/it/expect without imports
setupFiles: ['./tests/setup.ts'],
coverage: { provider: 'v8', reporter: ['text', 'html'] },
},
});
// tests/setup.ts
import { config } from '@vue/test-utils';
// Global stubs/plugins for every test, e.g. stub <RouterLink>
config.global.stubs = { RouterLink: { template: '<a><slot /></a>' } };
// Counter.vue exposes a button that increments a label
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import Counter from '@/components/Counter.vue';
describe('Counter', () => {
it('renders the initial count from a prop', () => {
const wrapper = mount(Counter, { props: { start: 3 } });
expect(wrapper.get('[data-testid="count"]').text()).toBe('3');
});
it('increments when the button is clicked', async () => {
const wrapper = mount(Counter, { props: { start: 0 } });
await wrapper.get('[data-testid="increment"]').trigger('click');
expect(wrapper.get('[data-testid="count"]').text()).toBe('1');
});
});
import { mount } from '@vue/test-utils';
import SearchBox from '@/components/SearchBox.vue';
it('emits "search" with the trimmed query on submit', async () => {
const wrapper = mount(SearchBox);
await wrapper.get('input[type="search"]').setValue(' vue testing ');
await wrapper.get('form').trigger('submit.prevent');
// emitted() returns arrays of arrays: one entry per emission
const events = wrapper.emitted('search');
expect(events).toHaveLength(1);
expect(events![0]).toEqual(['vue testing']);
});
it('disables the button while the query is empty', async () => {
const wrapper = mount(SearchBox);
const button = wrapper.get('button[type="submit"]');
expect(button.attributes('disabled')).toBeDefined();
await wrapper.get('input[type="search"]').setValue('x');
expect(button.attributes('disabled')).toBeUndefined();
});
import { mount } from '@vue/test-utils';
import Alert from '@/components/Alert.vue';
it('renders the danger variant and the default slot', () => {
const wrapper = mount(Alert, {
props: { variant: 'danger' },
slots: { default: 'Something broke' },
});
expect(wrapper.classes()).toContain('alert--danger');
expect(wrapper.text()).toContain('Something broke');
});
it('reacts to a prop change', async () => {
const wrapper = mount(Alert, { props: { variant: 'info' } });
await wrapper.setProps({ variant: 'danger' });
expect(wrapper.classes()).toContain('alert--danger');
});
it('hides itself when "open" is false', () => {
const wrapper = mount(Alert, { props: { open: false } });
expect(wrapper.find('[data-testid="alert"]').exists()).toBe(false);
});
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import CartSummary from '@/components/CartSummary.vue';
import { useCartStore } from '@/stores/cart';
it('shows the item count from the store and dispatches checkout', async () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn, // actions are auto-stubbed + spied
initialState: { cart: { items: [{ id: 1 }, { id: 2 }] } },
}),
],
},
});
expect(wrapper.get('[data-testid="count"]').text()).toBe('2');
const store = useCartStore();
await wrapper.get('[data-testid="checkout"]').trigger('click');
expect(store.checkout).toHaveBeenCalledOnce();
});
it('updates the DOM when a getter-backed value changes', async () => {
const wrapper = mount(CartSummary, {
global: { plugins: [createTestingPinia({ createSpy: vi.fn })] },
});
const store = useCartStore();
store.items.push({ id: 9 }); // mutate real state
await wrapper.vm.$nextTick();
expect(wrapper.get('[data-testid="count"]').text()).toBe('1');
});
import { mount, flushPromises } from '@vue/test-utils';
import { vi, beforeEach } from 'vitest';
import UserCard from '@/components/UserCard.vue';
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'Ada Lovelace' }),
} as Response);
});
it('renders a loading state then the fetched user', async () => {
const wrapper = mount(UserCard, { props: { userId: 1 } });
expect(wrapper.text()).toContain('Loading');
await flushPromises(); // resolve fetch + re-render
expect(wrapper.text()).toContain('Ada Lovelace');
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
it('shows an error message when the request fails', async () => {
(global.fetch as any).mockResolvedValueOnce({ ok: false, status: 500 });
const wrapper = mount(UserCard, { props: { userId: 1 } });
await flushPromises();
expect(wrapper.get('[data-testid="error"]').text()).toMatch(/failed/i);
});
Composables that use lifecycle hooks (onMounted) need a host component; pure ones can be called directly.
import { withSetup } from './withSetup'; // tiny mount helper
import { useCounter } from '@/composables/useCounter';
it('increments and exposes a reactive count', async () => {
const [result, app] = withSetup(() => useCounter(5));
expect(result.count.value).toBe(5);
result.increment();
expect(result.count.value).toBe(6);
app.unmount();
});
// tests/withSetup.ts — runs a composable inside a real app instance
import { createApp } from 'vue';
export function withSetup<T>(composable: () => T): [T, ReturnType<typeof createApp>] {
let result!: T;
const app = createApp({ setup() { result = composable(); return () => {}; } });
app.mount(document.createElement('div'));
return [result, app];
}
data-testid to elements you assert on. It decouples tests from markup/classes and makes intent explicit.wrapper.get() when an element must exist (it throws a clear error if missing) and wrapper.find().exists() when checking for absence.findComponent(ChildStub) with a name/ref over CSS selectors when asserting child props: wrapper.findComponent(ProductCard).props('price').vi.clearAllMocks() in afterEach (or clearMocks: true in config) so spy call counts don't leak.createTestingPinia({ stubActions: false }) when you need actions to actually run (e.g. testing a store-driven flow end to end).await after a state change. wrapper.trigger('click'); expect(...) asserts before Vue re-renders and gives flaky, confusing failures. Always await the trigger/$nextTick/flushPromises.wrapper.vm data or calling private methods. Tests coupled to implementation break on every refactor. Drive via the DOM and assert via the DOM/emitted events.find('.btn-primary') shatters the moment a designer renames a class. Use data-testid or roles.shallowMount everywhere. Stubbing all children means you never test that the pieces actually work together; bugs slip through the seams.createTestingPinia so getters compute and actions are spied for free.fetch/axios with vi.mock and stub RouterLink/router-view; otherwise tests are slow, flaky, and network-dependent..vue component" / "add unit tests for my Vue app"@vue/test-utils, mount, shallowMount, flushPromises, createTestingPiniaawait)development
Build WebdriverIO E2E suites — wdio.conf.ts setup, $ and $$ selectors, auto-wait and waitUntil, Mocha framework structure, page objects, parallel capabilities, and services for visual testing and Appium mobile.
testing
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.
testing
Write fast unit and integration tests with Vitest — vitest.config.ts setup, vi.fn and vi.mock module mocking, fake timers, snapshots, V8 coverage with thresholds, workspaces for monorepos, and in-source testing.
development
Practice strict red-green-refactor test-driven development — write one failing test first, make it pass with the minimum code, then refactor under green, with worked cycles in Jest and pytest, AAA structure, and behavior-based test naming.