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:
+46
-52
@@ -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
|
||||
|
||||
+54
-49
@@ -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)
|
||||
|
||||
+70
-62
@@ -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)
|
||||
|
||||
+55
-46
@@ -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)
|
||||
|
||||
+48
-43
@@ -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)
|
||||
|
||||
+102
-97
@@ -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/)
|
||||
|
||||
+63
-61
@@ -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)
|
||||
|
||||
+75
-66
@@ -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)
|
||||
|
||||
+96
-92
@@ -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)
|
||||
|
||||
+81
-79
@@ -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)
|
||||
|
||||
+62
-56
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user