name: "Vue Test Utils Testing" description: "Vue.js component testing using Vue Test Utils with mount/shallow mount, event simulation, Vuex/Pinia store testing, and composition API testing." version: 1.0.0 author: qaskills license: MIT tags: [vue, test-utils, component, pinia, vuex] testingTypes: [unit, integration] frameworks: [vue] languages: [typescript, javascript] domains: [web] agents: [claude-code, cursor, github-copilot, windsurf, codex, aider, continue, cline, zed, bolt]
Vue Test Utils Testing
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.
Core Principles
- Prefer
mountovershallowMount. Fullmountrenders children so you test real behavior. Reach forshallowMountonly to isolate a component from an expensive/irrelevant child — and know that stubbing children hides integration bugs. - Query by accessible roles and
data-testid, not by CSS classes. Classes are styling and churn constantly;find('[data-testid="submit"]')andgetByRolesurvive refactors and assert what users actually see. awaitevery state change. Vue's DOM updates are asynchronous. Aftertrigger,setValue,setProps, or a store mutation you mustawait wrapper.vm.$nextTick()(orawait trigger(...), which returns nextTick) before asserting, or you assert against stale DOM.- Test the component's contract: props in, events/DOM out. Assert emitted events with
wrapper.emitted(), assert rendered text/attributes, and pass props. Do not assert on privatedata/refs or call internal methods. - Use a real Pinia instance with
createTestingPinia, not hand-mocked stores. It gives you real getters, auto-spied actions, andinitialState— far more faithful than stubbing the store object. - Stub the network, render the component. Mock
fetch/axios at the module boundary withvi.mock; never let component tests hit a live API.
Setup
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>' } };
Patterns / Workflow
1. Render and assert on output
// 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');
});
});
2. Form input, v-model, and emitted events
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();
});
3. Props, slots, and conditional rendering
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);
});
4. Testing a component backed by Pinia
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');
});
5. Async component with a mocked fetch
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);
});
6. Testing a composable in isolation
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];
}
Best Practices
- Add
data-testidto elements you assert on. It decouples tests from markup/classes and makes intent explicit. - Use
wrapper.get()when an element must exist (it throws a clear error if missing) andwrapper.find().exists()when checking for absence. - Prefer
findComponent(ChildStub)with aname/refover CSS selectors when asserting child props:wrapper.findComponent(ProductCard).props('price'). - Reset mocks between tests with
vi.clearAllMocks()inafterEach(orclearMocks: truein config) so spy call counts don't leak. - Use
createTestingPinia({ stubActions: false })when you need actions to actually run (e.g. testing a store-driven flow end to end). - Test the rendered text/role a user would perceive, then layer in emitted-event assertions for the parent contract.
Anti-Patterns
- Forgetting
awaitafter a state change.wrapper.trigger('click'); expect(...)asserts before Vue re-renders and gives flaky, confusing failures. Alwaysawaitthe trigger/$nextTick/flushPromises. - Asserting on internal
wrapper.vmdata or calling private methods. Tests coupled to implementation break on every refactor. Drive via the DOM and assert via the DOM/emitted events. - Selecting by CSS class.
find('.btn-primary')shatters the moment a designer renames a class. Usedata-testidor roles. - Defaulting to
shallowMounteverywhere. Stubbing all children means you never test that the pieces actually work together; bugs slip through the seams. - Hand-rolling a fake store object. It drifts from the real store's getters/actions. Use
createTestingPiniaso getters compute and actions are spied for free. - Letting tests hit a real API or a real router. Mock
fetch/axios withvi.mockand stubRouterLink/router-view; otherwise tests are slow, flaky, and network-dependent.
When to Trigger This Skill
- "Write Vue component tests" / "test this
.vuecomponent" / "add unit tests for my Vue app" - Anything mentioning
@vue/test-utils,mount,shallowMount,flushPromises,createTestingPinia - "Test my Pinia / Vuex store" or "test this composable"
- "My Vue test is flaky / asserts the wrong DOM" (usually a missing
await) - Setting up Vitest + jsdom for a Vue 3 project