--- name: sdlc-vue-testing description: | 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). paths: ["**/*.spec.{ts,js}", "**/*.test.{ts,js}", "tests/**"] --- # Vue 3 Testing Patterns ## Test framework selection | 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. ## Vitest setup `vite.config.ts`: ```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`: ```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 `shallowMount` ```ts import { mount, shallowMount } from '@vue/test-utils'; // mount renders ALL children real const wrapper = mount(MyComponent, { props: { name: 'Alice' } }); // shallowMount stubs ALL child components (renders ) 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. ## Component test (basics) ```ts // 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: 'a@b.c' } }, }); expect(wrapper.text()).toContain('Alice'); expect(wrapper.text()).toContain('a@b.c'); }); it('emits "delete" when delete button clicked', async () => { const wrapper = mount(UserCard, { props: { user: { id: '1', name: 'Alice', email: 'a@b.c' } }, }); 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: 'a@b.c' } }, slots: { header: '

Custom Header

' }, }); expect(wrapper.find('h2').text()).toBe('Custom Header'); }); }); ``` ### Common queries ```ts 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) ``` ### Triggering events ```ts 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) ```ts 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: 'a@b.c' } } }); 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. ## Testing Pinia stores ### Direct test ```ts 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); }); }); ``` ### Mocking stores in component tests ```bash pnpm add -D @pinia/testing ``` ```ts 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: 'a@b.c' }] }, }, 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. ## Testing composables Composables that don't touch DOM can be tested directly: ```ts 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: ```ts import { mount } from '@vue/test-utils'; import { defineComponent } from 'vue'; import { useUsers } from './useUsers'; it('fetches users on mount', async () => { let result: ReturnType; mount(defineComponent({ setup() { result = useUsers(); return () => null; }, })); await flushPromises(); expect(result!.users.value.length).toBeGreaterThan(0); }); ``` ## Network mocking with msw ```ts // 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: ```ts import { server } from '../vitest.setup'; import { http, HttpResponse } from 'msw'; server.use(http.get('/api/users', () => HttpResponse.error())); ``` ## Testing components with router ```ts import { mount } from '@vue/test-utils'; import { createRouter, createMemoryHistory } from 'vue-router'; const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: { template: '
Home
' } }, { path: '/users', component: { template: '
Users
' } }, ], }); 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'); }); ``` ## Testing forms (vee-validate) ```ts 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('a@b.c'); 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. ## Cypress component testing (in-browser) Slower but more realistic — runs actual browser DOM, native events, real CSS. ```ts // 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: 'a@b.c' } }, 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 e2e ```ts // 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' }, }); ``` ```ts // 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('a@b.c'); await page.getByLabel(/password/i).fill('longenough'); await page.getByRole('button', { name: /log in/i }).click(); await expect(page).toHaveURL('/dashboard'); }); ``` ## Coverage discipline Target ≥80% on: - Composables. - Pinia stores (state shape + actions). - Utility functions (`lib/`). - Components with logic (state, conditional rendering, event handling). Skip / lower bar: - Pure presentational components (snapshot churn). - `App.vue` / route layouts (mostly composition). - `main.ts`. ```ts // vite.config.ts test block test: { coverage: { exclude: [ 'src/main.ts', 'src/App.vue', 'src/router/**', '**/*.config.*', '**/types/**', ], }, } ``` ## Iteration cap (from QA agent) 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. ## Anti-patterns - ❌ Forgetting `await` before `wrapper.find('input').setValue('x')` — Vue's reactivity is async. - ❌ Asserting on internal state via `wrapper.vm.someRef` — test the public contract (props/emits/slots/rendered HTML). - ❌ Snapshot tests of large component trees — review noise. - ❌ Real network calls in unit tests (slow, flaky) — use msw. - ❌ `getByTestId` everywhere instead of accessible queries. - ❌ Forgetting `flushPromises()` after async operations. - ❌ E2E tests against real backend without seed data or mocked endpoints. - ❌ Mocking the SUT instead of its dependencies. - ❌ `setActivePinia(createPinia())` skipped in `beforeEach` — store state leaks between tests. - ❌ `mount` with stubs that hide real bugs — use `shallowMount` deliberately, not as default.