feat: Complete fleet — 94 skills across 10+ domains
Pulled ALL skills from 15 source repositories: - anthropics/skills: 16 (docs, design, MCP, testing) - obra/superpowers: 14 (TDD, debugging, agents, planning) - coreyhaines31/marketingskills: 25 (marketing, CRO, SEO, growth) - better-auth/skills: 5 (auth patterns) - vercel-labs/agent-skills: 5 (React, design, Vercel) - antfu/skills: 16 (Vue, Vite, Vitest, pnpm, Turborepo) - Plus 13 individual skills from various repos Mosaic Stack is not limited to coding — the Orchestrator and subagents serve coding, business, design, marketing, writing, logistics, analysis, and more. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21
skills/vue-testing-best-practices/LICENSE.md
Normal file
21
skills/vue-testing-best-practices/LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 hyf0, SerKo <https://github.com/serkodev>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
29
skills/vue-testing-best-practices/SKILL.md
Normal file
29
skills/vue-testing-best-practices/SKILL.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: vue-testing-best-practices
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
author: github.com/vuejs-ai
|
||||
description: Use for Vue.js testing. Covers Vitest, Vue Test Utils, component testing, mocking, testing patterns, and Playwright for E2E testing.
|
||||
---
|
||||
|
||||
Vue.js testing best practices, patterns, and common gotchas.
|
||||
|
||||
### Testing
|
||||
- Setting up test infrastructure for Vue 3 projects → See [testing-vitest-recommended-for-vue](reference/testing-vitest-recommended-for-vue.md)
|
||||
- Tests keep breaking when refactoring component internals → See [testing-component-blackbox-approach](reference/testing-component-blackbox-approach.md)
|
||||
- Tests fail intermittently with race conditions → See [testing-async-await-flushpromises](reference/testing-async-await-flushpromises.md)
|
||||
- Composables using lifecycle hooks or inject fail to test → See [testing-composables-helper-wrapper](reference/testing-composables-helper-wrapper.md)
|
||||
- Getting "injection Symbol(pinia) not found" errors in tests → See [testing-pinia-store-setup](reference/testing-pinia-store-setup.md)
|
||||
- Components with async setup won't render in tests → See [testing-suspense-async-components](reference/testing-suspense-async-components.md)
|
||||
- Snapshot tests keep passing despite broken functionality → See [testing-no-snapshot-only](reference/testing-no-snapshot-only.md)
|
||||
- Choosing end-to-end testing framework for Vue apps → See [testing-e2e-playwright-recommended](reference/testing-e2e-playwright-recommended.md)
|
||||
- Tests need to verify computed styles or real DOM events → See [testing-browser-vs-node-runners](reference/testing-browser-vs-node-runners.md)
|
||||
- Testing components created with defineAsyncComponent fails → See [async-component-testing](reference/async-component-testing.md)
|
||||
- Teleported modal content can't be found in wrapper queries → See [teleport-testing-complexity](reference/teleport-testing-complexity.md)
|
||||
|
||||
## Reference
|
||||
|
||||
- [Vue.js Testing Guide](https://vuejs.org/guide/scaling-up/testing)
|
||||
- [Vue Test Utils](https://test-utils.vuejs.org/)
|
||||
- [Vitest Documentation](https://vitest.dev/)
|
||||
- [Playwright Documentation](https://playwright.dev/)
|
||||
5
skills/vue-testing-best-practices/SYNC.md
Normal file
5
skills/vue-testing-best-practices/SYNC.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Sync Info
|
||||
|
||||
- **Source:** `vendor/vuejs-ai/skills/vue-testing-best-practices`
|
||||
- **Git SHA:** `de5bc1149695e7d82125bf69202fa37b6d111541`
|
||||
- **Synced:** 2026-01-31
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Use flushPromises for Testing Async Components
|
||||
impact: HIGH
|
||||
impactDescription: Without awaiting async operations, tests make assertions before the component has rendered, causing false negatives
|
||||
type: gotcha
|
||||
tags: [vue3, testing, async, defineAsyncComponent, flushPromises, vitest]
|
||||
---
|
||||
|
||||
# Use flushPromises for Testing Async Components
|
||||
|
||||
**Impact: HIGH** - When testing async components created with `defineAsyncComponent`, you must use `await flushPromises()` to ensure the component has loaded before making assertions. Vue updates asynchronously, so tests that don't account for this will make assertions before the component has rendered.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use `async/await` in test functions for async components
|
||||
- [ ] Call `await flushPromises()` after mounting async components
|
||||
- [ ] Test loading states by making assertions before `flushPromises()`
|
||||
- [ ] Test error states using rejected promises in `defineAsyncComponent`
|
||||
- [ ] Use `trigger()` with `await` as it returns a Promise
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
const AsyncWidget = defineAsyncComponent(() =>
|
||||
import('./Widget.vue')
|
||||
)
|
||||
|
||||
test('renders async component', () => {
|
||||
const wrapper = mount(AsyncWidget)
|
||||
|
||||
// FAILS: Component hasn't loaded yet
|
||||
expect(wrapper.text()).toContain('Widget Content')
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```javascript
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineAsyncComponent, nextTick } from 'vue'
|
||||
|
||||
const AsyncWidget = defineAsyncComponent(() =>
|
||||
import('./Widget.vue')
|
||||
)
|
||||
|
||||
test('renders async component', async () => {
|
||||
const wrapper = mount(AsyncWidget)
|
||||
|
||||
// Wait for async component to load
|
||||
await flushPromises()
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const wrapper = mount(AsyncWithLoading)
|
||||
|
||||
// Check loading state immediately
|
||||
expect(wrapper.text()).toContain('Loading...')
|
||||
|
||||
// Wait for component to load
|
||||
await flushPromises()
|
||||
|
||||
// Check final state
|
||||
expect(wrapper.text()).toContain('Widget Content')
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with Suspense
|
||||
|
||||
```javascript
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { Suspense, defineAsyncComponent, h } from 'vue'
|
||||
|
||||
const AsyncWidget = defineAsyncComponent(() =>
|
||||
import('./Widget.vue')
|
||||
)
|
||||
|
||||
test('renders async component with Suspense', async () => {
|
||||
const wrapper = mount({
|
||||
components: { AsyncWidget },
|
||||
template: `
|
||||
<Suspense>
|
||||
<AsyncWidget />
|
||||
<template #fallback>
|
||||
<div>Loading...</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
`
|
||||
})
|
||||
|
||||
// Initially shows fallback
|
||||
expect(wrapper.text()).toContain('Loading...')
|
||||
|
||||
// Wait for async resolution
|
||||
await flushPromises()
|
||||
|
||||
// Now shows actual content
|
||||
expect(wrapper.text()).toContain('Widget Content')
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Error States
|
||||
|
||||
```javascript
|
||||
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>' }
|
||||
})
|
||||
|
||||
const wrapper = mount(AsyncWithError)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
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 |
|
||||
|
||||
## Dynamic Import Handling
|
||||
|
||||
**Note:** Dynamic imports (`import('./File.vue')`) may require additional handling beyond `flushPromises()` in test environments. Test runners like Vitest handle module resolution differently than runtime bundlers, which can cause timing issues with dynamic imports. If `flushPromises()` alone doesn't resolve the component, consider:
|
||||
|
||||
- Mocking the dynamic import to return the component synchronously
|
||||
- Using multiple `await flushPromises()` calls in sequence
|
||||
- Wrapping assertions in `waitFor()` or retry utilities
|
||||
- Configuring your test runner's module resolution settings
|
||||
|
||||
```javascript
|
||||
// If flushPromises() isn't sufficient, mock the import
|
||||
vi.mock('./Widget.vue', () => ({
|
||||
default: { template: '<div>Widget Content</div>' }
|
||||
}))
|
||||
|
||||
// Or use multiple flush calls for nested async operations
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Vue Test Utils - Asynchronous Behavior](https://test-utils.vuejs.org/guide/advanced/async-suspense)
|
||||
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Teleported Content Requires Special Testing Approach
|
||||
impact: MEDIUM
|
||||
impactDescription: Vue Test Utils cannot find teleported content using standard wrapper.find() methods
|
||||
type: gotcha
|
||||
tags: [vue3, teleport, testing, vue-test-utils]
|
||||
---
|
||||
|
||||
# Teleported Content Requires Special Testing Approach
|
||||
|
||||
**Impact: MEDIUM** - Vue Test Utils scopes queries to the mounted component. Teleported content renders outside the component's DOM tree, so `wrapper.find()` cannot locate it. This leads to failing tests and confusion.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Stub Teleport in unit tests to keep content in component tree
|
||||
- [ ] Use `document.body` queries for integration tests with real Teleport
|
||||
- [ ] Consider using `getComponent()` instead of DOM queries for teleported components
|
||||
|
||||
**Problem - Standard Testing Fails:**
|
||||
```vue
|
||||
<!-- Modal.vue -->
|
||||
<template>
|
||||
<button @click="open = true">Open</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="modal" data-testid="modal">
|
||||
<input type="text" data-testid="modal-input" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
```
|
||||
|
||||
```ts
|
||||
// Modal.spec.ts - BROKEN
|
||||
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')
|
||||
|
||||
// FAILS: Teleported content is not in wrapper's DOM tree
|
||||
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'
|
||||
|
||||
test('modal input exists', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
global: {
|
||||
stubs: {
|
||||
// Stub teleport to render content inline
|
||||
Teleport: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
// Works: Content renders inside wrapper
|
||||
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'
|
||||
|
||||
test('modal renders to body', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
attachTo: document.body // Required for Teleport to work
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
// Query the actual DOM
|
||||
const modal = document.querySelector('[data-testid="modal"]')
|
||||
expect(modal).toBeTruthy()
|
||||
|
||||
const input = document.querySelector('[data-testid="modal-input"]')
|
||||
expect(input).toBeTruthy()
|
||||
|
||||
// Cleanup
|
||||
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'
|
||||
|
||||
// Custom stub that renders content in a testable way
|
||||
const TeleportStub = {
|
||||
setup(props, { slots }) {
|
||||
return () => h('div', { class: 'teleport-stub' }, slots.default?.())
|
||||
}
|
||||
}
|
||||
|
||||
test('modal with custom stub', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: TeleportStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
// Content is inside .teleport-stub
|
||||
expect(wrapper.find('.teleport-stub [data-testid="modal-input"]').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Vue Final Modal and UI Libraries
|
||||
|
||||
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'
|
||||
|
||||
test('modal content', async () => {
|
||||
const wrapper = mount(MyComponent, {
|
||||
global: {
|
||||
stubs: {
|
||||
// Stub the modal component to avoid teleport issues
|
||||
VueFinalModal: true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## E2E Testing (Cypress, Playwright)
|
||||
|
||||
E2E tests query the real DOM, so Teleport works naturally:
|
||||
|
||||
```ts
|
||||
// Cypress
|
||||
it('opens modal', () => {
|
||||
cy.visit('/page-with-modal')
|
||||
cy.get('button').click()
|
||||
|
||||
// Works: Cypress queries the real DOM
|
||||
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)
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Properly Handle Async Updates with nextTick and flushPromises
|
||||
impact: HIGH
|
||||
impactDescription: Race conditions and flaky tests occur when async DOM updates or API calls complete after assertions run
|
||||
type: gotcha
|
||||
tags: [vue3, testing, async, flushPromises, nextTick, vitest, vue-test-utils, race-condition]
|
||||
---
|
||||
|
||||
# Properly Handle Async Updates with nextTick and flushPromises
|
||||
|
||||
**Impact: HIGH** - Vue updates the DOM asynchronously. Without properly awaiting these updates, tests may assert against stale DOM state, causing intermittent failures and false negatives.
|
||||
|
||||
Use `await` with triggers and `setValue`, use `nextTick` for reactive updates, and use `flushPromises` for external async operations like API calls.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Always await `trigger()` and `setValue()` calls
|
||||
- [ ] Use `await nextTick()` after programmatic reactive state changes
|
||||
- [ ] Use `await flushPromises()` for external async operations (API calls, timers)
|
||||
- [ ] Don't chain multiple `nextTick` calls - use `flushPromises` instead
|
||||
- [ ] Consider using `waitFor` from testing-library for polling assertions
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
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)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// BAD: Using nextTick for API calls
|
||||
test('loads data from API', async () => {
|
||||
const wrapper = mount(DataLoader)
|
||||
|
||||
await nextTick() // This won't wait for the API call!
|
||||
|
||||
// Assertion runs before fetch completes
|
||||
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'
|
||||
|
||||
// CORRECT: Await trigger and setValue
|
||||
test('search filters results', async () => {
|
||||
const wrapper = mount(SearchComponent)
|
||||
|
||||
await wrapper.find('input').setValue('vue')
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.findAll('.result').length).toBe(3)
|
||||
})
|
||||
|
||||
// CORRECT: Use flushPromises for API calls
|
||||
test('loads data from API', async () => {
|
||||
const wrapper = mount(DataLoader)
|
||||
|
||||
// Wait for all pending promises to resolve
|
||||
await flushPromises()
|
||||
|
||||
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 nextTick()` - Programmatic Reactive Updates
|
||||
```javascript
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
test('reflects programmatic state changes', async () => {
|
||||
const wrapper = mount(Counter)
|
||||
|
||||
// Direct state modification (when testing with exposed internals)
|
||||
wrapper.vm.count = 5
|
||||
|
||||
await nextTick() // Wait for Vue to update DOM
|
||||
|
||||
expect(wrapper.find('.count').text()).toBe('5')
|
||||
})
|
||||
```
|
||||
|
||||
### `await flushPromises()` - External Async Operations
|
||||
```javascript
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
|
||||
test('displays fetched data', async () => {
|
||||
const wrapper = mount(UserProfile, {
|
||||
props: { userId: 1 }
|
||||
})
|
||||
|
||||
// Wait for component's API call to complete
|
||||
await flushPromises()
|
||||
|
||||
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)
|
||||
|
||||
await flushPromises() // Wait for fetch
|
||||
await flushPromises() // Wait for processing triggered by fetch
|
||||
|
||||
expect(wrapper.find('.processed').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
## Common Pattern: Combining Methods
|
||||
```javascript
|
||||
test('submits form and shows success', async () => {
|
||||
const wrapper = mount(ContactForm)
|
||||
|
||||
// Fill form (awaiting each interaction)
|
||||
await wrapper.find('#name').setValue('John')
|
||||
await wrapper.find('#email').setValue('john@example.com')
|
||||
|
||||
// Submit form
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
// Wait for API submission to complete
|
||||
await flushPromises()
|
||||
|
||||
// Assert success state
|
||||
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'
|
||||
|
||||
const server = setupServer(
|
||||
rest.get('/api/user', (req, res, ctx) => {
|
||||
return res(ctx.json({ name: 'John' }))
|
||||
})
|
||||
)
|
||||
|
||||
test('displays user data', async () => {
|
||||
const wrapper = mount(UserCard)
|
||||
|
||||
// MSW might require multiple flushPromises
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: Choose Browser-Based Runner for Style and DOM Event Testing
|
||||
impact: MEDIUM
|
||||
impactDescription: Node-based runners cannot test real CSS behavior, native DOM events, cookies, or computed styles
|
||||
type: capability
|
||||
tags: [vue3, testing, component-testing, vitest, browser, jsdom]
|
||||
---
|
||||
|
||||
# Choose Browser-Based Runner for Style and DOM Event Testing
|
||||
|
||||
**Impact: MEDIUM** - Node-based test runners (Vitest with jsdom/happy-dom) simulate the DOM but cannot test real CSS rendering, native browser events, cookies, computed styles, or cross-browser behavior. Use browser-based runners when these matter.
|
||||
|
||||
Use Vitest for most component tests (fast), but use Vitest Browser Mode when testing visual/DOM-dependent features.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Use Vitest (node) for logic-focused component tests
|
||||
- [ ] Use Vitest Browser Mode for style-dependent tests
|
||||
- [ ] Use Vitest Browser Mode for native events (focus, drag, resize)
|
||||
- [ ] Use Vitest Browser Mode for cookies and computed CSS styles
|
||||
- [ ] Accept slower speed tradeoff for browser accuracy
|
||||
|
||||
## When to Use Each Approach
|
||||
|
||||
### Node-Based Runner (Vitest + happy-dom/jsdom)
|
||||
Best for:
|
||||
- Pure logic testing
|
||||
- State management
|
||||
- Event emission
|
||||
- Props/slots behavior
|
||||
- Most component interactions
|
||||
- Fast CI/CD pipelines
|
||||
|
||||
```javascript
|
||||
// vitest.config.js
|
||||
export default defineConfig({
|
||||
test: {
|
||||
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()
|
||||
})
|
||||
```
|
||||
|
||||
### Vitest Browser Mode
|
||||
Required for:
|
||||
- CSS computed styles verification
|
||||
- CSS transitions/animations
|
||||
- Real focus/blur behavior
|
||||
- Drag and drop
|
||||
- Cookie operations
|
||||
- Viewport-dependent behavior
|
||||
- Cross-browser validation
|
||||
|
||||
## Vitest Browser Mode Setup
|
||||
|
||||
```bash
|
||||
npm install -D @vitest/browser playwright
|
||||
```
|
||||
|
||||
```javascript
|
||||
// vitest.config.js
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
browser: {
|
||||
enabled: true,
|
||||
name: 'chromium',
|
||||
provider: 'playwright',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Button.browser.test.js
|
||||
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 button = getByRole('button')
|
||||
|
||||
// Check initial style
|
||||
await expect.element(button).toHaveStyle({
|
||||
backgroundColor: 'rgb(59, 130, 246)' // blue
|
||||
})
|
||||
})
|
||||
|
||||
test('maintains focus after click', async () => {
|
||||
const { getByRole } = render(Button)
|
||||
|
||||
const button = getByRole('button')
|
||||
await button.click()
|
||||
|
||||
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' } })
|
||||
// This only checks class exists, not actual color
|
||||
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' } })
|
||||
await expect.element(getByRole('button')).toHaveStyle({
|
||||
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)
|
||||
// getComputedStyle returns empty/default values in jsdom
|
||||
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')
|
||||
|
||||
await expect.element(button).toHaveStyle({
|
||||
padding: '12px 24px'
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Native Events - Browser Required
|
||||
```javascript
|
||||
// Node runner: Synthetic events only
|
||||
test('handles drag and drop', async () => {
|
||||
const wrapper = mount(DraggableList)
|
||||
// trigger('dragstart') is synthetic - may not work as expected
|
||||
await wrapper.find('.item').trigger('dragstart')
|
||||
})
|
||||
|
||||
// Vitest Browser Mode: Real native events via userEvent
|
||||
import { userEvent } from '@vitest/browser/context'
|
||||
|
||||
test('reorders items on drag', async () => {
|
||||
const { getByTestId } = render(DraggableList)
|
||||
|
||||
const item = getByTestId('item-1')
|
||||
const target = getByTestId('item-3')
|
||||
|
||||
await userEvent.dragAndDrop(item, target)
|
||||
|
||||
// Assert reordering
|
||||
})
|
||||
```
|
||||
|
||||
## Recommended Testing Strategy
|
||||
|
||||
```javascript
|
||||
// vitest.config.js - Separate test configurations
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Default: Node environment for speed
|
||||
environment: 'happy-dom',
|
||||
|
||||
// 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
|
||||
│ ├── Button.test.js
|
||||
│ └── useCounter.test.js
|
||||
├── component/ # Slower browser-based tests
|
||||
│ ├── Button.browser.test.js
|
||||
│ └── DragDrop.browser.test.js
|
||||
└── e2e/ # Full E2E tests (Playwright)
|
||||
└── user-flow.spec.ts
|
||||
```
|
||||
|
||||
## Reference
|
||||
- [Vue.js Testing - Component Testing](https://vuejs.org/guide/scaling-up/testing#component-testing)
|
||||
- [Vitest Browser Mode](https://vitest.dev/guide/browser.html)
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Test Components Using Blackbox Approach - Focus on Behavior Not Implementation
|
||||
impact: HIGH
|
||||
impactDescription: Implementation-aware tests become brittle and break during refactoring, leading to high maintenance burden
|
||||
type: best-practice
|
||||
tags: [vue3, testing, component-testing, vitest, vue-test-utils, blackbox]
|
||||
---
|
||||
|
||||
# Test Components Using Blackbox Approach - Focus on Behavior Not Implementation
|
||||
|
||||
**Impact: HIGH** - Tests that rely on implementation details (internal state, private methods, component structure) break during refactoring even when functionality remains correct. This leads to false negatives and high test maintenance burden.
|
||||
|
||||
Follow Kent C. Dodds' testing philosophy: "The more your tests resemble how your software is used, the more confidence they can give you."
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Test what the component does, not how it does it
|
||||
- [ ] Query elements by user-visible attributes (text, role, testid)
|
||||
- [ ] Simulate user interactions (click, type) rather than calling methods directly
|
||||
- [ ] Assert on rendered output, emitted events, and visible state changes
|
||||
- [ ] Avoid accessing component internal state or private methods
|
||||
- [ ] Use data-testid attributes for elements without semantic meaning
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Counter from './Counter.vue'
|
||||
|
||||
// BAD: Testing implementation details
|
||||
test('counter increments', async () => {
|
||||
const wrapper = mount(Counter)
|
||||
|
||||
// Accessing internal state directly
|
||||
expect(wrapper.vm.count).toBe(0)
|
||||
|
||||
// Calling internal method instead of simulating user action
|
||||
wrapper.vm.increment()
|
||||
|
||||
// Checking internal state instead of visible output
|
||||
expect(wrapper.vm.count).toBe(1)
|
||||
})
|
||||
|
||||
// BAD: Testing component structure
|
||||
test('has increment button', () => {
|
||||
const wrapper = mount(Counter)
|
||||
|
||||
// Testing implementation detail - what if button becomes an anchor?
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
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 }
|
||||
})
|
||||
|
||||
// Assert initial visible state
|
||||
expect(wrapper.find('[data-testid="counter-value"]').text()).toContain('0')
|
||||
|
||||
// Simulate user action
|
||||
await wrapper.find('[data-testid="increment-button"]').trigger('click')
|
||||
|
||||
// Assert visible result
|
||||
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)
|
||||
|
||||
await wrapper.find('[data-testid="increment-button"]').trigger('click')
|
||||
|
||||
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'
|
||||
|
||||
// Testing Library encourages accessible, user-centric queries
|
||||
test('increments counter on button click', async () => {
|
||||
render(Counter)
|
||||
|
||||
// Query by role - how screen readers see it
|
||||
const button = screen.getByRole('button', { name: /increment/i })
|
||||
const display = screen.getByText('0')
|
||||
|
||||
await fireEvent.click(button)
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
// Slots render correctly
|
||||
test('renders slot content', () => {
|
||||
const wrapper = mount(Card, {
|
||||
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()
|
||||
})
|
||||
```
|
||||
|
||||
### DON'T Test (Implementation Details)
|
||||
```javascript
|
||||
// Don't test internal computed properties
|
||||
// Don't test internal methods
|
||||
// Don't test component options/setup internals
|
||||
// Don't test that specific child components are rendered (unless critical)
|
||||
// Don't rely exclusively on snapshot tests for correctness
|
||||
```
|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: Test Complex Composables with Host Component Wrapper
|
||||
impact: MEDIUM
|
||||
impactDescription: Composables using lifecycle hooks or provide/inject fail when tested directly without a component context
|
||||
type: capability
|
||||
tags: [vue3, testing, composables, vitest, lifecycle-hooks, provide-inject]
|
||||
---
|
||||
|
||||
# Test Complex Composables with Host Component Wrapper
|
||||
|
||||
**Impact: MEDIUM** - Composables that use Vue lifecycle hooks (`onMounted`, `onUnmounted`) or dependency injection (`inject`) require a component context to function. Testing them directly will cause errors or incorrect behavior.
|
||||
|
||||
Simple composables using only reactivity APIs can be tested directly. Complex composables need a helper function that creates a host component context.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Identify if composable uses lifecycle hooks or inject
|
||||
- [ ] For simple composables (refs, computed only): test directly
|
||||
- [ ] For complex composables: use `withSetup` helper pattern
|
||||
- [ ] Clean up by unmounting the test app after each test
|
||||
- [ ] Use `app.provide()` to mock injected dependencies
|
||||
|
||||
**Simple Composable - Test Directly:**
|
||||
```javascript
|
||||
// composables/useCounter.js
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export function useCounter(initialValue = 0) {
|
||||
const count = ref(initialValue)
|
||||
const doubled = computed(() => count.value * 2)
|
||||
const increment = () => count.value++
|
||||
|
||||
return { count, doubled, increment }
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// useCounter.test.js
|
||||
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)
|
||||
})
|
||||
|
||||
it('increments count', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Complex Composable - Use Host Wrapper:**
|
||||
```javascript
|
||||
// composables/useFetch.js
|
||||
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
|
||||
|
||||
// Uses inject - needs component context
|
||||
const apiClient = inject('apiClient')
|
||||
|
||||
// Uses lifecycle hooks - needs component context
|
||||
onMounted(async () => {
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const response = await apiClient.get(url, { signal: controller.signal })
|
||||
data.value = response.data
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') error.value = e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
controller?.abort()
|
||||
})
|
||||
|
||||
return { data, error, loading }
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// test-utils.js
|
||||
import { createApp } from 'vue'
|
||||
|
||||
/**
|
||||
* Helper to test composables that need component context
|
||||
*/
|
||||
export function withSetup(composable) {
|
||||
let result
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = composable()
|
||||
// Return a render function to suppress warnings
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
|
||||
app.mount(document.createElement('div'))
|
||||
|
||||
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'
|
||||
|
||||
describe('useFetch', () => {
|
||||
let app
|
||||
const mockApiClient = {
|
||||
get: vi.fn()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// IMPORTANT: Clean up to trigger onUnmounted
|
||||
app?.unmount()
|
||||
})
|
||||
|
||||
it('fetches data on mount', async () => {
|
||||
mockApiClient.get.mockResolvedValue({ data: { id: 1, name: 'Test' } })
|
||||
|
||||
const [result, testApp] = withSetup(() => useFetch('/api/test'))
|
||||
app = testApp
|
||||
|
||||
// Provide mocked dependency
|
||||
app.provide('apiClient', mockApiClient)
|
||||
|
||||
// Wait for async operations
|
||||
await flushPromises()
|
||||
|
||||
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 [result, testApp] = withSetup(() => useFetch('/api/test'))
|
||||
app = testApp
|
||||
app.provide('apiClient', mockApiClient)
|
||||
|
||||
await flushPromises()
|
||||
|
||||
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
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = composable()
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
|
||||
// Apply global provides before mounting
|
||||
if (options.provide) {
|
||||
Object.entries(options.provide).forEach(([key, value]) => {
|
||||
app.provide(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
app.mount(document.createElement('div'))
|
||||
|
||||
return [result, app]
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [result, app] = withSetup(() => useMyComposable(), {
|
||||
provide: {
|
||||
apiClient: mockApiClient,
|
||||
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'
|
||||
|
||||
test('useFetch in component context', async () => {
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { data, loading } = useFetch('/api/users')
|
||||
return { data, loading }
|
||||
},
|
||||
template: '<div>{{ loading ? "Loading..." : data }}</div>'
|
||||
})
|
||||
|
||||
const wrapper = mount(TestComponent, {
|
||||
global: {
|
||||
provide: {
|
||||
apiClient: mockApiClient
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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/)
|
||||
@@ -0,0 +1,242 @@
|
||||
---
|
||||
title: Use Playwright for E2E Testing - Cross-Browser Support and Better DX
|
||||
impact: MEDIUM
|
||||
impactDescription: Cypress has browser limitations and some features require paid subscriptions
|
||||
type: best-practice
|
||||
tags: [vue3, testing, e2e, playwright, cypress, end-to-end]
|
||||
---
|
||||
|
||||
# Use Playwright for E2E Testing - Cross-Browser Support and Better DX
|
||||
|
||||
**Impact: MEDIUM** - Playwright offers superior cross-browser testing (Chromium, WebKit, Firefox), excellent debugging tools, and is fully open source. Cypress has limitations with WebKit support and requires paid subscriptions for some features.
|
||||
|
||||
Use Playwright for new E2E testing setups. Consider Cypress if team already has expertise or for its visual debugging UI.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Install Playwright with browsers for your target platforms
|
||||
- [ ] Configure for Vue dev server integration
|
||||
- [ ] Set up projects for different browsers
|
||||
- [ ] Use locator strategies that match component test patterns
|
||||
- [ ] Configure CI for parallel test execution
|
||||
- [ ] Use trace and screenshot features for debugging
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
# Install Playwright
|
||||
npm init playwright@latest
|
||||
|
||||
# This will create:
|
||||
# - playwright.config.ts
|
||||
# - tests/ directory
|
||||
# - tests-examples/ directory
|
||||
```
|
||||
|
||||
**playwright.config.ts:**
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
|
||||
use: {
|
||||
// Base URL for navigation
|
||||
baseURL: 'http://localhost:5173',
|
||||
// Capture trace on first retry
|
||||
trace: 'on-first-retry',
|
||||
// Screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
// Mobile viewports
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
],
|
||||
|
||||
// Run local dev server before tests
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## E2E Test Example
|
||||
|
||||
```typescript
|
||||
// e2e/user-flow.spec.ts
|
||||
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')
|
||||
|
||||
// Fill login form
|
||||
await page.getByLabel('Email').fill('user@example.com')
|
||||
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()
|
||||
})
|
||||
|
||||
test('shows error for invalid credentials', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
|
||||
await page.getByLabel('Email').fill('wrong@example.com')
|
||||
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')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
## Testing Vue Components with Data-Testid
|
||||
|
||||
```typescript
|
||||
// e2e/product-list.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('user can add product to cart', async ({ page }) => {
|
||||
await page.goto('/products')
|
||||
|
||||
// Use data-testid for reliable selectors
|
||||
await page.getByTestId('product-card').first().click()
|
||||
|
||||
// Verify product detail page
|
||||
await expect(page.getByTestId('product-title')).toBeVisible()
|
||||
|
||||
// Add to cart
|
||||
await page.getByTestId('add-to-cart-button').click()
|
||||
|
||||
// Verify cart updated
|
||||
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'
|
||||
|
||||
export class LoginPage {
|
||||
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')
|
||||
}
|
||||
|
||||
async goto() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// e2e/auth.spec.ts
|
||||
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('user@example.com', 'password123')
|
||||
|
||||
await expect(page).toHaveURL('/dashboard')
|
||||
})
|
||||
```
|
||||
|
||||
## Visual Regression Testing
|
||||
|
||||
```typescript
|
||||
test('homepage visual regression', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Full page screenshot comparison
|
||||
await expect(page).toHaveScreenshot('homepage.png')
|
||||
|
||||
// Element-specific screenshot
|
||||
await expect(page.getByTestId('hero-section')).toHaveScreenshot('hero.png')
|
||||
})
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npx playwright test
|
||||
|
||||
# Run in headed mode (see browser)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run specific file
|
||||
npx playwright test e2e/auth.spec.ts
|
||||
|
||||
# Run in specific browser
|
||||
npx playwright test --project=chromium
|
||||
|
||||
# Debug mode
|
||||
npx playwright test --debug
|
||||
|
||||
# Generate test from actions
|
||||
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)
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: Avoid Snapshot-Only Tests - They Don't Prove Correctness
|
||||
impact: MEDIUM
|
||||
impactDescription: Snapshot tests verify structure but not functionality, leading to false confidence and brittle tests
|
||||
type: best-practice
|
||||
tags: [vue3, testing, snapshot, vitest, vue-test-utils, anti-pattern]
|
||||
---
|
||||
|
||||
# Avoid Snapshot-Only Tests - They Don't Prove Correctness
|
||||
|
||||
**Impact: MEDIUM** - Snapshot tests only verify that HTML structure hasn't changed - they don't verify that the component works correctly. Relying exclusively on snapshots leads to false confidence and tests that break on any refactoring, even when functionality is preserved.
|
||||
|
||||
Use snapshots sparingly for regression detection. Prefer behavioral assertions that test what the component does.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Don't use snapshots as the only assertion for component behavior
|
||||
- [ ] Use snapshots for regression detection on stable UI components
|
||||
- [ ] Always pair snapshots with behavioral assertions
|
||||
- [ ] Keep snapshots small and focused (avoid full component snapshots)
|
||||
- [ ] Review snapshot diffs carefully - don't blindly update
|
||||
- [ ] Consider inline snapshots for small, critical structures
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
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: 'john@example.com' } }
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
// This test passes even if:
|
||||
// - The email isn't clickable
|
||||
// - The avatar doesn't load
|
||||
// - User actions are completely broken
|
||||
// - Accessibility is broken
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
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: 'john@example.com' } }
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="user-name"]').text()).toBe('John')
|
||||
expect(wrapper.find('[data-testid="user-email"]').text()).toBe('john@example.com')
|
||||
})
|
||||
|
||||
test('UserCard email link is clickable', async () => {
|
||||
const wrapper = mount(UserCard, {
|
||||
props: { user: { name: 'John', email: 'john@example.com' } }
|
||||
})
|
||||
|
||||
const emailLink = wrapper.find('a[href^="mailto:"]')
|
||||
expect(emailLink.exists()).toBe(true)
|
||||
expect(emailLink.attributes('href')).toBe('mailto:john@example.com')
|
||||
})
|
||||
|
||||
test('UserCard emits select event when clicked', async () => {
|
||||
const wrapper = mount(UserCard, {
|
||||
props: { user: { id: 1, name: 'John' } }
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
|
||||
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') }
|
||||
})
|
||||
|
||||
// Primary assertions - verify behavior
|
||||
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()
|
||||
})
|
||||
```
|
||||
|
||||
### 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' } })
|
||||
|
||||
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] }
|
||||
})
|
||||
|
||||
// Verify key behavior
|
||||
expect(wrapper.findAll('path').length).toBe(3)
|
||||
|
||||
// Snapshot for full SVG structure
|
||||
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 } }
|
||||
})
|
||||
|
||||
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!' }
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
test('form has accessible labels', () => {
|
||||
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()
|
||||
})
|
||||
```
|
||||
|
||||
## Snapshot Anti-Patterns
|
||||
|
||||
```javascript
|
||||
// ANTI-PATTERN: Giant component snapshot
|
||||
test('page renders', () => {
|
||||
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!
|
||||
})
|
||||
|
||||
// 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
|
||||
})
|
||||
```
|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: Configure Pinia Testing with createTestingPinia and setActivePinia
|
||||
impact: HIGH
|
||||
impactDescription: Missing Pinia configuration causes 'injection Symbol(pinia) not found' errors and failing tests
|
||||
type: gotcha
|
||||
tags: [vue3, testing, pinia, vitest, store, mocking, createTestingPinia]
|
||||
---
|
||||
|
||||
# Configure Pinia Testing with createTestingPinia and setActivePinia
|
||||
|
||||
**Impact: HIGH** - Testing components or composables that use Pinia stores without proper configuration results in "[Vue warn]: injection Symbol(pinia) not found" errors. Tests will fail or behave unexpectedly.
|
||||
|
||||
Use `@pinia/testing` package with `createTestingPinia` for component tests and `setActivePinia(createPinia())` for unit testing stores directly.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Install `@pinia/testing` as a dev dependency
|
||||
- [ ] Use `createTestingPinia` in component tests with `global.plugins`
|
||||
- [ ] Use `setActivePinia(createPinia())` in `beforeEach` for store unit tests
|
||||
- [ ] Configure `createSpy: vi.fn` when NOT using `globals: true` in Vitest
|
||||
- [ ] Initialize store inside each test to get fresh state
|
||||
- [ ] Use `stubActions: false` when you need real action execution
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
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')
|
||||
})
|
||||
```
|
||||
|
||||
```javascript
|
||||
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')
|
||||
})
|
||||
```
|
||||
|
||||
**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'
|
||||
|
||||
// CORRECT: Provide testing pinia with stubbed actions
|
||||
test('displays user name', () => {
|
||||
const wrapper = mount(UserProfile, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn, // Required if not using globals: true
|
||||
initialState: {
|
||||
user: { name: 'John', email: 'john@example.com' }
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
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 })]
|
||||
}
|
||||
})
|
||||
|
||||
// Get store AFTER mounting with createTestingPinia
|
||||
const store = useUserStore()
|
||||
|
||||
await wrapper.find('[data-testid="logout"]').trigger('click')
|
||||
|
||||
// Actions are stubbed and wrapped in spies
|
||||
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'
|
||||
|
||||
describe('User Store', () => {
|
||||
beforeEach(() => {
|
||||
// Create fresh Pinia instance for each test
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('initializes with empty user', () => {
|
||||
const store = useUserStore()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('updates user on login', async () => {
|
||||
const store = useUserStore()
|
||||
|
||||
// Real action executes - not stubbed
|
||||
await store.login('john', 'password')
|
||||
|
||||
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
|
||||
|
||||
store.logout()
|
||||
|
||||
expect(store.user).toBeNull()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Testing with Real Actions vs Stubbed Actions
|
||||
|
||||
```javascript
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
// Stubbed actions (default) - for isolation
|
||||
const wrapper = mount(Component, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
// stubActions: true (default) - actions are mocked
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Real actions - for integration testing
|
||||
const wrapper = mount(Component, {
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
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'
|
||||
|
||||
test('handles checkout failure', async () => {
|
||||
const wrapper = mount(Checkout, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })]
|
||||
}
|
||||
})
|
||||
|
||||
const cartStore = useCartStore()
|
||||
|
||||
// Mock specific action behavior
|
||||
cartStore.checkout.mockRejectedValue(new Error('Payment failed'))
|
||||
|
||||
await wrapper.find('[data-testid="checkout"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
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'
|
||||
|
||||
test('tracks action calls', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useUserStore()
|
||||
|
||||
const loginSpy = vi.spyOn(store, 'login')
|
||||
loginSpy.mockResolvedValue({ success: true })
|
||||
|
||||
await store.login('john', 'password')
|
||||
|
||||
expect(loginSpy).toHaveBeenCalledWith('john', 'password')
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Store $subscribe
|
||||
|
||||
```javascript
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
test('subscription triggers on state change', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useUserStore()
|
||||
|
||||
const callback = vi.fn()
|
||||
store.$subscribe(callback)
|
||||
|
||||
store.user = { name: 'John' }
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
title: Wrap Async Setup Components in Suspense for Testing
|
||||
impact: HIGH
|
||||
impactDescription: Components with async setup() fail to render in tests without Suspense wrapper, causing cryptic errors
|
||||
type: gotcha
|
||||
tags: [vue3, testing, suspense, async-setup, vue-test-utils, vitest]
|
||||
---
|
||||
|
||||
# Wrap Async Setup Components in Suspense for Testing
|
||||
|
||||
**Impact: HIGH** - Components using `async setup()` require a `<Suspense>` wrapper to function correctly. Testing them without Suspense causes the component to never render, leading to test failures and confusing errors.
|
||||
|
||||
Create a test wrapper component with Suspense or use a `mountSuspense` helper function for testing async components.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Identify components with async setup (uses `await` in `<script setup>` or `async setup()`)
|
||||
- [ ] Create a wrapper component with `<Suspense>` for testing
|
||||
- [ ] Use `flushPromises()` after mounting to wait for async resolution
|
||||
- [ ] Access the actual component via `findComponent()` for assertions
|
||||
- [ ] Consider using `@testing-library/vue` with caution (has Suspense issues)
|
||||
|
||||
**Incorrect:**
|
||||
```javascript
|
||||
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 }
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// This fails - component never rendered
|
||||
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'
|
||||
|
||||
test('displays user data', async () => {
|
||||
// Create wrapper component with Suspense
|
||||
const TestWrapper = defineComponent({
|
||||
components: { AsyncUserProfile },
|
||||
template: `
|
||||
<Suspense>
|
||||
<AsyncUserProfile :user-id="1" />
|
||||
<template #fallback>Loading...</template>
|
||||
</Suspense>
|
||||
`
|
||||
})
|
||||
|
||||
const wrapper = mount(TestWrapper)
|
||||
|
||||
// Initially shows fallback
|
||||
expect(wrapper.text()).toContain('Loading...')
|
||||
|
||||
// Wait for async setup to complete
|
||||
await flushPromises()
|
||||
|
||||
// Find the actual component for detailed assertions
|
||||
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'
|
||||
|
||||
export async function mountSuspense(component, options = {}) {
|
||||
const { props, slots, ...mountOptions } = options
|
||||
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
render() {
|
||||
return h(
|
||||
Suspense,
|
||||
null,
|
||||
{
|
||||
default: () => h(component, props, slots),
|
||||
fallback: () => h('div', 'Loading...')
|
||||
}
|
||||
)
|
||||
}
|
||||
}),
|
||||
mountOptions
|
||||
)
|
||||
|
||||
// Wait for async component to resolve
|
||||
await flushPromises()
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
// Provide easy access to the actual component
|
||||
component: wrapper.findComponent(component)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// AsyncUserProfile.test.js
|
||||
import { mountSuspense } from './test-utils'
|
||||
import AsyncUserProfile from './AsyncUserProfile.vue'
|
||||
|
||||
test('displays user data', async () => {
|
||||
const { component } = await mountSuspense(AsyncUserProfile, {
|
||||
props: { userId: 1 },
|
||||
global: {
|
||||
stubs: {
|
||||
// Stub any child components if needed
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(component.find('.username').text()).toBe('John')
|
||||
})
|
||||
|
||||
test('handles errors gracefully', async () => {
|
||||
const { component } = await mountSuspense(AsyncUserProfile, {
|
||||
props: { userId: 'invalid' }
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
test('catches async errors', async () => {
|
||||
const capturedError = ref(null)
|
||||
|
||||
const TestWrapper = defineComponent({
|
||||
setup() {
|
||||
onErrorCaptured((error) => {
|
||||
capturedError.value = error
|
||||
return true // Prevent error propagation
|
||||
})
|
||||
return { capturedError }
|
||||
},
|
||||
render() {
|
||||
return h(Suspense, null, {
|
||||
default: () => h(AsyncComponent, { shouldFail: true }),
|
||||
fallback: () => h('div', 'Loading...')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(TestWrapper)
|
||||
await flushPromises()
|
||||
|
||||
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'
|
||||
|
||||
test('renders async page', async () => {
|
||||
const wrapper = await mountSuspended(AsyncPage, {
|
||||
props: { id: 1 }
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
test('async component with testing library', async () => {
|
||||
const TestWrapper = {
|
||||
template: `
|
||||
<Suspense>
|
||||
<AsyncComponent />
|
||||
</Suspense>
|
||||
`,
|
||||
components: { AsyncComponent }
|
||||
}
|
||||
|
||||
const { getByText } = render(TestWrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Loaded content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Accessing Component Instance
|
||||
```javascript
|
||||
test('access vm on async component', async () => {
|
||||
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')
|
||||
})
|
||||
```
|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
title: Use Vitest for Vue 3 Testing - Recommended by Vue Team
|
||||
impact: MEDIUM
|
||||
impactDescription: Using Jest or other runners with Vite projects requires complex configuration and causes slower test runs
|
||||
type: best-practice
|
||||
tags: [vue3, testing, vitest, vite, configuration, setup]
|
||||
---
|
||||
|
||||
# Use Vitest for Vue 3 Testing - Recommended by Vue Team
|
||||
|
||||
**Impact: MEDIUM** - Vitest is created and maintained by Vue/Vite team members and shares the same configuration and transform pipeline as Vite. Using Jest or other test runners with Vite-based projects requires additional configuration and can result in slower test execution and compatibility issues.
|
||||
|
||||
Use Vitest for new Vue 3 projects. Only consider Jest if migrating an existing test suite.
|
||||
|
||||
## Task Checklist
|
||||
|
||||
- [ ] Install Vitest and related packages for Vue testing
|
||||
- [ ] Configure vitest in vite.config.js or vitest.config.js
|
||||
- [ ] Set up proper test environment (happy-dom or jsdom)
|
||||
- [ ] Add test scripts to package.json
|
||||
- [ ] Configure globals if desired for cleaner test syntax
|
||||
- [ ] Use @vue/test-utils for component mounting
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
# Install required packages
|
||||
npm install -D vitest @vue/test-utils happy-dom
|
||||
# or with jsdom
|
||||
npm install -D vitest @vue/test-utils jsdom
|
||||
```
|
||||
|
||||
**vite.config.js:**
|
||||
```javascript
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
// Enable global test APIs (describe, it, expect)
|
||||
globals: true,
|
||||
// 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']
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**package.json:**
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**tsconfig.json (if using TypeScript):**
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test File Example
|
||||
|
||||
```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'
|
||||
|
||||
describe('Counter', () => {
|
||||
let wrapper
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mount(Counter)
|
||||
})
|
||||
|
||||
it('renders initial count', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
## Using with Testing Library
|
||||
|
||||
```bash
|
||||
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'
|
||||
|
||||
expect.extend(matchers)
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Component.test.js
|
||||
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||
import UserCard from './UserCard.vue'
|
||||
|
||||
test('displays user name', () => {
|
||||
render(UserCard, {
|
||||
props: { name: 'John Doe' }
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
exclude: ['node_modules', 'dist', 'e2e'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['node_modules', 'test']
|
||||
},
|
||||
// Helpful for debugging
|
||||
reporters: ['verbose'],
|
||||
// Run tests in sequence in CI
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: process.env.CI === 'true'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Mocking Modules
|
||||
```javascript
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('@/api/users', () => ({
|
||||
fetchUser: vi.fn().mockResolvedValue({ name: 'John' })
|
||||
}))
|
||||
```
|
||||
|
||||
### Testing with Fake Timers
|
||||
```javascript
|
||||
import { vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('debounced search', async () => {
|
||||
const wrapper = mount(SearchBox)
|
||||
await wrapper.find('input').setValue('vue')
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
await flushPromises()
|
||||
|
||||
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