format: apply repo prettier (3.8.1) to the folded skills tree

963 markdown files reformatted with the repository's pinned prettier so
pnpm format:check covers the folded tree like every other repo file.

The formatter's embedded-language pass also normalized code fences
(TS semicolons, closed HTML tags in examples, lowercased CSS hex colors,
one renumbered list that skipped an index). Alphanumeric token deltas vs
the fold commit were audited file-by-file; all are formatter-equivalent
markup normalizations plus the four sanitized skills.
This commit is contained in:
fargo
2026-08-19 14:37:17 -05:00
parent d2eeb64433
commit 1a822493ba
962 changed files with 29594 additions and 27188 deletions
@@ -21,69 +21,63 @@ tags: [vue3, testing, async, defineAsyncComponent, flushPromises, vitest]
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import { defineAsyncComponent } from 'vue'
import { mount } from '@vue/test-utils';
import { defineAsyncComponent } from 'vue';
const AsyncWidget = defineAsyncComponent(() =>
import('./Widget.vue')
)
const AsyncWidget = defineAsyncComponent(() => import('./Widget.vue'));
test('renders async component', () => {
const wrapper = mount(AsyncWidget)
const wrapper = mount(AsyncWidget);
// FAILS: Component hasn't loaded yet
expect(wrapper.text()).toContain('Widget Content')
})
expect(wrapper.text()).toContain('Widget Content');
});
```
**Correct:**
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { defineAsyncComponent, nextTick } from 'vue'
import { mount, flushPromises } from '@vue/test-utils';
import { defineAsyncComponent, nextTick } from 'vue';
const AsyncWidget = defineAsyncComponent(() =>
import('./Widget.vue')
)
const AsyncWidget = defineAsyncComponent(() => import('./Widget.vue'));
test('renders async component', async () => {
const wrapper = mount(AsyncWidget)
const wrapper = mount(AsyncWidget);
// Wait for async component to load
await flushPromises()
await flushPromises();
expect(wrapper.text()).toContain('Widget Content')
})
expect(wrapper.text()).toContain('Widget Content');
});
test('shows loading state initially', async () => {
const AsyncWithLoading = defineAsyncComponent({
loader: () => import('./Widget.vue'),
loadingComponent: { template: '<div>Loading...</div>' },
delay: 0
})
delay: 0,
});
const wrapper = mount(AsyncWithLoading)
const wrapper = mount(AsyncWithLoading);
// Check loading state immediately
expect(wrapper.text()).toContain('Loading...')
expect(wrapper.text()).toContain('Loading...');
// Wait for component to load
await flushPromises()
await flushPromises();
// Check final state
expect(wrapper.text()).toContain('Widget Content')
})
expect(wrapper.text()).toContain('Widget Content');
});
```
## Testing with Suspense
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { Suspense, defineAsyncComponent, h } from 'vue'
import { mount, flushPromises } from '@vue/test-utils';
import { Suspense, defineAsyncComponent, h } from 'vue';
const AsyncWidget = defineAsyncComponent(() =>
import('./Widget.vue')
)
const AsyncWidget = defineAsyncComponent(() => import('./Widget.vue'));
test('renders async component with Suspense', async () => {
const wrapper = mount({
@@ -95,47 +89,47 @@ test('renders async component with Suspense', async () => {
<div>Loading...</div>
</template>
</Suspense>
`
})
`,
});
// Initially shows fallback
expect(wrapper.text()).toContain('Loading...')
expect(wrapper.text()).toContain('Loading...');
// Wait for async resolution
await flushPromises()
await flushPromises();
// Now shows actual content
expect(wrapper.text()).toContain('Widget Content')
})
expect(wrapper.text()).toContain('Widget Content');
});
```
## Testing Error States
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { defineAsyncComponent } from 'vue'
import { mount, flushPromises } from '@vue/test-utils';
import { defineAsyncComponent } from 'vue';
test('shows error component on load failure', async () => {
const AsyncWithError = defineAsyncComponent({
loader: () => Promise.reject(new Error('Failed to load')),
errorComponent: { template: '<div>Error loading component</div>' }
})
errorComponent: { template: '<div>Error loading component</div>' },
});
const wrapper = mount(AsyncWithError)
const wrapper = mount(AsyncWithError);
await flushPromises()
await flushPromises();
expect(wrapper.text()).toContain('Error loading component')
})
expect(wrapper.text()).toContain('Error loading component');
});
```
## Utilities Reference
| Utility | Purpose |
|---------|---------|
| `await flushPromises()` | Resolves all pending promises |
| `await nextTick()` | Waits for Vue's next DOM update cycle |
| `await wrapper.trigger('click')` | Triggers event and waits for update |
| Utility | Purpose |
| -------------------------------- | ------------------------------------- |
| `await flushPromises()` | Resolves all pending promises |
| `await nextTick()` | Waits for Vue's next DOM update cycle |
| `await wrapper.trigger('click')` | Triggers event and waits for update |
## Dynamic Import Handling
@@ -149,12 +143,12 @@ test('shows error component on load failure', async () => {
```javascript
// If flushPromises() isn't sufficient, mock the import
vi.mock('./Widget.vue', () => ({
default: { template: '<div>Widget Content</div>' }
}))
default: { template: '<div>Widget Content</div>' },
}));
// Or use multiple flush calls for nested async operations
await flushPromises()
await flushPromises()
await flushPromises();
await flushPromises();
```
## References
@@ -17,6 +17,7 @@ tags: [vue3, teleport, testing, vue-test-utils]
- [ ] Consider using `getComponent()` instead of DOM queries for teleported components
**Problem - Standard Testing Fails:**
```vue
<!-- Modal.vue -->
<template>
@@ -31,91 +32,94 @@ tags: [vue3, teleport, testing, vue-test-utils]
```ts
// Modal.spec.ts - BROKEN
import { mount } from '@vue/test-utils'
import Modal from './Modal.vue'
import { mount } from '@vue/test-utils';
import Modal from './Modal.vue';
test('modal input exists', async () => {
const wrapper = mount(Modal)
await wrapper.find('button').trigger('click')
const wrapper = mount(Modal);
await wrapper.find('button').trigger('click');
// FAILS: Teleported content is not in wrapper's DOM tree
expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true)
})
expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true);
});
```
**Solution 1 - Stub Teleport:**
```ts
import { mount } from '@vue/test-utils'
import Modal from './Modal.vue'
import { mount } from '@vue/test-utils';
import Modal from './Modal.vue';
test('modal input exists', async () => {
const wrapper = mount(Modal, {
global: {
stubs: {
// Stub teleport to render content inline
Teleport: true
}
}
})
Teleport: true,
},
},
});
await wrapper.find('button').trigger('click')
await wrapper.find('button').trigger('click');
// Works: Content renders inside wrapper
expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true)
})
expect(wrapper.find('[data-testid="modal-input"]').exists()).toBe(true);
});
```
**Solution 2 - Query Document Body:**
```ts
import { mount } from '@vue/test-utils'
import Modal from './Modal.vue'
import { mount } from '@vue/test-utils';
import Modal from './Modal.vue';
test('modal renders to body', async () => {
const wrapper = mount(Modal, {
attachTo: document.body // Required for Teleport to work
})
attachTo: document.body, // Required for Teleport to work
});
await wrapper.find('button').trigger('click')
await wrapper.find('button').trigger('click');
// Query the actual DOM
const modal = document.querySelector('[data-testid="modal"]')
expect(modal).toBeTruthy()
const modal = document.querySelector('[data-testid="modal"]');
expect(modal).toBeTruthy();
const input = document.querySelector('[data-testid="modal-input"]')
expect(input).toBeTruthy()
const input = document.querySelector('[data-testid="modal-input"]');
expect(input).toBeTruthy();
// Cleanup
wrapper.unmount()
})
wrapper.unmount();
});
```
**Solution 3 - Custom Teleport Stub with Content Access:**
```ts
import { mount, config } from '@vue/test-utils'
import { h, Teleport } from 'vue'
import Modal from './Modal.vue'
import { mount, config } from '@vue/test-utils';
import { h, Teleport } from 'vue';
import Modal from './Modal.vue';
// Custom stub that renders content in a testable way
const TeleportStub = {
setup(props, { slots }) {
return () => h('div', { class: 'teleport-stub' }, slots.default?.())
}
}
return () => h('div', { class: 'teleport-stub' }, slots.default?.());
},
};
test('modal with custom stub', async () => {
const wrapper = mount(Modal, {
global: {
stubs: {
Teleport: TeleportStub
}
}
})
Teleport: TeleportStub,
},
},
});
await wrapper.find('button').trigger('click')
await wrapper.find('button').trigger('click');
// Content is inside .teleport-stub
expect(wrapper.find('.teleport-stub [data-testid="modal-input"]').exists()).toBe(true)
})
expect(wrapper.find('.teleport-stub [data-testid="modal-input"]').exists()).toBe(true);
});
```
## Testing Vue Final Modal and UI Libraries
@@ -124,18 +128,18 @@ Libraries like Vue Final Modal use Teleport internally, causing test failures:
```ts
// Problem: Vue Final Modal teleports to body
import { VueFinalModal } from 'vue-final-modal'
import { VueFinalModal } from 'vue-final-modal';
test('modal content', async () => {
const wrapper = mount(MyComponent, {
global: {
stubs: {
// Stub the modal component to avoid teleport issues
VueFinalModal: true
}
}
})
})
VueFinalModal: true,
},
},
});
});
```
## E2E Testing (Cypress, Playwright)
@@ -145,14 +149,15 @@ E2E tests query the real DOM, so Teleport works naturally:
```ts
// Cypress
it('opens modal', () => {
cy.visit('/page-with-modal')
cy.get('button').click()
cy.visit('/page-with-modal');
cy.get('button').click();
// Works: Cypress queries the real DOM
cy.get('[data-testid="modal"]').should('be.visible')
})
cy.get('[data-testid="modal"]').should('be.visible');
});
```
## Reference
- [Vue Test Utils - Teleport](https://test-utils.vuejs.org/guide/advanced/teleport)
- [Vue Test Utils - Stubs](https://test-utils.vuejs.org/guide/advanced/stubs-shallow-mount)
@@ -21,155 +21,163 @@ Use `await` with triggers and `setValue`, use `nextTick` for reactive updates, a
- [ ] Consider using `waitFor` from testing-library for polling assertions
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import SearchComponent from './SearchComponent.vue'
import { mount } from '@vue/test-utils';
import SearchComponent from './SearchComponent.vue';
// BAD: Not awaiting trigger - assertion runs before DOM updates
test('search filters results', () => {
const wrapper = mount(SearchComponent)
const wrapper = mount(SearchComponent);
wrapper.find('input').setValue('vue') // Missing await!
wrapper.find('button').trigger('click') // Missing await!
wrapper.find('input').setValue('vue'); // Missing await!
wrapper.find('button').trigger('click'); // Missing await!
// This assertion likely fails - DOM hasn't updated yet
expect(wrapper.findAll('.result').length).toBe(3)
})
expect(wrapper.findAll('.result').length).toBe(3);
});
// BAD: Using nextTick for API calls
test('loads data from API', async () => {
const wrapper = mount(DataLoader)
const wrapper = mount(DataLoader);
await nextTick() // This won't wait for the API call!
await nextTick(); // This won't wait for the API call!
// Assertion runs before fetch completes
expect(wrapper.find('.data').text()).toBe('Loaded data')
})
expect(wrapper.find('.data').text()).toBe('Loaded data');
});
```
**Correct:**
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import SearchComponent from './SearchComponent.vue'
import DataLoader from './DataLoader.vue'
import { mount, flushPromises } from '@vue/test-utils';
import { nextTick } from 'vue';
import SearchComponent from './SearchComponent.vue';
import DataLoader from './DataLoader.vue';
// CORRECT: Await trigger and setValue
test('search filters results', async () => {
const wrapper = mount(SearchComponent)
const wrapper = mount(SearchComponent);
await wrapper.find('input').setValue('vue')
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('vue');
await wrapper.find('button').trigger('click');
expect(wrapper.findAll('.result').length).toBe(3)
})
expect(wrapper.findAll('.result').length).toBe(3);
});
// CORRECT: Use flushPromises for API calls
test('loads data from API', async () => {
const wrapper = mount(DataLoader)
const wrapper = mount(DataLoader);
// Wait for all pending promises to resolve
await flushPromises()
await flushPromises();
expect(wrapper.find('.data').text()).toBe('Loaded data')
})
expect(wrapper.find('.data').text()).toBe('Loaded data');
});
```
## When to Use Each Method
### `await trigger()` / `await setValue()` - User Interactions
```javascript
// These methods return nextTick internally
await wrapper.find('button').trigger('click')
await wrapper.find('input').setValue('new value')
await wrapper.find('form').trigger('submit')
await wrapper.find('button').trigger('click');
await wrapper.find('input').setValue('new value');
await wrapper.find('form').trigger('submit');
```
### `await nextTick()` - Programmatic Reactive Updates
```javascript
import { nextTick } from 'vue'
import { nextTick } from 'vue';
test('reflects programmatic state changes', async () => {
const wrapper = mount(Counter)
const wrapper = mount(Counter);
// Direct state modification (when testing with exposed internals)
wrapper.vm.count = 5
wrapper.vm.count = 5;
await nextTick() // Wait for Vue to update DOM
await nextTick(); // Wait for Vue to update DOM
expect(wrapper.find('.count').text()).toBe('5')
})
expect(wrapper.find('.count').text()).toBe('5');
});
```
### `await flushPromises()` - External Async Operations
```javascript
import { flushPromises } from '@vue/test-utils'
import { flushPromises } from '@vue/test-utils';
test('displays fetched data', async () => {
const wrapper = mount(UserProfile, {
props: { userId: 1 }
})
props: { userId: 1 },
});
// Wait for component's API call to complete
await flushPromises()
await flushPromises();
expect(wrapper.find('.username').text()).toBe('John')
})
expect(wrapper.find('.username').text()).toBe('John');
});
// Sometimes you need multiple flushPromises for chained async operations
test('processes data after fetch', async () => {
const wrapper = mount(DataProcessor)
const wrapper = mount(DataProcessor);
await flushPromises() // Wait for fetch
await flushPromises() // Wait for processing triggered by fetch
await flushPromises(); // Wait for fetch
await flushPromises(); // Wait for processing triggered by fetch
expect(wrapper.find('.processed').exists()).toBe(true)
})
expect(wrapper.find('.processed').exists()).toBe(true);
});
```
## Common Pattern: Combining Methods
```javascript
test('submits form and shows success', async () => {
const wrapper = mount(ContactForm)
const wrapper = mount(ContactForm);
// Fill form (awaiting each interaction)
await wrapper.find('#name').setValue('John')
await wrapper.find('#email').setValue('[email protected]')
await wrapper.find('#name').setValue('John');
await wrapper.find('#email').setValue('[email protected]');
// Submit form
await wrapper.find('form').trigger('submit')
await wrapper.find('form').trigger('submit');
// Wait for API submission to complete
await flushPromises()
await flushPromises();
// Assert success state
expect(wrapper.find('.success-message').exists()).toBe(true)
})
expect(wrapper.find('.success-message').exists()).toBe(true);
});
```
## Testing with MSW or Mock APIs
```javascript
import { flushPromises } from '@vue/test-utils'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { flushPromises } from '@vue/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.json({ name: 'John' }))
})
)
return res(ctx.json({ name: 'John' }));
}),
);
test('displays user data', async () => {
const wrapper = mount(UserCard)
const wrapper = mount(UserCard);
// MSW might require multiple flushPromises
await flushPromises()
await flushPromises()
await flushPromises();
await flushPromises();
expect(wrapper.find('.name').text()).toBe('John')
})
expect(wrapper.find('.name').text()).toBe('John');
});
```
## Reference
- [Vue Test Utils - Asynchronous Behavior](https://test-utils.vuejs.org/guide/advanced/async-suspense)
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
@@ -23,7 +23,9 @@ Use Vitest for most component tests (fast), but use Vitest Browser Mode when tes
## When to Use Each Approach
### Node-Based Runner (Vitest + happy-dom/jsdom)
Best for:
- Pure logic testing
- State management
- Event emission
@@ -35,22 +37,24 @@ Best for:
// vitest.config.js
export default defineConfig({
test: {
environment: 'happy-dom', // or 'jsdom'
}
})
environment: 'happy-dom', // or 'jsdom'
},
});
```
```javascript
// Fast but limited - fine for most tests
test('button emits click event', async () => {
const wrapper = mount(Button)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
const wrapper = mount(Button);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeTruthy();
});
```
### Vitest Browser Mode
Required for:
- CSS computed styles verification
- CSS transitions/animations
- Real focus/blur behavior
@@ -67,7 +71,7 @@ npm install -D @vitest/browser playwright
```javascript
// vitest.config.js
import { defineConfig } from 'vitest/config'
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
@@ -77,98 +81,101 @@ export default defineConfig({
provider: 'playwright',
},
},
})
});
```
```javascript
// Button.browser.test.js
import { render } from 'vitest-browser-vue'
import Button from './Button.vue'
import { render } from 'vitest-browser-vue';
import Button from './Button.vue';
test('has correct hover styling', async () => {
const { getByRole } = render(Button, { props: { label: 'Click me' } })
const { getByRole } = render(Button, { props: { label: 'Click me' } });
const button = getByRole('button')
const button = getByRole('button');
// Check initial style
await expect.element(button).toHaveStyle({
backgroundColor: 'rgb(59, 130, 246)' // blue
})
})
backgroundColor: 'rgb(59, 130, 246)', // blue
});
});
test('maintains focus after click', async () => {
const { getByRole } = render(Button)
const { getByRole } = render(Button);
const button = getByRole('button')
await button.click()
const button = getByRole('button');
await button.click();
await expect.element(button).toHaveFocus()
})
await expect.element(button).toHaveFocus();
});
```
## Examples: What Each Runner Can/Cannot Test
### Styles - Browser Required
```javascript
// Node runner: CANNOT verify actual CSS
test('danger button has red background', () => {
const wrapper = mount(Button, { props: { variant: 'danger' } })
const wrapper = mount(Button, { props: { variant: 'danger' } });
// This only checks class exists, not actual color
expect(wrapper.classes()).toContain('bg-red-500')
})
expect(wrapper.classes()).toContain('bg-red-500');
});
// Vitest Browser Mode: CAN verify computed styles
test('danger button renders red', async () => {
const { getByRole } = render(Button, { props: { variant: 'danger' } })
const { getByRole } = render(Button, { props: { variant: 'danger' } });
await expect.element(getByRole('button')).toHaveStyle({
backgroundColor: 'rgb(239, 68, 68)'
})
})
backgroundColor: 'rgb(239, 68, 68)',
});
});
```
### Computed CSS Styles - Browser Required
```javascript
// Node runner: CANNOT get real computed styles
test('button has correct padding', () => {
const wrapper = mount(Button)
const wrapper = mount(Button);
// getComputedStyle returns empty/default values in jsdom
const style = window.getComputedStyle(wrapper.element)
const style = window.getComputedStyle(wrapper.element);
// style.padding will be empty string, not actual computed value
})
});
// Vitest Browser Mode: Real computed styles
test('button has correct padding', async () => {
const { getByRole } = render(Button)
const button = getByRole('button')
const { getByRole } = render(Button);
const button = getByRole('button');
await expect.element(button).toHaveStyle({
padding: '12px 24px'
})
})
padding: '12px 24px',
});
});
```
### Native Events - Browser Required
```javascript
// Node runner: Synthetic events only
test('handles drag and drop', async () => {
const wrapper = mount(DraggableList)
const wrapper = mount(DraggableList);
// trigger('dragstart') is synthetic - may not work as expected
await wrapper.find('.item').trigger('dragstart')
})
await wrapper.find('.item').trigger('dragstart');
});
// Vitest Browser Mode: Real native events via userEvent
import { userEvent } from '@vitest/browser/context'
import { userEvent } from '@vitest/browser/context';
test('reorders items on drag', async () => {
const { getByTestId } = render(DraggableList)
const { getByTestId } = render(DraggableList);
const item = getByTestId('item-1')
const target = getByTestId('item-3')
const item = getByTestId('item-1');
const target = getByTestId('item-3');
await userEvent.dragAndDrop(item, target)
await userEvent.dragAndDrop(item, target);
// Assert reordering
})
});
```
## Recommended Testing Strategy
@@ -184,13 +191,14 @@ export default defineConfig({
// Browser tests in separate directory
include: ['src/**/*.test.{js,ts}'],
},
})
});
// Run browser tests separately
// npx vitest --browser.enabled
```
### Directory Structure
```
tests/
├── unit/ # Fast node-based tests
@@ -204,5 +212,6 @@ tests/
```
## Reference
- [Vue.js Testing - Component Testing](https://vuejs.org/guide/scaling-up/testing#component-testing)
- [Vitest Browser Mode](https://vitest.dev/guide/browser.html)
@@ -22,114 +22,118 @@ Follow Kent C. Dodds' testing philosophy: "The more your tests resemble how your
- [ ] Use data-testid attributes for elements without semantic meaning
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
// BAD: Testing implementation details
test('counter increments', async () => {
const wrapper = mount(Counter)
const wrapper = mount(Counter);
// Accessing internal state directly
expect(wrapper.vm.count).toBe(0)
expect(wrapper.vm.count).toBe(0);
// Calling internal method instead of simulating user action
wrapper.vm.increment()
wrapper.vm.increment();
// Checking internal state instead of visible output
expect(wrapper.vm.count).toBe(1)
})
expect(wrapper.vm.count).toBe(1);
});
// BAD: Testing component structure
test('has increment button', () => {
const wrapper = mount(Counter)
const wrapper = mount(Counter);
// Testing implementation detail - what if button becomes an anchor?
expect(wrapper.find('button').exists()).toBe(true)
})
expect(wrapper.find('button').exists()).toBe(true);
});
```
**Correct:**
```javascript
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
// CORRECT: Testing behavior like a user would
test('counter displays updated value after clicking increment', async () => {
const wrapper = mount(Counter, {
props: { max: 10 }
})
props: { max: 10 },
});
// Assert initial visible state
expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('0')
expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('0');
// Simulate user action
await wrapper.find('[data-testid="increment-button"]').trigger('click')
await wrapper.find('[data-testid="increment-button"]').trigger('click');
// Assert visible result
expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('1')
})
expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('1');
});
// CORRECT: Testing emitted events (public API)
test('emits change event with new value when incremented', async () => {
const wrapper = mount(Counter)
const wrapper = mount(Counter);
await wrapper.find('[data-testid="increment-button"]').trigger('click')
await wrapper.find('[data-testid="increment-button"]').trigger('click');
expect(wrapper.emitted('change')).toHaveLength(1)
expect(wrapper.emitted('change')[0]).toEqual([1])
})
expect(wrapper.emitted('change')).toHaveLength(1);
expect(wrapper.emitted('change')[0]).toEqual([1]);
});
```
## Using @testing-library/vue for Better Blackbox Tests
```javascript
import { render, screen, fireEvent } from '@testing-library/vue'
import Counter from './Counter.vue'
import { render, screen, fireEvent } from '@testing-library/vue';
import Counter from './Counter.vue';
// Testing Library encourages accessible, user-centric queries
test('increments counter on button click', async () => {
render(Counter)
render(Counter);
// Query by role - how screen readers see it
const button = screen.getByRole('button', { name: /increment/i })
const display = screen.getByText('0')
const button = screen.getByRole('button', { name: /increment/i });
const display = screen.getByText('0');
await fireEvent.click(button)
await fireEvent.click(button);
expect(screen.getByText('1')).toBeInTheDocument()
})
expect(screen.getByText('1')).toBeInTheDocument();
});
```
## What to Test vs What Not to Test
### DO Test (Public Interface)
```javascript
// Props affect rendered output
test('shows title from props', () => {
const wrapper = mount(Card, {
props: { title: 'Hello World' }
})
expect(wrapper.text()).toContain('Hello World')
})
props: { title: 'Hello World' },
});
expect(wrapper.text()).toContain('Hello World');
});
// Slots render correctly
test('renders slot content', () => {
const wrapper = mount(Card, {
slots: { default: '<p>Slot content</p>' }
})
expect(wrapper.text()).toContain('Slot content')
})
slots: { default: '<p>Slot content</p>' },
});
expect(wrapper.text()).toContain('Slot content');
});
// Emitted events
test('emits close event when X clicked', async () => {
const wrapper = mount(Modal)
await wrapper.find('[data-testid="close-button"]').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
const wrapper = mount(Modal);
await wrapper.find('[data-testid="close-button"]').trigger('click');
expect(wrapper.emitted('close')).toBeTruthy();
});
```
### DON'T Test (Implementation Details)
```javascript
// Don't test internal computed properties
// Don't test internal methods
@@ -139,6 +143,7 @@ test('emits close event when X clicked', async () => {
```
## Reference
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
- [Vue Test Utils - Testing Philosophy](https://test-utils.vuejs.org/guide/)
- [Testing Library Guiding Principles](https://testing-library.com/docs/guiding-principles)
@@ -21,218 +21,223 @@ Simple composables using only reactivity APIs can be tested directly. Complex co
- [ ] Use `app.provide()` to mock injected dependencies
**Simple Composable - Test Directly:**
```javascript
// composables/useCounter.js
import { ref, computed } from 'vue'
import { ref, computed } from 'vue';
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
const increment = () => count.value++
const count = ref(initialValue);
const doubled = computed(() => count.value * 2);
const increment = () => count.value++;
return { count, doubled, increment }
return { count, doubled, increment };
}
```
```javascript
// useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
// CORRECT: Simple composable can be tested directly
describe('useCounter', () => {
it('initializes with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
const { count } = useCounter();
expect(count.value).toBe(0);
});
it('increments count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
it('computes doubled value', () => {
const { count, doubled, increment } = useCounter(5)
expect(doubled.value).toBe(10)
increment()
expect(doubled.value).toBe(12)
})
})
const { count, doubled, increment } = useCounter(5);
expect(doubled.value).toBe(10);
increment();
expect(doubled.value).toBe(12);
});
});
```
**Complex Composable - Use Host Wrapper:**
```javascript
// composables/useFetch.js
import { ref, onMounted, onUnmounted, inject } from 'vue'
import { ref, onMounted, onUnmounted, inject } from 'vue';
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(true)
let controller = null
const data = ref(null);
const error = ref(null);
const loading = ref(true);
let controller = null;
// Uses inject - needs component context
const apiClient = inject('apiClient')
const apiClient = inject('apiClient');
// Uses lifecycle hooks - needs component context
onMounted(async () => {
controller = new AbortController()
controller = new AbortController();
try {
const response = await apiClient.get(url, { signal: controller.signal })
data.value = response.data
const response = await apiClient.get(url, { signal: controller.signal });
data.value = response.data;
} catch (e) {
if (e.name !== 'AbortError') error.value = e
if (e.name !== 'AbortError') error.value = e;
} finally {
loading.value = false
loading.value = false;
}
})
});
onUnmounted(() => {
controller?.abort()
})
controller?.abort();
});
return { data, error, loading }
return { data, error, loading };
}
```
```javascript
// test-utils.js
import { createApp } from 'vue'
import { createApp } from 'vue';
/**
* Helper to test composables that need component context
*/
export function withSetup(composable) {
let result
let result;
const app = createApp({
setup() {
result = composable()
result = composable();
// Return a render function to suppress warnings
return () => {}
}
})
return () => {};
},
});
app.mount(document.createElement('div'))
app.mount(document.createElement('div'));
return [result, app]
return [result, app];
}
```
```javascript
// useFetch.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { withSetup } from './test-utils'
import { useFetch } from './useFetch'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushPromises } from '@vue/test-utils';
import { withSetup } from './test-utils';
import { useFetch } from './useFetch';
describe('useFetch', () => {
let app
let app;
const mockApiClient = {
get: vi.fn()
}
get: vi.fn(),
};
afterEach(() => {
// IMPORTANT: Clean up to trigger onUnmounted
app?.unmount()
})
app?.unmount();
});
it('fetches data on mount', async () => {
mockApiClient.get.mockResolvedValue({ data: { id: 1, name: 'Test' } })
mockApiClient.get.mockResolvedValue({ data: { id: 1, name: 'Test' } });
const [result, testApp] = withSetup(() => useFetch('/api/test'))
app = testApp
const [result, testApp] = withSetup(() => useFetch('/api/test'));
app = testApp;
// Provide mocked dependency
app.provide('apiClient', mockApiClient)
app.provide('apiClient', mockApiClient);
// Wait for async operations
await flushPromises()
await flushPromises();
expect(result.data.value).toEqual({ id: 1, name: 'Test' })
expect(result.loading.value).toBe(false)
expect(result.error.value).toBeNull()
})
expect(result.data.value).toEqual({ id: 1, name: 'Test' });
expect(result.loading.value).toBe(false);
expect(result.error.value).toBeNull();
});
it('handles errors', async () => {
const testError = new Error('Network error')
mockApiClient.get.mockRejectedValue(testError)
const testError = new Error('Network error');
mockApiClient.get.mockRejectedValue(testError);
const [result, testApp] = withSetup(() => useFetch('/api/test'))
app = testApp
app.provide('apiClient', mockApiClient)
const [result, testApp] = withSetup(() => useFetch('/api/test'));
app = testApp;
app.provide('apiClient', mockApiClient);
await flushPromises()
await flushPromises();
expect(result.error.value).toBe(testError)
expect(result.data.value).toBeNull()
})
})
expect(result.error.value).toBe(testError);
expect(result.data.value).toBeNull();
});
});
```
## Enhanced withSetup Helper with Provide Support
```javascript
// test-utils.js
export function withSetup(composable, options = {}) {
let result
let result;
const app = createApp({
setup() {
result = composable()
return () => {}
}
})
result = composable();
return () => {};
},
});
// Apply global provides before mounting
if (options.provide) {
Object.entries(options.provide).forEach(([key, value]) => {
app.provide(key, value)
})
app.provide(key, value);
});
}
app.mount(document.createElement('div'))
app.mount(document.createElement('div'));
return [result, app]
return [result, app];
}
// Usage
const [result, app] = withSetup(() => useMyComposable(), {
provide: {
apiClient: mockApiClient,
currentUser: { id: 1, name: 'Test User' }
}
})
currentUser: { id: 1, name: 'Test User' },
},
});
```
## Testing with @vue/test-utils mount
```javascript
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { useFetch } from './useFetch'
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { useFetch } from './useFetch';
test('useFetch in component context', async () => {
const TestComponent = defineComponent({
setup() {
const { data, loading } = useFetch('/api/users')
return { data, loading }
const { data, loading } = useFetch('/api/users');
return { data, loading };
},
template: '<div>{{ loading ? "Loading..." : data }}</div>'
})
template: '<div>{{ loading ? "Loading..." : data }}</div>',
});
const wrapper = mount(TestComponent, {
global: {
provide: {
apiClient: mockApiClient
}
}
})
apiClient: mockApiClient,
},
},
});
await flushPromises()
expect(wrapper.text()).toContain('Test data')
})
await flushPromises();
expect(wrapper.text()).toContain('Test data');
});
```
## Reference
- [Vue.js Testing Guide - Testing Composables](https://vuejs.org/guide/scaling-up/testing#testing-composables)
- [Vue Test Utils - Mounting Components](https://test-utils.vuejs.org/guide/)
@@ -34,8 +34,9 @@ npm init playwright@latest
```
**playwright.config.ts:**
```typescript
import { defineConfig, devices } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
@@ -80,138 +81,138 @@ export default defineConfig({
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})
});
```
## E2E Test Example
```typescript
// e2e/user-flow.spec.ts
import { test, expect } from '@playwright/test'
import { test, expect } from '@playwright/test';
test.describe('User Authentication', () => {
test('user can log in and see dashboard', async ({ page }) => {
// Navigate to login
await page.goto('/login')
await page.goto('/login');
// Fill login form
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign In' }).click()
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign In' }).click();
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard')
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible()
})
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('wrongpassword')
await page.getByRole('button', { name: 'Sign In' }).click()
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByRole('alert')).toContainText('Invalid credentials')
await expect(page).toHaveURL('/login')
})
})
await expect(page.getByRole('alert')).toContainText('Invalid credentials');
await expect(page).toHaveURL('/login');
});
});
```
## Playwright vs Cypress Comparison
| Feature | Playwright | Cypress |
|---------|------------|---------|
| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox, Electron (WebKit experimental) |
| Cross-browser | Full support | Limited |
| Parallelization | Built-in | Requires Cypress Cloud |
| Open source | Fully | Core only |
| Mobile testing | Device emulation | Limited |
| Debugging | Inspector, trace viewer | Time-travel UI |
| API testing | Built-in | Plugin required |
| Iframes | Full support | Limited |
| Feature | Playwright | Cypress |
| --------------- | ------------------------- | ------------------------------------------------- |
| Browsers | Chromium, Firefox, WebKit | Chromium, Firefox, Electron (WebKit experimental) |
| Cross-browser | Full support | Limited |
| Parallelization | Built-in | Requires Cypress Cloud |
| Open source | Fully | Core only |
| Mobile testing | Device emulation | Limited |
| Debugging | Inspector, trace viewer | Time-travel UI |
| API testing | Built-in | Plugin required |
| Iframes | Full support | Limited |
## Testing Vue Components with Data-Testid
```typescript
// e2e/product-list.spec.ts
import { test, expect } from '@playwright/test'
import { test, expect } from '@playwright/test';
test('user can add product to cart', async ({ page }) => {
await page.goto('/products')
await page.goto('/products');
// Use data-testid for reliable selectors
await page.getByTestId('product-card').first().click()
await page.getByTestId('product-card').first().click();
// Verify product detail page
await expect(page.getByTestId('product-title')).toBeVisible()
await expect(page.getByTestId('product-title')).toBeVisible();
// Add to cart
await page.getByTestId('add-to-cart-button').click()
await page.getByTestId('add-to-cart-button').click();
// Verify cart updated
await expect(page.getByTestId('cart-count')).toHaveText('1')
})
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
```
## Page Object Pattern for Vue Apps
```typescript
// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test'
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.submitButton = page.getByRole('button', { name: 'Sign In' })
this.errorMessage = page.getByRole('alert')
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign In' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login')
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
```
```typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('successful login', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('[email protected]', 'password123')
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
await expect(page).toHaveURL('/dashboard')
})
await expect(page).toHaveURL('/dashboard');
});
```
## Visual Regression Testing
```typescript
test('homepage visual regression', async ({ page }) => {
await page.goto('/')
await page.goto('/');
// Full page screenshot comparison
await expect(page).toHaveScreenshot('homepage.png')
await expect(page).toHaveScreenshot('homepage.png');
// Element-specific screenshot
await expect(page.getByTestId('hero-section')).toHaveScreenshot('hero.png')
})
await expect(page.getByTestId('hero-section')).toHaveScreenshot('hero.png');
});
```
## Running Tests
@@ -237,6 +238,7 @@ npx playwright codegen localhost:5173
```
## Reference
- [Playwright Documentation](https://playwright.dev/)
- [Vue.js E2E Testing Recommendations](https://vuejs.org/guide/scaling-up/testing#e2e-testing)
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)
@@ -22,18 +22,19 @@ Use snapshots sparingly for regression detection. Prefer behavioral assertions t
- [ ] Consider inline snapshots for small, critical structures
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';
// BAD: Snapshot-only test proves nothing about functionality
test('UserCard renders correctly', () => {
const wrapper = mount(UserCard, {
props: { user: { name: 'John', email: '[email protected]' } }
})
props: { user: { name: 'John', email: '[email protected]' } },
});
expect(wrapper.html()).toMatchSnapshot()
})
expect(wrapper.html()).toMatchSnapshot();
});
// This test passes even if:
// - The email isn't clickable
@@ -43,129 +44,136 @@ test('UserCard renders correctly', () => {
```
**Correct:**
```javascript
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';
// CORRECT: Test actual behavior
test('UserCard displays user information', () => {
const wrapper = mount(UserCard, {
props: { user: { name: 'John', email: '[email protected]' } }
})
props: { user: { name: 'John', email: '[email protected]' } },
});
expect(wrapper.find('[data-testid="user-name"]').text()).toBe('John')
expect(wrapper.find('[data-testid="user-email"]').text()).toBe('[email protected]')
})
expect(wrapper.find('[data-testid="user-name"]').text()).toBe('John');
expect(wrapper.find('[data-testid="user-email"]').text()).toBe('[email protected]');
});
test('UserCard email link is clickable', async () => {
const wrapper = mount(UserCard, {
props: { user: { name: 'John', email: '[email protected]' } }
})
props: { user: { name: 'John', email: '[email protected]' } },
});
const emailLink = wrapper.find('a[href^="mailto:"]')
expect(emailLink.exists()).toBe(true)
expect(emailLink.attributes('href')).toBe('mailto:[email protected]')
})
const emailLink = wrapper.find('a[href^="mailto:"]');
expect(emailLink.exists()).toBe(true);
expect(emailLink.attributes('href')).toBe('mailto:[email protected]');
});
test('UserCard emits select event when clicked', async () => {
const wrapper = mount(UserCard, {
props: { user: { id: 1, name: 'John' } }
})
props: { user: { id: 1, name: 'John' } },
});
await wrapper.trigger('click')
await wrapper.trigger('click');
expect(wrapper.emitted('select')).toBeTruthy()
expect(wrapper.emitted('select')[0]).toEqual([{ id: 1, name: 'John' }])
})
expect(wrapper.emitted('select')).toBeTruthy();
expect(wrapper.emitted('select')[0]).toEqual([{ id: 1, name: 'John' }]);
});
```
## When Snapshots ARE Useful
### Regression Detection for Stable Components
```javascript
// ACCEPTABLE: Snapshot as additional check, not the only check
test('ErrorBoundary renders error message', () => {
const wrapper = mount(ErrorBoundary, {
props: { error: new Error('Something went wrong') }
})
props: { error: new Error('Something went wrong') },
});
// Primary assertions - verify behavior
expect(wrapper.find('.error-title').text()).toBe('Error')
expect(wrapper.find('.error-message').text()).toContain('Something went wrong')
expect(wrapper.find('.error-title').text()).toBe('Error');
expect(wrapper.find('.error-message').text()).toContain('Something went wrong');
// Secondary snapshot - catches unexpected structural changes
expect(wrapper.find('.error-container').html()).toMatchSnapshot()
})
expect(wrapper.find('.error-container').html()).toMatchSnapshot();
});
```
### Inline Snapshots for Small Structures
```javascript
// ACCEPTABLE: Inline snapshot for small, critical structure
test('generates correct list markup', () => {
const wrapper = mount(ListItem, { props: { item: 'Test' } })
const wrapper = mount(ListItem, { props: { item: 'Test' } });
expect(wrapper.html()).toMatchInlineSnapshot(`
"<li class="list-item">Test</li>"
`)
})
`);
});
```
### Complex SVG or Icon Output
```javascript
// ACCEPTABLE: Snapshot for complex generated content
test('renders correct chart SVG', () => {
const wrapper = mount(PieChart, {
props: { data: [30, 40, 30] }
})
props: { data: [30, 40, 30] },
});
// Verify key behavior
expect(wrapper.findAll('path').length).toBe(3)
expect(wrapper.findAll('path').length).toBe(3);
// Snapshot for full SVG structure
expect(wrapper.find('svg').html()).toMatchSnapshot()
})
expect(wrapper.find('svg').html()).toMatchSnapshot();
});
```
## Better Alternatives to Snapshots
### Test Specific Elements
```javascript
// Instead of snapshotting entire component
test('renders product with all required fields', () => {
const wrapper = mount(ProductCard, {
props: { product: { name: 'Widget', price: 9.99, inStock: true } }
})
props: { product: { name: 'Widget', price: 9.99, inStock: true } },
});
expect(wrapper.find('.product-name').text()).toBe('Widget')
expect(wrapper.find('.product-price').text()).toContain('9.99')
expect(wrapper.find('.in-stock-badge').exists()).toBe(true)
})
expect(wrapper.find('.product-name').text()).toBe('Widget');
expect(wrapper.find('.product-price').text()).toContain('9.99');
expect(wrapper.find('.in-stock-badge').exists()).toBe(true);
});
```
### Test CSS Classes for Styling
```javascript
test('applies danger styling for errors', () => {
const wrapper = mount(Alert, {
props: { type: 'error', message: 'Failed!' }
})
props: { type: 'error', message: 'Failed!' },
});
expect(wrapper.classes()).toContain('alert-danger')
expect(wrapper.find('.alert-icon').classes()).toContain('icon-error')
})
expect(wrapper.classes()).toContain('alert-danger');
expect(wrapper.find('.alert-icon').classes()).toContain('icon-error');
});
```
### Use Testing Library Queries
```javascript
import { render, screen } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue';
test('form has accessible labels', () => {
render(LoginForm)
render(LoginForm);
// Testing Library queries verify accessibility
expect(screen.getByLabelText('Email')).toBeInTheDocument()
expect(screen.getByLabelText('Password')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument()
})
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
});
```
## Snapshot Anti-Patterns
@@ -173,25 +181,26 @@ test('form has accessible labels', () => {
```javascript
// ANTI-PATTERN: Giant component snapshot
test('page renders', () => {
const wrapper = mount(EntirePageComponent)
expect(wrapper.html()).toMatchSnapshot() // 500+ lines of HTML
})
const wrapper = mount(EntirePageComponent);
expect(wrapper.html()).toMatchSnapshot(); // 500+ lines of HTML
});
// ANTI-PATTERN: Snapshot with dynamic content
test('shows current date', () => {
const wrapper = mount(DateDisplay)
expect(wrapper.html()).toMatchSnapshot() // Fails every day!
})
const wrapper = mount(DateDisplay);
expect(wrapper.html()).toMatchSnapshot(); // Fails every day!
});
// ANTI-PATTERN: Snapshot after every test
test('button works', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.html()).toMatchSnapshot() // Redundant
})
const wrapper = mount(Counter);
await wrapper.find('button').trigger('click');
expect(wrapper.html()).toMatchSnapshot(); // Redundant
});
```
## Reference
- [Vue.js Testing Guide - What Not to Test](https://vuejs.org/guide/scaling-up/testing)
- [Effective Snapshot Testing](https://kentcdodds.com/blog/effective-snapshot-testing)
- [Vitest Snapshot Testing](https://vitest.dev/guide/snapshot.html)
@@ -22,34 +22,36 @@ Use `@pinia/testing` package with `createTestingPinia` for component tests and `
- [ ] Use `stubActions: false` when you need real action execution
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'
import { mount } from '@vue/test-utils';
import UserProfile from './UserProfile.vue';
// BAD: Missing Pinia - causes injection error
test('displays user name', () => {
const wrapper = mount(UserProfile) // ERROR: injection "Symbol(pinia)" not found
expect(wrapper.text()).toContain('John')
})
const wrapper = mount(UserProfile); // ERROR: injection "Symbol(pinia)" not found
expect(wrapper.text()).toContain('John');
});
```
```javascript
import { useUserStore } from '@/stores/user'
import { useUserStore } from '@/stores/user';
// BAD: No active Pinia instance
test('user store actions', () => {
const store = useUserStore() // ERROR: no active Pinia
store.login('john', 'password')
})
const store = useUserStore(); // ERROR: no active Pinia
store.login('john', 'password');
});
```
**Correct - Component Testing:**
```javascript
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import UserProfile from './UserProfile.vue'
import { useUserStore } from '@/stores/user'
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import UserProfile from './UserProfile.vue';
import { useUserStore } from '@/stores/user';
// CORRECT: Provide testing pinia with stubbed actions
test('displays user name', () => {
@@ -57,79 +59,80 @@ test('displays user name', () => {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn, // Required if not using globals: true
createSpy: vi.fn, // Required if not using globals: true
initialState: {
user: { name: 'John', email: '[email protected]' }
}
})
]
}
})
user: { name: 'John', email: '[email protected]' },
},
}),
],
},
});
expect(wrapper.text()).toContain('John')
})
expect(wrapper.text()).toContain('John');
});
// CORRECT: Test with stubbed actions (default behavior)
test('calls logout action', async () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })]
}
})
plugins: [createTestingPinia({ createSpy: vi.fn })],
},
});
// Get store AFTER mounting with createTestingPinia
const store = useUserStore()
const store = useUserStore();
await wrapper.find('[data-testid="logout"]').trigger('click')
await wrapper.find('[data-testid="logout"]').trigger('click');
// Actions are stubbed and wrapped in spies
expect(store.logout).toHaveBeenCalled()
})
expect(store.logout).toHaveBeenCalled();
});
```
**Correct - Store Unit Testing:**
```javascript
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from '@/stores/user';
describe('User Store', () => {
beforeEach(() => {
// Create fresh Pinia instance for each test
setActivePinia(createPinia())
})
setActivePinia(createPinia());
});
it('initializes with empty user', () => {
const store = useUserStore()
expect(store.user).toBeNull()
expect(store.isLoggedIn).toBe(false)
})
const store = useUserStore();
expect(store.user).toBeNull();
expect(store.isLoggedIn).toBe(false);
});
it('updates user on login', async () => {
const store = useUserStore()
const store = useUserStore();
// Real action executes - not stubbed
await store.login('john', 'password')
await store.login('john', 'password');
expect(store.user).toEqual({ name: 'John' })
expect(store.isLoggedIn).toBe(true)
})
expect(store.user).toEqual({ name: 'John' });
expect(store.isLoggedIn).toBe(true);
});
it('clears user on logout', () => {
const store = useUserStore()
store.user = { name: 'John' } // Set initial state
const store = useUserStore();
store.user = { name: 'John' }; // Set initial state
store.logout()
store.logout();
expect(store.user).toBeNull()
})
})
expect(store.user).toBeNull();
});
});
```
## Testing with Real Actions vs Stubbed Actions
```javascript
import { createTestingPinia } from '@pinia/testing'
import { createTestingPinia } from '@pinia/testing';
// Stubbed actions (default) - for isolation
const wrapper = mount(Component, {
@@ -138,10 +141,10 @@ const wrapper = mount(Component, {
createTestingPinia({
createSpy: vi.fn,
// stubActions: true (default) - actions are mocked
})
]
}
})
}),
],
},
});
// Real actions - for integration testing
const wrapper = mount(Component, {
@@ -149,80 +152,81 @@ const wrapper = mount(Component, {
plugins: [
createTestingPinia({
createSpy: vi.fn,
stubActions: false // Actions execute normally
})
]
}
})
stubActions: false, // Actions execute normally
}),
],
},
});
```
## Mocking Specific Action Implementations
```javascript
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import { useCartStore } from '@/stores/cart'
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { useCartStore } from '@/stores/cart';
test('handles checkout failure', async () => {
const wrapper = mount(Checkout, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })]
}
})
plugins: [createTestingPinia({ createSpy: vi.fn })],
},
});
const cartStore = useCartStore()
const cartStore = useCartStore();
// Mock specific action behavior
cartStore.checkout.mockRejectedValue(new Error('Payment failed'))
cartStore.checkout.mockRejectedValue(new Error('Payment failed'));
await wrapper.find('[data-testid="checkout"]').trigger('click')
await flushPromises()
await wrapper.find('[data-testid="checkout"]').trigger('click');
await flushPromises();
expect(wrapper.find('.error').text()).toContain('Payment failed')
})
expect(wrapper.find('.error').text()).toContain('Payment failed');
});
```
## Spying on Actions with vi.spyOn
```javascript
import { setActivePinia, createPinia } from 'pinia'
import { vi } from 'vitest'
import { useUserStore } from '@/stores/user'
import { setActivePinia, createPinia } from 'pinia';
import { vi } from 'vitest';
import { useUserStore } from '@/stores/user';
test('tracks action calls', async () => {
setActivePinia(createPinia())
const store = useUserStore()
setActivePinia(createPinia());
const store = useUserStore();
const loginSpy = vi.spyOn(store, 'login')
loginSpy.mockResolvedValue({ success: true })
const loginSpy = vi.spyOn(store, 'login');
loginSpy.mockResolvedValue({ success: true });
await store.login('john', 'password')
await store.login('john', 'password');
expect(loginSpy).toHaveBeenCalledWith('john', 'password')
})
expect(loginSpy).toHaveBeenCalledWith('john', 'password');
});
```
## Testing Store $subscribe
```javascript
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from '@/stores/user';
test('subscription triggers on state change', () => {
setActivePinia(createPinia())
const store = useUserStore()
setActivePinia(createPinia());
const store = useUserStore();
const callback = vi.fn()
store.$subscribe(callback)
const callback = vi.fn();
store.$subscribe(callback);
store.user = { name: 'John' }
store.user = { name: 'John' };
expect(callback).toHaveBeenCalled()
})
expect(callback).toHaveBeenCalled();
});
```
## Reference
- [Pinia Testing Guide](https://pinia.vuejs.org/cookbook/testing.html)
- [@pinia/testing Package](https://www.npmjs.com/package/@pinia/testing)
- [Vue Test Utils - Plugins](https://test-utils.vuejs.org/guide/advanced/plugins.html)
@@ -21,29 +21,31 @@ Create a test wrapper component with Suspense or use a `mountSuspense` helper fu
- [ ] Consider using `@testing-library/vue` with caution (has Suspense issues)
**Incorrect:**
```javascript
import { mount } from '@vue/test-utils'
import AsyncUserProfile from './AsyncUserProfile.vue'
import { mount } from '@vue/test-utils';
import AsyncUserProfile from './AsyncUserProfile.vue';
// BAD: Async component without Suspense wrapper
test('displays user data', async () => {
// This won't render - Vue expects Suspense wrapper for async setup
const wrapper = mount(AsyncUserProfile, {
props: { userId: 1 }
})
props: { userId: 1 },
});
await flushPromises()
await flushPromises();
// This fails - component never rendered
expect(wrapper.find('.username').text()).toBe('John')
})
expect(wrapper.find('.username').text()).toBe('John');
});
```
**Correct - Manual Wrapper Component:**
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, Suspense } from 'vue'
import AsyncUserProfile from './AsyncUserProfile.vue'
import { mount, flushPromises } from '@vue/test-utils';
import { defineComponent, Suspense } from 'vue';
import AsyncUserProfile from './AsyncUserProfile.vue';
test('displays user data', async () => {
// Create wrapper component with Suspense
@@ -54,63 +56,60 @@ test('displays user data', async () => {
<AsyncUserProfile :user-id="1" />
<template #fallback>Loading...</template>
</Suspense>
`
})
`,
});
const wrapper = mount(TestWrapper)
const wrapper = mount(TestWrapper);
// Initially shows fallback
expect(wrapper.text()).toContain('Loading...')
expect(wrapper.text()).toContain('Loading...');
// Wait for async setup to complete
await flushPromises()
await flushPromises();
// Find the actual component for detailed assertions
const profile = wrapper.findComponent(AsyncUserProfile)
expect(profile.find('.username').text()).toBe('John')
})
const profile = wrapper.findComponent(AsyncUserProfile);
expect(profile.find('.username').text()).toBe('John');
});
```
**Correct - Reusable Helper Function:**
```javascript
// test-utils.js
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, Suspense, h } from 'vue'
import { mount, flushPromises } from '@vue/test-utils';
import { defineComponent, Suspense, h } from 'vue';
export async function mountSuspense(component, options = {}) {
const { props, slots, ...mountOptions } = options
const { props, slots, ...mountOptions } = options;
const wrapper = mount(
defineComponent({
render() {
return h(
Suspense,
null,
{
default: () => h(component, props, slots),
fallback: () => h('div', 'Loading...')
}
)
}
return h(Suspense, null, {
default: () => h(component, props, slots),
fallback: () => h('div', 'Loading...'),
});
},
}),
mountOptions
)
mountOptions,
);
// Wait for async component to resolve
await flushPromises()
await flushPromises();
return {
wrapper,
// Provide easy access to the actual component
component: wrapper.findComponent(component)
}
component: wrapper.findComponent(component),
};
}
```
```javascript
// AsyncUserProfile.test.js
import { mountSuspense } from './test-utils'
import AsyncUserProfile from './AsyncUserProfile.vue'
import { mountSuspense } from './test-utils';
import AsyncUserProfile from './AsyncUserProfile.vue';
test('displays user data', async () => {
const { component } = await mountSuspense(AsyncUserProfile, {
@@ -118,81 +117,82 @@ test('displays user data', async () => {
global: {
stubs: {
// Stub any child components if needed
}
}
})
},
},
});
expect(component.find('.username').text()).toBe('John')
})
expect(component.find('.username').text()).toBe('John');
});
test('handles errors gracefully', async () => {
const { component } = await mountSuspense(AsyncUserProfile, {
props: { userId: 'invalid' }
})
props: { userId: 'invalid' },
});
expect(component.find('.error').exists()).toBe(true)
})
expect(component.find('.error').exists()).toBe(true);
});
```
## Testing with onErrorCaptured
```javascript
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, Suspense, h, ref, onErrorCaptured } from 'vue'
import AsyncComponent from './AsyncComponent.vue'
import { mount, flushPromises } from '@vue/test-utils';
import { defineComponent, Suspense, h, ref, onErrorCaptured } from 'vue';
import AsyncComponent from './AsyncComponent.vue';
test('catches async errors', async () => {
const capturedError = ref(null)
const capturedError = ref(null);
const TestWrapper = defineComponent({
setup() {
onErrorCaptured((error) => {
capturedError.value = error
return true // Prevent error propagation
})
return { capturedError }
capturedError.value = error;
return true; // Prevent error propagation
});
return { capturedError };
},
render() {
return h(Suspense, null, {
default: () => h(AsyncComponent, { shouldFail: true }),
fallback: () => h('div', 'Loading...')
})
}
})
fallback: () => h('div', 'Loading...'),
});
},
});
const wrapper = mount(TestWrapper)
await flushPromises()
const wrapper = mount(TestWrapper);
await flushPromises();
expect(capturedError.value).toBeTruthy()
expect(capturedError.value.message).toContain('Failed to load')
})
expect(capturedError.value).toBeTruthy();
expect(capturedError.value.message).toContain('Failed to load');
});
```
## Using with Nuxt's mountSuspended
```javascript
// If using Nuxt, use the built-in mountSuspended helper
import { mountSuspended } from '@nuxt/test-utils/runtime'
import AsyncPage from './AsyncPage.vue'
import { mountSuspended } from '@nuxt/test-utils/runtime';
import AsyncPage from './AsyncPage.vue';
test('renders async page', async () => {
const wrapper = await mountSuspended(AsyncPage, {
props: { id: 1 }
})
props: { id: 1 },
});
expect(wrapper.find('h1').text()).toBe('Page Title')
})
expect(wrapper.find('h1').text()).toBe('Page Title');
});
```
## Important Caveats
### @testing-library/vue Limitation
```javascript
// CAUTION: @testing-library/vue has issues with Suspense
// Use @vue/test-utils for async components instead
// If you must use Testing Library, create manual wrapper:
import { render, waitFor } from '@testing-library/vue'
import { render, waitFor } from '@testing-library/vue';
test('async component with testing library', async () => {
const TestWrapper = {
@@ -201,29 +201,31 @@ test('async component with testing library', async () => {
<AsyncComponent />
</Suspense>
`,
components: { AsyncComponent }
}
components: { AsyncComponent },
};
const { getByText } = render(TestWrapper)
const { getByText } = render(TestWrapper);
await waitFor(() => {
expect(getByText('Loaded content')).toBeInTheDocument()
})
})
expect(getByText('Loaded content')).toBeInTheDocument();
});
});
```
### Accessing Component Instance
```javascript
test('access vm on async component', async () => {
const { wrapper, component } = await mountSuspense(AsyncComponent)
const { wrapper, component } = await mountSuspense(AsyncComponent);
// The wrapper.vm is the Suspense wrapper - not useful
// Use component.vm for the actual async component
expect(component.vm.someData).toBe('value')
})
expect(component.vm.someData).toBe('value');
});
```
## Reference
- [Vue Test Utils - Async Suspense](https://test-utils.vuejs.org/guide/advanced/async-suspense)
- [Vue.js Suspense Documentation](https://vuejs.org/guide/built-ins/suspense.html)
- [Testing Library Vue Suspense Issue](https://github.com/testing-library/vue-testing-library/issues/230)
@@ -31,9 +31,10 @@ npm install -D vitest @vue/test-utils jsdom
```
**vite.config.js:**
```javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
@@ -43,12 +44,13 @@ export default defineConfig({
// Use happy-dom for faster tests (or 'jsdom' for better compatibility)
environment: 'happy-dom',
// Optional: Setup files for global configuration
setupFiles: ['./src/test/setup.js']
}
})
setupFiles: ['./src/test/setup.js'],
},
});
```
**package.json:**
```json
{
"scripts": {
@@ -60,6 +62,7 @@ export default defineConfig({
```
**tsconfig.json (if using TypeScript):**
```json
{
"compilerOptions": {
@@ -72,38 +75,38 @@ export default defineConfig({
```javascript
// src/components/Counter.test.js
import { describe, it, expect, beforeEach } from 'vitest' // optional with globals: true
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
import { describe, it, expect, beforeEach } from 'vitest'; // optional with globals: true
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
describe('Counter', () => {
let wrapper
let wrapper;
beforeEach(() => {
wrapper = mount(Counter)
})
wrapper = mount(Counter);
});
it('renders initial count', () => {
expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
})
expect(wrapper.find('[data-testid="count"]').text()).toBe('0');
});
it('increments when button clicked', async () => {
await wrapper.find('[data-testid="increment"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
})
})
await wrapper.find('[data-testid="increment"]').trigger('click');
expect(wrapper.find('[data-testid="count"]').text()).toBe('1');
});
});
```
## Vitest vs Jest Comparison
| Feature | Vitest | Jest |
|---------|--------|------|
| Vite Integration | Native | Requires config |
| Speed | Very fast (ESM native) | Slower with Vite |
| Watch Mode | Excellent | Good |
| Vue SFC Support | Works with Vite | Needs vue-jest |
| Config Sharing | Same as vite.config | Separate |
| API | Jest-compatible | Standard |
| Feature | Vitest | Jest |
| ---------------- | ---------------------- | ---------------- |
| Vite Integration | Native | Requires config |
| Speed | Very fast (ESM native) | Slower with Vite |
| Watch Mode | Excellent | Good |
| Vue SFC Support | Works with Vite | Needs vue-jest |
| Config Sharing | Same as vite.config | Separate |
| API | Jest-compatible | Standard |
## Using with Testing Library
@@ -113,32 +116,32 @@ npm install -D @testing-library/vue @testing-library/jest-dom
```javascript
// src/test/setup.js
import { expect } from 'vitest'
import * as matchers from '@testing-library/jest-dom/matchers'
import { expect } from 'vitest';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers)
expect.extend(matchers);
```
```javascript
// Component.test.js
import { render, screen, fireEvent } from '@testing-library/vue'
import UserCard from './UserCard.vue'
import { render, screen, fireEvent } from '@testing-library/vue';
import UserCard from './UserCard.vue';
test('displays user name', () => {
render(UserCard, {
props: { name: 'John Doe' }
})
props: { name: 'John Doe' },
});
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
```
## Advanced Configuration
```javascript
// vitest.config.js (separate file if preferred)
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
@@ -150,55 +153,58 @@ export default defineConfig({
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules', 'test']
exclude: ['node_modules', 'test'],
},
// Helpful for debugging
reporters: ['verbose'],
// Run tests in sequence in CI
poolOptions: {
threads: {
singleThread: process.env.CI === 'true'
}
}
}
})
singleThread: process.env.CI === 'true',
},
},
},
});
```
## Common Patterns
### Mocking Modules
```javascript
import { vi } from 'vitest'
import { vi } from 'vitest';
vi.mock('@/api/users', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: 'John' })
}))
fetchUser: vi.fn().mockResolvedValue({ name: 'John' }),
}));
```
### Testing with Fake Timers
```javascript
import { vi, beforeEach, afterEach } from 'vitest'
import { vi, beforeEach, afterEach } from 'vitest';
beforeEach(() => {
vi.useFakeTimers()
})
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks()
})
vi.restoreAllMocks();
});
test('debounced search', async () => {
const wrapper = mount(SearchBox)
await wrapper.find('input').setValue('vue')
const wrapper = mount(SearchBox);
await wrapper.find('input').setValue('vue');
vi.advanceTimersByTime(300)
await flushPromises()
vi.advanceTimersByTime(300);
await flushPromises();
expect(wrapper.emitted('search')).toBeTruthy()
})
expect(wrapper.emitted('search')).toBeTruthy();
});
```
## Reference
- [Vitest Documentation](https://vitest.dev/)
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
- [Vue Test Utils](https://test-utils.vuejs.org/)