Initial commit
This commit is contained in:
11
.claude-plugin/plugin.json
Normal file
11
.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "vue-development",
|
||||||
|
"description": "Modern Vue 3 development with TypeScript, Composition API, defineModel bindings, Testing Library for user-behavior tests, and MSW for API mocking",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Alexander Opalic"
|
||||||
|
},
|
||||||
|
"skills": [
|
||||||
|
"./skills"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# vue-development
|
||||||
|
|
||||||
|
Modern Vue 3 development with TypeScript, Composition API, defineModel bindings, Testing Library for user-behavior tests, and MSW for API mocking
|
||||||
65
plugin.lock.json
Normal file
65
plugin.lock.json
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"$schema": "internal://schemas/plugin.lock.v1.json",
|
||||||
|
"pluginId": "gh:alexanderop/claude-skill-vue-development:",
|
||||||
|
"normalized": {
|
||||||
|
"repo": null,
|
||||||
|
"ref": "refs/tags/v20251128.0",
|
||||||
|
"commit": "3f58c20ca2ce28a3c886441685b5954af4f654ac",
|
||||||
|
"treeHash": "286d540ef398ac40c370d75780e0a4c32e8ebc3148552962fc7dd38b2a3ccebd",
|
||||||
|
"generatedAt": "2025-11-28T10:13:08.623095Z",
|
||||||
|
"toolVersion": "publish_plugins.py@0.2.0"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"remote": "git@github.com:zhongweili/42plugin-data.git",
|
||||||
|
"branch": "master",
|
||||||
|
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
|
||||||
|
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"name": "vue-development",
|
||||||
|
"description": "Modern Vue 3 development with TypeScript, Composition API, defineModel bindings, Testing Library for user-behavior tests, and MSW for API mocking",
|
||||||
|
"version": "0.1.0"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "README.md",
|
||||||
|
"sha256": "f97c3be6b3381e1435d44e349aa6f8472d1a50204d0a9d602d723cc0428c56d6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude-plugin/plugin.json",
|
||||||
|
"sha256": "d66c1859e29a64990f3a89d8e8355195c7478811c1f41b9524662a55fd3b18c3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/vue-development/SKILL.md",
|
||||||
|
"sha256": "dad7a14c95f295e574ca07956bc2efbc0b7f4378203ec8ac96fb81d70cf25e0a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/vue-development/references/component-patterns.md",
|
||||||
|
"sha256": "dab63d0f2396affb33e94d3c0c957b6c5dedea474c89447f426ad388aea5987c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/vue-development/references/composable-patterns.md",
|
||||||
|
"sha256": "e47333e3d1658a6fbf5468c3e019731f79affafdfc01244fe316dade9dfc9bd6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/vue-development/references/testing-composables.md",
|
||||||
|
"sha256": "c8d30bd51ba855ed49ccddc6c4fe7873ad491b8c91040b9b50da274f44af8522"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/vue-development/references/testing-patterns.md",
|
||||||
|
"sha256": "e03d9d52365d39e9433d674090ca14edb03a806623a32029fb0148b7adb63efc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "skills/vue-development/references/routing-patterns.md",
|
||||||
|
"sha256": "ed00d690c6794ab7f0df89aed8b862dc999e67ffd40f2cc8a7ac1ee6a147228c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dirSha256": "286d540ef398ac40c370d75780e0a4c32e8ebc3148552962fc7dd38b2a3ccebd"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"scannedAt": null,
|
||||||
|
"scannerVersion": null,
|
||||||
|
"flags": []
|
||||||
|
}
|
||||||
|
}
|
||||||
123
skills/vue-development/SKILL.md
Normal file
123
skills/vue-development/SKILL.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
name: vue-development
|
||||||
|
description: Use when planning or implementing Vue 3 projects - helps architect component structure, plan feature implementation, and enforce TypeScript-first patterns with Composition API, defineModel for bindings, Testing Library for user-behavior tests, and MSW for API mocking. Especially useful in planning phase to guide proper patterns before writing code.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vue Development
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Modern Vue 3 development with TypeScript, Composition API, and user-behavior testing. **Core principle:** Use TypeScript generics (not runtime validation), modern APIs (defineModel not manual props), and test user behavior (not implementation details).
|
||||||
|
|
||||||
|
## Red Flags - STOP and Fix
|
||||||
|
|
||||||
|
If you catch yourself thinking or doing ANY of these, STOP:
|
||||||
|
|
||||||
|
- "For speed" / "quick demo" / "emergency" → Using shortcuts
|
||||||
|
- "We can clean it up later" → Accepting poor patterns
|
||||||
|
- "TypeScript is too verbose" → Skipping types
|
||||||
|
- "This is production-ready" → Without type safety
|
||||||
|
- "Following existing code style" → When existing code uses legacy patterns
|
||||||
|
- "Task explicitly stated..." → Following bad requirements literally
|
||||||
|
- Using `const props = defineProps()` without using props in script
|
||||||
|
- Manual `modelValue` prop + `update:modelValue` emit → Use defineModel()
|
||||||
|
- "Component that takes value and emits changes" → Use defineModel(), NOT manual props/emit
|
||||||
|
- Using runtime prop validation when TypeScript is available
|
||||||
|
- Array syntax for emits: `defineEmits(['event'])` → Missing type safety
|
||||||
|
- `setTimeout()` in tests → Use proper async utilities
|
||||||
|
- Testing `wrapper.vm.*` internal state → Test user-visible behavior
|
||||||
|
- Using `index.vue` in routes → Use route groups `(name).vue`
|
||||||
|
- Generic route params `[id]` → Use explicit `[userId]`, `[postSlug]`
|
||||||
|
- Composables calling `showToast()`, `alert()`, or modals → Expose error state, component handles UI
|
||||||
|
- External composable used in only ONE component → Start inline, extract when reused
|
||||||
|
|
||||||
|
**All of these mean: Use the modern pattern. No exceptions.**
|
||||||
|
|
||||||
|
## Quick Rules
|
||||||
|
|
||||||
|
**Components:** `defineProps<{ }>()` (no const unless used in script), `defineEmits<{ event: [args] }>()`, `defineModel<type>()` for v-model. See @references/component-patterns.md
|
||||||
|
|
||||||
|
**Testing:** `@testing-library/vue` + MSW. Use `findBy*` or `waitFor()` for async. NEVER `setTimeout()` or test internal state. See @references/testing-patterns.md
|
||||||
|
|
||||||
|
**Routing:** Explicit params `[userId]` not `[id]`. Avoid `index.vue`, use `(name).vue`. Use `.` for nesting: `users.edit.vue` → `/users/edit`. See @references/routing-patterns.md
|
||||||
|
|
||||||
|
**Composables:** START INLINE for component-specific logic, extract to external file when reused. External composables: prefix `use`, NO UI logic (expose error state instead). See @references/composable-patterns.md
|
||||||
|
|
||||||
|
## Key Pattern: defineModel()
|
||||||
|
|
||||||
|
The most important pattern to remember - use for ALL two-way binding:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ✅ For simple v-model
|
||||||
|
const value = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
// ✅ For multiple v-models
|
||||||
|
const firstName = defineModel<string>('firstName')
|
||||||
|
const lastName = defineModel<string>('lastName')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input v-model="value" />
|
||||||
|
<!-- Parent uses: <Component v-model="data" /> -->
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Reduces 5 lines of boilerplate to 1. No manual `modelValue` prop + `update:modelValue` emit.
|
||||||
|
|
||||||
|
## Component Implementation Workflow
|
||||||
|
|
||||||
|
When implementing complex Vue components, use TodoWrite to track progress:
|
||||||
|
|
||||||
|
```
|
||||||
|
TodoWrite checklist for component implementation:
|
||||||
|
- [ ] Define TypeScript interfaces for props/emits/models
|
||||||
|
- [ ] Implement props with defineProps<{ }>() (no const unless used in script)
|
||||||
|
- [ ] Implement emits with defineEmits<{ event: [args] }>()
|
||||||
|
- [ ] Add v-model with defineModel<type>() if needed
|
||||||
|
- [ ] Write user-behavior tests with Testing Library
|
||||||
|
- [ ] Test async behavior with findBy* queries or waitFor()
|
||||||
|
- [ ] Verify: No red flags, no setTimeout in tests, all types present
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to create TodoWrite todos:**
|
||||||
|
- Implementing new components with state, v-model, and testing
|
||||||
|
- Refactoring components to modern patterns
|
||||||
|
- Adding routing with typed params
|
||||||
|
- Creating composables with async logic
|
||||||
|
|
||||||
|
## Rationalizations Table
|
||||||
|
|
||||||
|
| Excuse | Reality |
|
||||||
|
|--------|---------|
|
||||||
|
| "For speed/emergency/no time" | Correct patterns take SAME time. TypeScript IS fast. |
|
||||||
|
| "TypeScript is too verbose" | `defineProps<{ count: number }>()` is LESS code. |
|
||||||
|
| "We can clean it up later" | Write it right the first time. |
|
||||||
|
| "This is production-ready" | Without type safety, it's not production-ready. |
|
||||||
|
| "Simple array syntax is fine" | Missing types = runtime errors TypeScript would catch. |
|
||||||
|
| "Manual modelValue was correct" | That was Vue 2. Use defineModel() in Vue 3.4+. |
|
||||||
|
| "Tests are flaky, add timeout" | Timeouts mask bugs. Use proper async handling. |
|
||||||
|
| "Following existing code style" | Legacy code exists. Use modern patterns to improve. |
|
||||||
|
| "Task explicitly stated X" | Understand INTENT. Bad requirements need good implementation. |
|
||||||
|
| "Composables can show toasts" | UI belongs in components. Expose error state. |
|
||||||
|
| "[id] is industry standard" | Explicit names prevent bugs, enable TypeScript autocomplete. |
|
||||||
|
| "counter.ts is fine" | Must prefix with 'use': useCounter.ts |
|
||||||
|
| "test-utils is the standard" | Testing Library is gold standard for user-behavior. |
|
||||||
|
|
||||||
|
## Detailed References
|
||||||
|
|
||||||
|
See @references/ directory for comprehensive guides: component-patterns.md, testing-patterns.md, testing-composables.md, routing-patterns.md, composable-patterns.md
|
||||||
|
|
||||||
|
|
||||||
|
## When NOT to Use This Skill
|
||||||
|
|
||||||
|
- Vue 2 projects (different API)
|
||||||
|
- Options API codebases (this is Composition API focused)
|
||||||
|
- Projects without TypeScript (though you should add it)
|
||||||
|
|
||||||
|
## Real-World Impact
|
||||||
|
|
||||||
|
**Baseline:** 37.5% correct patterns under pressure
|
||||||
|
**With skill:** 100% correct patterns under pressure
|
||||||
|
|
||||||
|
Type safety prevents runtime errors. defineModel() reduces boilerplate. Testing Library catches real user issues.
|
||||||
204
skills/vue-development/references/component-patterns.md
Normal file
204
skills/vue-development/references/component-patterns.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# Vue Component Patterns Reference
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Props Definition](#props-definition)
|
||||||
|
- [Emits Definition](#emits-definition)
|
||||||
|
- [V-Model Bindings](#v-model-bindings)
|
||||||
|
- [Template Patterns](#template-patterns)
|
||||||
|
- [Component Naming](#component-naming)
|
||||||
|
- [V-Model Modifiers](#v-model-modifiers)
|
||||||
|
|
||||||
|
## Props Definition
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ✅ CORRECT: TypeScript generics, no const (props not used in script)
|
||||||
|
defineProps<{
|
||||||
|
userId: number
|
||||||
|
userName: string
|
||||||
|
isActive?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ✅ CORRECT: With const ONLY if props used in script
|
||||||
|
const props = defineProps<{
|
||||||
|
count: number
|
||||||
|
}>()
|
||||||
|
console.log(props.count) // Used in script
|
||||||
|
|
||||||
|
// ✅ CORRECT: With defaults (requires destructuring)
|
||||||
|
const { theme = 'light', size = 'medium' } = defineProps<{
|
||||||
|
theme?: string
|
||||||
|
size?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ❌ WRONG: Runtime validation when TypeScript available
|
||||||
|
const props = defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object as PropType<User>,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ❌ WRONG: const when props not used
|
||||||
|
const props = defineProps<{ count: number }>()
|
||||||
|
// props never referenced in script
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- ALWAYS use TypeScript generics: `defineProps<{ }>()`
|
||||||
|
- Use `const props =` ONLY if props referenced in script block
|
||||||
|
- NEVER use runtime validation (`type`, `required`, `PropType`) when TypeScript available
|
||||||
|
- Destructure ONLY for default values
|
||||||
|
|
||||||
|
## Emits Definition
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ✅ CORRECT: TypeScript with event payloads
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [formData: FormData]
|
||||||
|
cancel: []
|
||||||
|
update: [userId: number, changes: Partial<User>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
emit('submit', formData)
|
||||||
|
emit('cancel')
|
||||||
|
emit('update', 123, { name: 'Updated' })
|
||||||
|
|
||||||
|
// ❌ WRONG: Array syntax without types
|
||||||
|
const emit = defineEmits(['submit', 'cancel'])
|
||||||
|
|
||||||
|
// ❌ WRONG: Runtime validation
|
||||||
|
const emit = defineEmits({
|
||||||
|
submit: (payload: FormData) => true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- ALWAYS use `const emit = defineEmits<{ }>()`
|
||||||
|
- ALWAYS define event payload types: `eventName: [arg1: type, arg2: type]` or `eventName: []`
|
||||||
|
- ALWAYS use camelCase in script, kebab-case in templates
|
||||||
|
- Use semantic event names: `submit`, `delete`, `userUpdated` (not generic `update`)
|
||||||
|
|
||||||
|
## V-Model Bindings
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ✅ CORRECT: Simple v-model
|
||||||
|
const title = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
// ✅ CORRECT: With options
|
||||||
|
const [count, modifiers] = defineModel<number>({
|
||||||
|
default: 0,
|
||||||
|
get: (value) => Math.max(0, value), // transform on read
|
||||||
|
set: (value) => {
|
||||||
|
if (modifiers.even) {
|
||||||
|
return Math.round(value / 2) * 2
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ CORRECT: Multiple v-models
|
||||||
|
const firstName = defineModel<string>('firstName')
|
||||||
|
const age = defineModel<number>('age')
|
||||||
|
// Usage: <UserForm v-model:first-name="user.firstName" v-model:age="user.age" />
|
||||||
|
|
||||||
|
// ❌ WRONG: Manual modelValue implementation
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
// Should use: const modelValue = defineModel<string>()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- ALWAYS use `defineModel<type>()` for v-model bindings
|
||||||
|
- NEVER manually define `modelValue` prop + `update:modelValue` emit
|
||||||
|
- Use named models for multiple bindings: `defineModel<type>('name')`
|
||||||
|
- defineModel handles all prop/emit wiring automatically
|
||||||
|
|
||||||
|
## Template Patterns
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- ✅ CORRECT: Prop shorthand -->
|
||||||
|
<UserCard :user-id />
|
||||||
|
|
||||||
|
<!-- ❌ WRONG: Verbose when value matches prop name -->
|
||||||
|
<UserCard :user-id="userId" />
|
||||||
|
|
||||||
|
<!-- ✅ CORRECT: Explicit template tags with shorthand slots -->
|
||||||
|
<Card>
|
||||||
|
<template #header>
|
||||||
|
<h2>Title</h2>
|
||||||
|
</template>
|
||||||
|
<template #default>
|
||||||
|
<p>Content</p>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ❌ WRONG: v-slot longhand -->
|
||||||
|
<Card>
|
||||||
|
<template v-slot:header>...</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ❌ WRONG: Implicit default slot (missing template tag) -->
|
||||||
|
<Card>
|
||||||
|
<p>Content</p>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Use `:propName` when value variable matches prop name (instead of `:propName="propName"`)
|
||||||
|
- ALWAYS use explicit `<template>` tags for ALL slots
|
||||||
|
- ALWAYS use shorthand: `#slotName` instead of `v-slot:slotName`
|
||||||
|
- Use kebab-case for props/events in templates, camelCase in script
|
||||||
|
|
||||||
|
## Component Naming
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ CORRECT:
|
||||||
|
src/components/
|
||||||
|
UserProfile.vue (PascalCase)
|
||||||
|
SearchButtonClear.vue (General → Specific)
|
||||||
|
|
||||||
|
✅ ALSO CORRECT:
|
||||||
|
src/components/
|
||||||
|
user-profile.vue (kebab-case)
|
||||||
|
search-button-clear.vue
|
||||||
|
|
||||||
|
❌ WRONG:
|
||||||
|
ClearSearchButton.vue (Specific → General)
|
||||||
|
userProfile.vue (camelCase)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- File names: PascalCase OR kebab-case (be consistent in project)
|
||||||
|
- Component names in script: ALWAYS PascalCase
|
||||||
|
- Compose names from general to specific: `SearchButtonClear` not `ClearSearchButton`
|
||||||
|
|
||||||
|
## V-Model Modifiers
|
||||||
|
|
||||||
|
For custom modifiers, fetch the official docs:
|
||||||
|
<https://vuejs.org/guide/components/v-model.md#handling-v-model-modifiers>
|
||||||
|
|
||||||
|
Basic pattern:
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const [modelValue, modifiers] = defineModel<string>({
|
||||||
|
set(value) {
|
||||||
|
if (modifiers.capitalize) {
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
791
skills/vue-development/references/composable-patterns.md
Normal file
791
skills/vue-development/references/composable-patterns.md
Normal file
@@ -0,0 +1,791 @@
|
|||||||
|
# Vue Composables Best Practices Reference
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [What is a Composable?](#what-is-a-composable)
|
||||||
|
- [Inline vs External Composables](#inline-vs-external-composables)
|
||||||
|
- [File Naming and Structure](#file-naming-and-structure)
|
||||||
|
- [Composable Anatomy](#composable-anatomy)
|
||||||
|
- [Single Responsibility Principle](#single-responsibility-principle)
|
||||||
|
- [Argument Passing](#argument-passing)
|
||||||
|
- [Error Handling](#error-handling)
|
||||||
|
- [Separation of Concerns: No UI Logic](#separation-of-concerns-no-ui-logic-in-composables)
|
||||||
|
- [Functional Core, Imperative Shell](#functional-core-imperative-shell-optional-pattern)
|
||||||
|
- [Consistent File Structure](#consistent-file-structure)
|
||||||
|
- [Composable Composition](#composable-composition)
|
||||||
|
- [Return Values](#return-values)
|
||||||
|
- [TypeScript Best Practices](#typescript-best-practices)
|
||||||
|
- [Common Patterns](#common-patterns)
|
||||||
|
- [Testing Composables](#testing-composables)
|
||||||
|
- [Common Mistakes](#common-mistakes)
|
||||||
|
- [Quick Checklist](#quick-checklist)
|
||||||
|
|
||||||
|
## What is a Composable?
|
||||||
|
|
||||||
|
Composables encapsulate reusable stateful logic using Vue's Composition API.
|
||||||
|
|
||||||
|
## Inline vs External Composables
|
||||||
|
|
||||||
|
**CRITICAL PRINCIPLE:** Start with inline composables for component-specific logic. Only extract to external files when the logic is reused in multiple components.
|
||||||
|
|
||||||
|
### The Decision Framework
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Component-specific logic stays INLINE
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Inline composable - used only in this component
|
||||||
|
function useHiddenFolders() {
|
||||||
|
const showHidden = ref(localStorage.getItem('show-hidden') === 'true')
|
||||||
|
|
||||||
|
watch(showHidden, (value) => {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem('show-hidden', 'true')
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('show-hidden')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { showHidden }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the inline composable
|
||||||
|
const { showHidden } = useHiddenFolders()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
// ❌ WRONG: Extracting to external file when used in ONE component
|
||||||
|
// src/composables/useHiddenFolders.ts - only used in one place!
|
||||||
|
export function useHiddenFolders() { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to Use Each Approach
|
||||||
|
|
||||||
|
| Pattern | When to Use | Example |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| **Inline composable** | Logic specific to ONE component | Form validation, local UI state, component-specific data fetching |
|
||||||
|
| **External composable** | Logic reused in 2+ components | Authentication, global state, shared API calls |
|
||||||
|
|
||||||
|
### Real-World Example: FolderManager Component
|
||||||
|
|
||||||
|
This example shows inline composables organizing component logic:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useQuery, mutate } from 'vue-apollo'
|
||||||
|
|
||||||
|
// External composables (reusable across app)
|
||||||
|
import { useNetworkState } from '@/composables/useNetworkState'
|
||||||
|
|
||||||
|
// GraphQL queries
|
||||||
|
import FOLDERS_FAVORITE from '@/graphql/folder/favoriteFolders.gql'
|
||||||
|
import FOLDER_SET_FAVORITE from '@/graphql/folder/folderSetFavorite.gql'
|
||||||
|
|
||||||
|
// Network state (reusable)
|
||||||
|
const { networkState } = useNetworkState()
|
||||||
|
|
||||||
|
// Component-specific logic as inline composables
|
||||||
|
const { showHiddenFolders } = useHiddenFolders()
|
||||||
|
const { favoriteFolders, toggleFavorite } = useFavoriteFolders()
|
||||||
|
|
||||||
|
// Inline composable #1: Hidden folders management
|
||||||
|
function useHiddenFolders() {
|
||||||
|
const showHiddenFolders = ref(
|
||||||
|
localStorage.getItem('show-hidden-folders') === 'true'
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(showHiddenFolders, (value) => {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem('show-hidden-folders', 'true')
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('show-hidden-folders')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { showHiddenFolders }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline composable #2: Favorite folders management
|
||||||
|
function useFavoriteFolders() {
|
||||||
|
const favoriteFolders = useQuery(FOLDERS_FAVORITE, [])
|
||||||
|
|
||||||
|
async function toggleFavorite(folderPath: string) {
|
||||||
|
await mutate({
|
||||||
|
mutation: FOLDER_SET_FAVORITE,
|
||||||
|
variables: { path: folderPath }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { favoriteFolders, toggleFavorite }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<input v-model="showHiddenFolders" type="checkbox" />
|
||||||
|
<ul>
|
||||||
|
<li v-for="folder in favoriteFolders" :key="folder.path">
|
||||||
|
{{ folder.name }}
|
||||||
|
<button @click="toggleFavorite(folder.path)">Toggle</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits of Inline Composables
|
||||||
|
|
||||||
|
**Readability:**
|
||||||
|
- Related logic grouped together
|
||||||
|
- Clear component structure at a glance
|
||||||
|
- No need to jump between files
|
||||||
|
|
||||||
|
**Maintainability:**
|
||||||
|
- Changes stay in one file
|
||||||
|
- No premature abstraction
|
||||||
|
- Easier to understand component behavior
|
||||||
|
|
||||||
|
**Flexibility:**
|
||||||
|
- Easy to refactor when needed
|
||||||
|
- Can access component-specific imports
|
||||||
|
- Simple to extract later if reused
|
||||||
|
|
||||||
|
### When to Extract to External File
|
||||||
|
|
||||||
|
Extract an inline composable to `src/composables/` when:
|
||||||
|
|
||||||
|
1. **Used in 2+ components** - Actual reuse, not "might be reused"
|
||||||
|
2. **Shared business logic** - Authentication, API patterns, etc.
|
||||||
|
3. **Testing isolation needed** - Complex logic requiring dedicated tests
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// After you use the SAME logic in a second component, extract it:
|
||||||
|
|
||||||
|
// src/composables/useHiddenFolders.ts
|
||||||
|
export function useHiddenFolders() {
|
||||||
|
const showHiddenFolders = ref(
|
||||||
|
localStorage.getItem('show-hidden-folders') === 'true'
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(showHiddenFolders, (value) => {
|
||||||
|
if (value) {
|
||||||
|
localStorage.setItem('show-hidden-folders', 'true')
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('show-hidden-folders')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { showHiddenFolders }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Anti-Patterns
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ WRONG: Extracting too early
|
||||||
|
// Creating external composable used in only ONE component
|
||||||
|
// src/composables/useComponentSpecificThing.ts - only used once!
|
||||||
|
|
||||||
|
// ❌ WRONG: Not organizing component logic
|
||||||
|
// Flat <script setup> with mixed concerns
|
||||||
|
<script setup lang="ts">
|
||||||
|
const showHidden = ref(localStorage.getItem('show-hidden') === 'true')
|
||||||
|
watch(showHidden, (value) => { /* ... */ })
|
||||||
|
|
||||||
|
const favoriteFolders = useQuery(FOLDERS_FAVORITE, [])
|
||||||
|
async function toggleFavorite() { /* ... */ }
|
||||||
|
|
||||||
|
// ...hundreds of lines of mixed logic
|
||||||
|
</script>
|
||||||
|
|
||||||
|
// ✅ CORRECT: Inline composables organize component
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { showHidden } = useHiddenFolders()
|
||||||
|
const { favoriteFolders, toggleFavorite } = useFavoriteFolders()
|
||||||
|
|
||||||
|
function useHiddenFolders() { /* grouped logic */ }
|
||||||
|
function useFavoriteFolders() { /* grouped logic */ }
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Decision Checklist
|
||||||
|
|
||||||
|
- [ ] Is this logic used in only ONE component? → Inline composable
|
||||||
|
- [ ] Does it organize complex component logic? → Inline composable
|
||||||
|
- [ ] Is it actually reused in 2+ components NOW? → External composable
|
||||||
|
- [ ] Is it shared business logic (auth, API)? → External composable
|
||||||
|
- [ ] Might it be reused someday? → Keep inline until that day comes
|
||||||
|
|
||||||
|
**Remember:** Premature extraction is premature optimization. Start inline, extract when you have proof of reuse.
|
||||||
|
|
||||||
|
## File Naming and Structure
|
||||||
|
|
||||||
|
**Note:** This section applies to **external composables** in `src/composables/`. Inline composables (defined within components) don't need separate files.
|
||||||
|
|
||||||
|
### Naming Convention
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ CORRECT:
|
||||||
|
src/composables/
|
||||||
|
useCounter.ts // External - reused in multiple components
|
||||||
|
useUserData.ts // External - shared business logic
|
||||||
|
useApiRequest.ts // External - used across app
|
||||||
|
|
||||||
|
❌ WRONG:
|
||||||
|
src/composables/
|
||||||
|
counter.ts // Missing 'use' prefix
|
||||||
|
APIrequest.ts // Wrong casing
|
||||||
|
user-data.ts // Wrong casing
|
||||||
|
useComponentSpecificThing.ts // Should be inline in component!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules for External Composables:**
|
||||||
|
- ALWAYS prefix with `use`
|
||||||
|
- ALWAYS use PascalCase after `use`: `useUserData` not `useuserdata`
|
||||||
|
- Place in `src/composables/` directory
|
||||||
|
- One composable per file
|
||||||
|
- Only extract when reused in 2+ components
|
||||||
|
|
||||||
|
### Function Naming
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Descriptive names
|
||||||
|
export function useUserData() {}
|
||||||
|
export function useApiRequest() {}
|
||||||
|
export function useLocalStorage() {}
|
||||||
|
|
||||||
|
// ❌ WRONG: Too generic
|
||||||
|
export function useData() {}
|
||||||
|
export function useRequest() {}
|
||||||
|
export function useStorage() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composable Anatomy
|
||||||
|
|
||||||
|
A well-structured composable has three parts:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useUserData(userId: string) {
|
||||||
|
// 1. PRIMARY STATE - Main reactive data
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
|
||||||
|
// 2. STATE METADATA - Supporting state (loading, error, etc.)
|
||||||
|
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
|
// 3. METHODS - Functions that update state
|
||||||
|
const fetchUser = async () => {
|
||||||
|
status.value = 'loading'
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${userId}`)
|
||||||
|
user.value = response.data
|
||||||
|
status.value = 'success'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error
|
||||||
|
status.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, status, error, fetchUser }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Order:**
|
||||||
|
1. Primary State (the main data)
|
||||||
|
2. State Metadata (loading, error, etc.)
|
||||||
|
3. Methods (functions that manipulate state)
|
||||||
|
|
||||||
|
## Single Responsibility Principle
|
||||||
|
|
||||||
|
Each composable should do ONE thing well.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Single responsibility
|
||||||
|
export function useCounter() {
|
||||||
|
const count = ref(0)
|
||||||
|
|
||||||
|
const increment = () => count.value++
|
||||||
|
const decrement = () => count.value--
|
||||||
|
const reset = () => count.value = 0
|
||||||
|
|
||||||
|
return { count, increment, decrement, reset }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG: Multiple responsibilities
|
||||||
|
export function useUserAndCounter(userId: string) {
|
||||||
|
// User management
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const fetchUser = async () => { /* ... */ }
|
||||||
|
|
||||||
|
// Counter logic (unrelated!)
|
||||||
|
const count = ref(0)
|
||||||
|
const increment = () => count.value++
|
||||||
|
|
||||||
|
return { user, fetchUser, count, increment }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:** Create two composables: `useUser` and `useCounter`.
|
||||||
|
|
||||||
|
## Argument Passing
|
||||||
|
|
||||||
|
### Four or More Parameters: Use Object
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Object for 4+ parameters
|
||||||
|
useUserData({
|
||||||
|
id: 1,
|
||||||
|
fetchOnMount: true,
|
||||||
|
token: 'abc',
|
||||||
|
locale: 'en'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ CORRECT: Individual args for 3 or fewer
|
||||||
|
useCounter(initialValue, autoIncrement, storageKey)
|
||||||
|
|
||||||
|
// ❌ WRONG: Many individual args
|
||||||
|
useUserData(1, true, 'abc', 'en', false, null)
|
||||||
|
```
|
||||||
|
|
||||||
|
**With TypeScript:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface UseUserDataOptions {
|
||||||
|
id: number
|
||||||
|
fetchOnMount?: boolean
|
||||||
|
token?: string
|
||||||
|
locale?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserData(options: UseUserDataOptions) {
|
||||||
|
const { id, fetchOnMount = false, token, locale = 'en' } = options
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
ALWAYS expose error state, NEVER just console.error.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Expose errors
|
||||||
|
export function useUserData(userId: string) {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${userId}`)
|
||||||
|
user.value = response.data
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error // Expose to caller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, error, fetchUser }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG: Swallow errors
|
||||||
|
export function useUserDataBad(userId: string) {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${userId}`)
|
||||||
|
user.value = response.data
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error:', e) // Component can't handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, fetchUser } // No error exposed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Separation of Concerns: No UI Logic in Composables
|
||||||
|
|
||||||
|
Composables handle STATE and BUSINESS LOGIC only. UI concerns (toasts, alerts, modals) belong in components.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Business logic only
|
||||||
|
export function useUserData(userId: string) {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
status.value = 'loading'
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${userId}`)
|
||||||
|
user.value = response.data
|
||||||
|
status.value = 'success'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error
|
||||||
|
status.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, error, status, fetchUser }
|
||||||
|
}
|
||||||
|
|
||||||
|
// In component - UI layer handles presentation
|
||||||
|
const { user, error, status, fetchUser } = useUserData(userId)
|
||||||
|
|
||||||
|
watch(status, (s) => {
|
||||||
|
if (s === 'success') showToast('Success!') // UI in component
|
||||||
|
if (s === 'error') showToast(`Error: ${error.value?.message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ❌ WRONG: UI logic in composable
|
||||||
|
export function useUserDataBad(userId: string) {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${userId}`)
|
||||||
|
user.value = response.data
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Failed to load user') // UI logic in composable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, fetchUser }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why separation matters:**
|
||||||
|
- Composable is testable without mocking UI
|
||||||
|
- Different components can use different UI (toast, modal, inline error)
|
||||||
|
- Composable works in non-UI contexts (background tasks, SSR)
|
||||||
|
- Follows separation of concerns
|
||||||
|
|
||||||
|
## Functional Core, Imperative Shell (Optional Pattern)
|
||||||
|
|
||||||
|
Separate pure functions (core logic) from side effects (shell).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Functional core, imperative shell
|
||||||
|
// Functional Core (pure, testable)
|
||||||
|
const calculate = (a: number, b: number) => a + b
|
||||||
|
const multiply = (a: number, b: number) => a * b
|
||||||
|
|
||||||
|
// Imperative Shell (Vue reactivity, side effects)
|
||||||
|
export function useCalculator() {
|
||||||
|
const result = ref(0)
|
||||||
|
|
||||||
|
const add = (a: number, b: number) => {
|
||||||
|
result.value = calculate(a, b) // Use pure function
|
||||||
|
}
|
||||||
|
|
||||||
|
const times = (a: number, b: number) => {
|
||||||
|
result.value = multiply(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result, add, times }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ WRONG: Mixed concerns
|
||||||
|
export function useCalculatorBad() {
|
||||||
|
const result = ref(0)
|
||||||
|
|
||||||
|
const add = (a: number, b: number) => {
|
||||||
|
console.log('Adding:', a, b) // Side effect mixed with logic
|
||||||
|
result.value = a + b
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result, add }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Pure functions are easy to test
|
||||||
|
- Side effects isolated to shell
|
||||||
|
- Better separation of concerns
|
||||||
|
|
||||||
|
## Consistent File Structure
|
||||||
|
|
||||||
|
Maintain consistent ordering within composable files:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
|
||||||
|
export function useExample() {
|
||||||
|
// 1. INITIALIZING
|
||||||
|
// Setup, imports, router, other composables
|
||||||
|
const router = useRouter()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
// 2. REFS
|
||||||
|
const count = ref(0)
|
||||||
|
const data = ref<Data | null>(null)
|
||||||
|
|
||||||
|
// 3. COMPUTED
|
||||||
|
const isEven = computed(() => count.value % 2 === 0)
|
||||||
|
const hasData = computed(() => data.value !== null)
|
||||||
|
|
||||||
|
// 4. METHODS
|
||||||
|
const increment = () => count.value++
|
||||||
|
const fetchData = async () => { /* ... */ }
|
||||||
|
|
||||||
|
// 5. LIFECYCLE HOOKS
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 6. WATCH
|
||||||
|
watch(count, (newCount) => {
|
||||||
|
console.log('Count changed:', newCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { count, isEven, increment, data, fetchData }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommended Order:**
|
||||||
|
1. Initializing (setup, other composables)
|
||||||
|
2. Refs (reactive state)
|
||||||
|
3. Computed (derived state)
|
||||||
|
4. Methods (functions)
|
||||||
|
5. Lifecycle Hooks (onMounted, onUnmounted, etc.)
|
||||||
|
6. Watch (watchers)
|
||||||
|
|
||||||
|
## Composable Composition
|
||||||
|
|
||||||
|
Composables can use other composables:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Composing composables
|
||||||
|
export function useAuthenticatedUser(userId: string) {
|
||||||
|
const { token, isAuthenticated } = useAuth()
|
||||||
|
const { user, error, fetchUser } = useUserData(userId)
|
||||||
|
|
||||||
|
// Only fetch if authenticated
|
||||||
|
watchEffect(() => {
|
||||||
|
if (isAuthenticated.value && token.value) {
|
||||||
|
fetchUser()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { user, error, isAuthenticated }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules for composition:**
|
||||||
|
- Call composables at the top of your composable function
|
||||||
|
- Don't call composables conditionally
|
||||||
|
- Compose related functionality (auth + user data)
|
||||||
|
- Maintain single responsibility even when composing
|
||||||
|
|
||||||
|
## Return Values
|
||||||
|
|
||||||
|
Return an object with named properties for clarity:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Named object return
|
||||||
|
export function useCounter() {
|
||||||
|
const count = ref(0)
|
||||||
|
const increment = () => count.value++
|
||||||
|
|
||||||
|
return { count, increment }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const { count, increment } = useCounter()
|
||||||
|
|
||||||
|
// ❌ WRONG: Array return (unclear)
|
||||||
|
export function useCounterBad() {
|
||||||
|
const count = ref(0)
|
||||||
|
const increment = () => count.value++
|
||||||
|
|
||||||
|
return [count, increment] // Which is which?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Best Practices
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Proper typing
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseUserDataReturn {
|
||||||
|
user: Ref<User | null>
|
||||||
|
error: Ref<Error | null>
|
||||||
|
status: Ref<'idle' | 'loading' | 'success' | 'error'>
|
||||||
|
fetchUser: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserData(userId: string): UseUserDataReturn {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
status.value = 'loading'
|
||||||
|
try {
|
||||||
|
const response = await axios.get<User>(`/api/users/${userId}`)
|
||||||
|
user.value = response.data
|
||||||
|
status.value = 'success'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error
|
||||||
|
status.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, error, status, fetchUser }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Loading State Pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useAsyncData<T>(fetcher: () => Promise<T>) {
|
||||||
|
const data = ref<T | null>(null)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
const execute = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.value = await fetcher()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, error, isLoading, execute }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### LocalStorage Sync Pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useLocalStorage<T>(key: string, defaultValue: T) {
|
||||||
|
const data = ref<T>(defaultValue)
|
||||||
|
|
||||||
|
// Load from localStorage on init
|
||||||
|
const stored = localStorage.getItem(key)
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
data.value = JSON.parse(stored)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse stored value')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync to localStorage on change
|
||||||
|
watch(data, (newValue) => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(newValue))
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounced Input Pattern
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useDebouncedRef<T>(value: T, delay = 300) {
|
||||||
|
const immediate = ref(value)
|
||||||
|
const debounced = ref(value)
|
||||||
|
|
||||||
|
watch(immediate, (newValue) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
debounced.value = newValue
|
||||||
|
}, delay)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { immediate, debounced }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Composables
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { useCounter } from './useCounter'
|
||||||
|
|
||||||
|
describe('useCounter', () => {
|
||||||
|
it('increments count', () => {
|
||||||
|
const { count, increment } = useCounter()
|
||||||
|
|
||||||
|
expect(count.value).toBe(0)
|
||||||
|
increment()
|
||||||
|
expect(count.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decrements count', () => {
|
||||||
|
const { count, decrement } = useCounter()
|
||||||
|
|
||||||
|
count.value = 5
|
||||||
|
decrement()
|
||||||
|
expect(count.value).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
For async composables, use Vue Test Utils:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
it('fetches user data', async () => {
|
||||||
|
const wrapper = mount({
|
||||||
|
setup() {
|
||||||
|
return useUserData('123')
|
||||||
|
},
|
||||||
|
template: '<div></div>'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchUser, user, status } = wrapper.vm
|
||||||
|
|
||||||
|
await fetchUser()
|
||||||
|
|
||||||
|
expect(status.value).toBe('success')
|
||||||
|
expect(user.value).toBeDefined()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Extracting to external file too early | Start inline, extract when reused in 2+ components |
|
||||||
|
| External composable used in only ONE component | Move to inline composable in that component |
|
||||||
|
| Missing `use` prefix | Always prefix: `useCounter` not `counter` |
|
||||||
|
| UI logic in composable | Move toasts/alerts to component |
|
||||||
|
| Multiple responsibilities | Split into focused composables |
|
||||||
|
| Not exposing errors | Always return error state |
|
||||||
|
| Too many parameters | Use object for 4+ params |
|
||||||
|
| Conditional composable calls | Call composables at top level only |
|
||||||
|
| Array returns | Use named object returns |
|
||||||
|
| Missing TypeScript types | Type all refs and return values |
|
||||||
|
|
||||||
|
## Quick Checklist
|
||||||
|
|
||||||
|
**Before Creating External Composable:**
|
||||||
|
- [ ] Is this logic used in 2+ components? (If not, keep inline)
|
||||||
|
- [ ] Is this shared business logic that should be centralized?
|
||||||
|
|
||||||
|
**For All Composables (Inline and External):**
|
||||||
|
- [ ] Single responsibility (one thing well)
|
||||||
|
- [ ] Exposes error state (not just console.error)
|
||||||
|
- [ ] No UI logic (toasts, alerts, modals)
|
||||||
|
- [ ] Consistent internal structure (refs → computed → methods → lifecycle → watch)
|
||||||
|
- [ ] Named object return (not array)
|
||||||
|
- [ ] Full TypeScript types
|
||||||
|
- [ ] 4+ params use object, 3 or fewer use individual args
|
||||||
|
|
||||||
|
**For External Composables Only:**
|
||||||
|
- [ ] File named with `use` prefix and PascalCase
|
||||||
|
- [ ] Located in `src/composables/` directory
|
||||||
|
- [ ] Actually reused in multiple components (not "might be reused")
|
||||||
211
skills/vue-development/references/routing-patterns.md
Normal file
211
skills/vue-development/references/routing-patterns.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Vue Router Patterns Reference
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Route Structure](#route-structure)
|
||||||
|
- [Route Groups](#route-groups)
|
||||||
|
- [File Naming Patterns](#file-naming-patterns)
|
||||||
|
- [Route Params Best Practices](#route-params-best-practices)
|
||||||
|
- [Navigation with Type Safety](#navigation-with-type-safety)
|
||||||
|
- [Using Route Data in Components](#using-route-data-in-components)
|
||||||
|
- [definePage() Customization](#definepage-customization)
|
||||||
|
- [Typed Router Reference](#typed-router-reference)
|
||||||
|
- [Route Layouts](#route-layouts)
|
||||||
|
- [Navigation Guards (Per-Route)](#navigation-guards-per-route)
|
||||||
|
- [Fetch Route Data](#fetch-route-data)
|
||||||
|
|
||||||
|
## Route Structure
|
||||||
|
|
||||||
|
File-based routing using unplugin-vue-router or similar.
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ CORRECT:
|
||||||
|
src/pages/
|
||||||
|
(home).vue # Route groups for meaningful names
|
||||||
|
users.edit.vue # Use . for / without nesting
|
||||||
|
users.vue # Layout for users/*
|
||||||
|
users/
|
||||||
|
(user-list).vue # Group instead of index.vue
|
||||||
|
[userId].vue # Explicit param names
|
||||||
|
posts.[[slug]]+.vue # Optional repeatable params
|
||||||
|
|
||||||
|
❌ WRONG:
|
||||||
|
src/pages/
|
||||||
|
index.vue # Use groups: (home).vue
|
||||||
|
users/
|
||||||
|
index.vue # Use groups: (user-list).vue
|
||||||
|
[id].vue # Too generic: use [userId].vue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- AVOID `index.vue` → use route groups `(descriptive-name).vue`
|
||||||
|
- Use explicit param names: `[userId]` not `[id]`, `[postSlug]` not `[slug]`
|
||||||
|
- Use `.` in filenames for `/` without route nesting: `users.edit.vue` → `/users/edit`
|
||||||
|
- Use `[[param]]` for optional params, `[param]+` for repeatable
|
||||||
|
- Prefer named routes with typed parameters over string URLs
|
||||||
|
|
||||||
|
## Route Groups
|
||||||
|
|
||||||
|
Route groups create shared layouts without affecting URLs:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/
|
||||||
|
├── (admin).vue # Layout for all admin routes
|
||||||
|
├── (admin)/
|
||||||
|
│ ├── dashboard.vue # URL: /dashboard
|
||||||
|
│ └── settings.vue # URL: /settings
|
||||||
|
└── (user)/
|
||||||
|
├── profile.vue # URL: /profile
|
||||||
|
└── orders.vue # URL: /orders
|
||||||
|
```
|
||||||
|
|
||||||
|
The `(admin).vue` and `(user).vue` files provide layouts but don't add URL segments.
|
||||||
|
|
||||||
|
## File Naming Patterns
|
||||||
|
|
||||||
|
```
|
||||||
|
Pattern URL Notes
|
||||||
|
─────────────────────────────────────────────────────────────
|
||||||
|
(home).vue / Root route with group
|
||||||
|
about.vue /about Simple route
|
||||||
|
users.vue /users Layout for nested routes
|
||||||
|
users/[userId].vue /users/:userId Dynamic param
|
||||||
|
users.edit.vue /users/edit Dot creates sibling
|
||||||
|
posts/[...slug].vue /posts/* Catch-all
|
||||||
|
settings.[[tab]].vue /settings/:tab? Optional param
|
||||||
|
docs.[[path]]+.vue /docs/:path+ Repeatable param
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Params Best Practices
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Explicit, semantic param names
|
||||||
|
pages/
|
||||||
|
users/[userId].vue
|
||||||
|
posts/[postSlug].vue
|
||||||
|
orders/[orderId].vue
|
||||||
|
|
||||||
|
// ❌ WRONG: Generic names
|
||||||
|
pages/
|
||||||
|
users/[id].vue // Which ID? User? Post? Order?
|
||||||
|
posts/[slug].vue // Could be any entity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Navigation with Type Safety
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useRouter } from 'vue-router/auto'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// ✅ CORRECT: Named route with typed params
|
||||||
|
router.push({
|
||||||
|
name: '/users/[userId]',
|
||||||
|
params: { userId: 123 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ❌ WRONG: String concatenation (no type safety)
|
||||||
|
router.push('/users/' + userId)
|
||||||
|
|
||||||
|
// ✅ CORRECT: Query params
|
||||||
|
router.push({
|
||||||
|
name: '/search',
|
||||||
|
query: { q: 'vue', page: 2 }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Route Data in Components
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute } from 'vue-router/auto'
|
||||||
|
|
||||||
|
// ✅ CORRECT: Pass route name for strict typing
|
||||||
|
const route = useRoute('/users/[userId]')
|
||||||
|
|
||||||
|
// Now route.params.userId is typed as string
|
||||||
|
const userId = route.params.userId
|
||||||
|
|
||||||
|
// ✅ CORRECT: Access query params
|
||||||
|
const searchQuery = route.query.q
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## definePage() Customization
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePage({
|
||||||
|
name: 'UserProfile',
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: 'User Profile'
|
||||||
|
},
|
||||||
|
alias: ['/profile'],
|
||||||
|
redirect: (to) => {
|
||||||
|
// Conditional redirect
|
||||||
|
if (!to.params.userId) {
|
||||||
|
return { name: '/users' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typed Router Reference
|
||||||
|
|
||||||
|
Always refer to `typed-router.d.ts` for:
|
||||||
|
- Available route names
|
||||||
|
- Required/optional params
|
||||||
|
- Query param types
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// typed-router.d.ts is auto-generated
|
||||||
|
// Check it for available routes and their params
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Layouts
|
||||||
|
|
||||||
|
```
|
||||||
|
src/pages/
|
||||||
|
├── users.vue # Parent layout
|
||||||
|
└── users/
|
||||||
|
├── (list).vue # Renders inside users.vue
|
||||||
|
└── [userId]/
|
||||||
|
├── (details).vue # Renders inside users.vue
|
||||||
|
└── edit.vue # Renders inside users.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
`users.vue` contains `<RouterView />` for nested routes.
|
||||||
|
|
||||||
|
## Navigation Guards (Per-Route)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||||
|
|
||||||
|
onBeforeRouteLeave((to, from) => {
|
||||||
|
if (hasUnsavedChanges.value) {
|
||||||
|
const answer = window.confirm('Discard unsaved changes?')
|
||||||
|
if (!answer) return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeRouteUpdate(async (to, from) => {
|
||||||
|
// Called when route params change but same component reused
|
||||||
|
if (to.params.userId !== from.params.userId) {
|
||||||
|
await loadUser(to.params.userId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fetch Route Data
|
||||||
|
|
||||||
|
For up-to-date routing documentation with unplugin-vue-router:
|
||||||
|
<https://uvr.esm.is/llms.txt>
|
||||||
|
|
||||||
|
Follow links in that file for:
|
||||||
|
- Advanced route patterns
|
||||||
|
- Type-safe navigation
|
||||||
|
- Meta fields
|
||||||
|
- Route guards
|
||||||
549
skills/vue-development/references/testing-composables.md
Normal file
549
skills/vue-development/references/testing-composables.md
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
# Testing Vue Composables in Isolation
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Composable Categories](#composable-categories)
|
||||||
|
- [Testing Independent Composables](#testing-independent-composables)
|
||||||
|
- [Testing Dependent Composables](#testing-dependent-composables)
|
||||||
|
- [Testing Composables with Inject](#testing-composables-with-inject)
|
||||||
|
- [Testing Patterns](#testing-patterns)
|
||||||
|
- [Common Testing Mistakes](#common-testing-mistakes)
|
||||||
|
- [Testing Checklist](#testing-checklist)
|
||||||
|
- [Quick Reference](#quick-reference)
|
||||||
|
- [Summary](#summary)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Test composables based on whether they're independent (reactivity only) or dependent (lifecycle/inject).
|
||||||
|
|
||||||
|
## Composable Categories
|
||||||
|
|
||||||
|
### Independent Composables 🔓
|
||||||
|
|
||||||
|
**Definition:** Use ONLY Vue's Reactivity APIs (ref, computed, watch). No lifecycle hooks, no provide/inject.
|
||||||
|
|
||||||
|
**Testing:** Test directly like regular functions.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ref, computed, type Ref, type ComputedRef } from 'vue'
|
||||||
|
|
||||||
|
// ✅ Independent: Only uses reactivity
|
||||||
|
function useSum(a: Ref<number>, b: Ref<number>): ComputedRef<number> {
|
||||||
|
return computed(() => a.value + b.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test directly
|
||||||
|
describe('useSum', () => {
|
||||||
|
it('correctly computes the sum of two numbers', () => {
|
||||||
|
const num1 = ref(2)
|
||||||
|
const num2 = ref(3)
|
||||||
|
const sum = useSum(num1, num2)
|
||||||
|
|
||||||
|
expect(sum.value).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependent Composables 🔗
|
||||||
|
|
||||||
|
**Definition:** Use lifecycle hooks (onMounted, onUnmounted) or provide/inject. Require component context.
|
||||||
|
|
||||||
|
**Testing:** Need helper to simulate component mounting.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
|
||||||
|
// ❌ Dependent: Uses onMounted lifecycle
|
||||||
|
function useLocalStorage<T>(key: string, initialValue: T) {
|
||||||
|
const value = ref<T>(initialValue)
|
||||||
|
|
||||||
|
function loadFromLocalStorage() {
|
||||||
|
const storedValue = localStorage.getItem(key)
|
||||||
|
if (storedValue !== null) {
|
||||||
|
value.value = JSON.parse(storedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadFromLocalStorage) // Requires component context
|
||||||
|
|
||||||
|
watch(value, (newValue) => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(newValue))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { value }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Independent Composables
|
||||||
|
|
||||||
|
Test directly without helpers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
describe('useCounter (independent)', () => {
|
||||||
|
it('increments count', () => {
|
||||||
|
const count = ref(0)
|
||||||
|
const increment = () => count.value++
|
||||||
|
|
||||||
|
expect(count.value).toBe(0)
|
||||||
|
increment()
|
||||||
|
expect(count.value).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Dependent Composables
|
||||||
|
|
||||||
|
### The withSetup Helper
|
||||||
|
|
||||||
|
For composables using lifecycle hooks:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { App } from 'vue'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
|
export function withSetup<T>(composable: () => T): [T, App] {
|
||||||
|
let result: T
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
setup() {
|
||||||
|
result = composable()
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.mount(document.createElement('div'))
|
||||||
|
|
||||||
|
return [result, app]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
describe('useLocalStorage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should load initial value', () => {
|
||||||
|
const [result, app] = withSetup(() =>
|
||||||
|
useLocalStorage('testKey', 'initValue')
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.value.value).toBe('initValue')
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should load from localStorage if exists', () => {
|
||||||
|
localStorage.setItem('testKey', JSON.stringify('fromStorage'))
|
||||||
|
|
||||||
|
const [result, app] = withSetup(() =>
|
||||||
|
useLocalStorage('testKey', 'initialValue')
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.value.value).toBe('fromStorage')
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sync changes to localStorage', async () => {
|
||||||
|
const [result, app] = withSetup(() =>
|
||||||
|
useLocalStorage('testKey', 'initial')
|
||||||
|
)
|
||||||
|
|
||||||
|
result.value.value = 'updated'
|
||||||
|
|
||||||
|
// Wait for watch to trigger
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
expect(localStorage.getItem('testKey')).toBe(JSON.stringify('updated'))
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why withSetup works:**
|
||||||
|
1. Creates actual Vue app
|
||||||
|
2. Executes composable during setup phase
|
||||||
|
3. Triggers lifecycle hooks (onMounted, etc.)
|
||||||
|
4. Returns result + app for cleanup
|
||||||
|
|
||||||
|
## Testing Composables with Inject
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { InjectionKey } from 'vue'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
export const MessageKey: InjectionKey<string> = Symbol('message')
|
||||||
|
|
||||||
|
export function useMessage() {
|
||||||
|
const message = inject(MessageKey)
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
throw new Error('Message must be provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUpperCase = () => message.toUpperCase()
|
||||||
|
const getReversed = () => message.split('').reverse().join('')
|
||||||
|
|
||||||
|
return { message, getUpperCase, getReversed }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This composable REQUIRES a provider. Testing directly will fail.
|
||||||
|
|
||||||
|
### The useInjectedSetup Helper
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { InjectionKey } from 'vue'
|
||||||
|
import { createApp, defineComponent, h, provide } from 'vue'
|
||||||
|
|
||||||
|
type InstanceType<V> = V extends { new (...arg: any[]): infer X } ? X : never
|
||||||
|
type VM<V> = InstanceType<V> & { unmount: () => void }
|
||||||
|
|
||||||
|
interface InjectionConfig {
|
||||||
|
key: InjectionKey<any> | string
|
||||||
|
value: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInjectedSetup<TResult>(
|
||||||
|
setup: () => TResult,
|
||||||
|
injections: InjectionConfig[] = []
|
||||||
|
): TResult & { unmount: () => void } {
|
||||||
|
let result!: TResult
|
||||||
|
|
||||||
|
const Comp = defineComponent({
|
||||||
|
setup() {
|
||||||
|
result = setup()
|
||||||
|
return () => h('div')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const Provider = defineComponent({
|
||||||
|
setup() {
|
||||||
|
injections.forEach(({ key, value }) => {
|
||||||
|
provide(key, value)
|
||||||
|
})
|
||||||
|
return () => h(Comp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mounted = mount(Provider)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
unmount: mounted.unmount
|
||||||
|
} as TResult & { unmount: () => void }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount<V>(Comp: V) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
const app = createApp(Comp as any)
|
||||||
|
const unmount = () => app.unmount()
|
||||||
|
const comp = app.mount(el) as any as VM<V>
|
||||||
|
comp.unmount = unmount
|
||||||
|
return comp
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing with useInjectedSetup
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { useInjectedSetup } from './helpers'
|
||||||
|
import { MessageKey, useMessage } from './useMessage'
|
||||||
|
|
||||||
|
describe('useMessage', () => {
|
||||||
|
it('should handle injected message', () => {
|
||||||
|
const wrapper = useInjectedSetup(
|
||||||
|
() => useMessage(),
|
||||||
|
[{ key: MessageKey, value: 'hello world' }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(wrapper.message).toBe('hello world')
|
||||||
|
expect(wrapper.getUpperCase()).toBe('HELLO WORLD')
|
||||||
|
expect(wrapper.getReversed()).toBe('dlrow olleh')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when message not provided', () => {
|
||||||
|
expect(() => {
|
||||||
|
useInjectedSetup(() => useMessage(), [])
|
||||||
|
}).toThrow('Message must be provided')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Creates Provider component that provides injections
|
||||||
|
2. Creates child component that uses the composable
|
||||||
|
3. Mounts both in proper hierarchy
|
||||||
|
4. Returns composable result + unmount function
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Independent Composable with Reactive State
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Composable
|
||||||
|
function useCounter(initial = 0) {
|
||||||
|
const count = ref(initial)
|
||||||
|
const increment = () => count.value++
|
||||||
|
const decrement = () => count.value--
|
||||||
|
const reset = () => count.value = initial
|
||||||
|
|
||||||
|
return { count, increment, decrement, reset }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test
|
||||||
|
describe('useCounter', () => {
|
||||||
|
it('initializes with default value', () => {
|
||||||
|
const { count } = useCounter()
|
||||||
|
expect(count.value).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializes with custom value', () => {
|
||||||
|
const { count } = useCounter(10)
|
||||||
|
expect(count.value).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('increments count', () => {
|
||||||
|
const { count, increment } = useCounter()
|
||||||
|
increment()
|
||||||
|
expect(count.value).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets to initial value', () => {
|
||||||
|
const { count, increment, reset } = useCounter(5)
|
||||||
|
increment()
|
||||||
|
increment()
|
||||||
|
expect(count.value).toBe(7)
|
||||||
|
reset()
|
||||||
|
expect(count.value).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Composable with Lifecycle (withSetup)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Composable
|
||||||
|
function useDocumentTitle(title: Ref<string>) {
|
||||||
|
onMounted(() => {
|
||||||
|
document.title = title.value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(title, (newTitle) => {
|
||||||
|
document.title = newTitle
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test
|
||||||
|
describe('useDocumentTitle', () => {
|
||||||
|
it('sets document title on mount', () => {
|
||||||
|
const title = ref('Test Title')
|
||||||
|
const [_, app] = withSetup(() => useDocumentTitle(title))
|
||||||
|
|
||||||
|
expect(document.title).toBe('Test Title')
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates document title when ref changes', async () => {
|
||||||
|
const title = ref('Initial')
|
||||||
|
const [_, app] = withSetup(() => useDocumentTitle(title))
|
||||||
|
|
||||||
|
title.value = 'Updated'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.title).toBe('Updated')
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Async Composable with Lifecycle
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Composable
|
||||||
|
function useAsyncData<T>(fetcher: () => Promise<T>) {
|
||||||
|
const data = ref<T | null>(null)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
const execute = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.value = await fetcher()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
execute()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { data, error, isLoading, execute }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test
|
||||||
|
describe('useAsyncData', () => {
|
||||||
|
it('loads data on mount', async () => {
|
||||||
|
const fetcher = vi.fn().mockResolvedValue({ id: 1, name: 'Test' })
|
||||||
|
|
||||||
|
const [result, app] = withSetup(() => useAsyncData(fetcher))
|
||||||
|
|
||||||
|
expect(result.isLoading.value).toBe(true)
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(result.isLoading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.data.value).toEqual({ id: 1, name: 'Test' })
|
||||||
|
expect(result.error.value).toBeNull()
|
||||||
|
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors', async () => {
|
||||||
|
const fetcher = vi.fn().mockRejectedValue(new Error('Failed'))
|
||||||
|
|
||||||
|
const [result, app] = withSetup(() => useAsyncData(fetcher))
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(result.isLoading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.error.value).toBeInstanceOf(Error)
|
||||||
|
expect(result.error.value?.message).toBe('Failed')
|
||||||
|
expect(result.data.value).toBeNull()
|
||||||
|
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Composable Using Other Composables
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Composables
|
||||||
|
function useAuth() {
|
||||||
|
const token = ref<string | null>(null)
|
||||||
|
const isAuthenticated = computed(() => token.value !== null)
|
||||||
|
return { token, isAuthenticated }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAuthenticatedUser(userId: string) {
|
||||||
|
const { token, isAuthenticated } = useAuth()
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
|
||||||
|
watchEffect(async () => {
|
||||||
|
if (isAuthenticated.value && token.value) {
|
||||||
|
user.value = await fetchUser(userId, token.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { user, isAuthenticated }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test
|
||||||
|
describe('useAuthenticatedUser', () => {
|
||||||
|
it('only fetches when authenticated', async () => {
|
||||||
|
const fetchUser = vi.fn()
|
||||||
|
|
||||||
|
const [result, app] = withSetup(() => {
|
||||||
|
// Mock useAuth
|
||||||
|
const auth = useAuth()
|
||||||
|
auth.token.value = null // Not authenticated
|
||||||
|
|
||||||
|
return useAuthenticatedUser('123')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetchUser).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
app.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Testing Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| Testing dependent composable directly | Use withSetup for lifecycle composables |
|
||||||
|
| Not unmounting after tests | Always call app.unmount() |
|
||||||
|
| Missing inject providers | Use useInjectedSetup with injections array |
|
||||||
|
| Not waiting for async operations | Use vi.waitFor() or await nextTick() |
|
||||||
|
| Testing implementation details | Test behavior, not internal refs |
|
||||||
|
| Forgetting to clear mocks/storage | Use beforeEach/afterEach hooks |
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### For Independent Composables
|
||||||
|
- [ ] Test directly without helpers
|
||||||
|
- [ ] Test all return values
|
||||||
|
- [ ] Test edge cases (null, undefined, empty)
|
||||||
|
- [ ] Test reactivity (refs update correctly)
|
||||||
|
|
||||||
|
### For Dependent Composables (Lifecycle)
|
||||||
|
- [ ] Use withSetup helper
|
||||||
|
- [ ] Test onMounted behavior
|
||||||
|
- [ ] Test watch/watchEffect triggers
|
||||||
|
- [ ] Unmount app after each test
|
||||||
|
- [ ] Clear side effects (localStorage, etc.)
|
||||||
|
|
||||||
|
### For Dependent Composables (Inject)
|
||||||
|
- [ ] Use useInjectedSetup helper
|
||||||
|
- [ ] Test with provided values
|
||||||
|
- [ ] Test error when injection missing
|
||||||
|
- [ ] Unmount wrapper after each test
|
||||||
|
|
||||||
|
### For Async Composables
|
||||||
|
- [ ] Test loading states
|
||||||
|
- [ ] Test success states
|
||||||
|
- [ ] Test error states
|
||||||
|
- [ ] Use vi.waitFor() for assertions
|
||||||
|
- [ ] Mock async dependencies
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Independent: Test directly
|
||||||
|
const { count, increment } = useCounter()
|
||||||
|
increment()
|
||||||
|
expect(count.value).toBe(1)
|
||||||
|
|
||||||
|
// Lifecycle: Use withSetup
|
||||||
|
const [result, app] = withSetup(() => useLocalStorage('key', 'value'))
|
||||||
|
expect(result.value.value).toBe('value')
|
||||||
|
app.unmount()
|
||||||
|
|
||||||
|
// Inject: Use useInjectedSetup
|
||||||
|
const wrapper = useInjectedSetup(
|
||||||
|
() => useMessage(),
|
||||||
|
[{ key: MessageKey, value: 'hello' }]
|
||||||
|
)
|
||||||
|
expect(wrapper.message).toBe('hello')
|
||||||
|
wrapper.unmount()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Composable Type | Testing Approach | Helper Needed |
|
||||||
|
|----------------|------------------|---------------|
|
||||||
|
| Independent (ref, computed, watch) | Test directly | ❌ No |
|
||||||
|
| Dependent (lifecycle hooks) | withSetup | ✅ Yes |
|
||||||
|
| Dependent (inject) | useInjectedSetup | ✅ Yes |
|
||||||
|
|
||||||
|
**Golden Rule:** If it uses lifecycle or inject, it needs a helper. Otherwise, test it like a regular function.
|
||||||
243
skills/vue-development/references/testing-patterns.md
Normal file
243
skills/vue-development/references/testing-patterns.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Vue Testing Patterns Reference
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Testing Philosophy](#testing-philosophy)
|
||||||
|
- [Primary Approach: Testing Library](#primary-approach-testing-library)
|
||||||
|
- [Async Testing](#async-testing)
|
||||||
|
- [MSW API Mocking](#msw-api-mocking)
|
||||||
|
- [Testing Library Queries Priority](#testing-library-queries-priority)
|
||||||
|
- [User Interactions](#user-interactions)
|
||||||
|
- [Component Library Testing (Fallback)](#component-library-testing-fallback)
|
||||||
|
- [Common Testing Mistakes](#common-testing-mistakes)
|
||||||
|
- [Testing Checklist](#testing-checklist)
|
||||||
|
|
||||||
|
## Testing Philosophy
|
||||||
|
|
||||||
|
**Gold standard:** Test user behavior, not implementation details.
|
||||||
|
|
||||||
|
**Note:** For testing composables in isolation, see @testing-composables.md which covers independent vs dependent composables, withSetup helper, and inject testing.
|
||||||
|
|
||||||
|
## Primary Approach: Testing Library
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: Testing Library - user behavior
|
||||||
|
import { render, screen } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
|
||||||
|
const server = setupServer(
|
||||||
|
http.get('/api/users', () => {
|
||||||
|
return HttpResponse.json([
|
||||||
|
{ id: 1, name: 'John Doe' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
beforeAll(() => server.listen())
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
test('loads and displays users', async () => {
|
||||||
|
render(UserList)
|
||||||
|
|
||||||
|
// Wait for user-visible content
|
||||||
|
expect(await screen.findByText('John Doe')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submits form on button click', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(UserForm)
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Name'), 'Jane')
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('Form submitted')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ❌ WRONG: Testing implementation details
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
test('sets isLoading to true', () => {
|
||||||
|
const wrapper = mount(UserList)
|
||||||
|
expect(wrapper.vm.isLoading).toBe(true) // Internal state
|
||||||
|
})
|
||||||
|
|
||||||
|
// ❌ WRONG: Arbitrary timeouts
|
||||||
|
test('loads users', async () => {
|
||||||
|
const wrapper = mount(UserList)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000)) // BAD
|
||||||
|
expect(wrapper.text()).toContain('John')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- PRIMARY: `@testing-library/vue` for user-behavior tests
|
||||||
|
- NEVER use arbitrary `setTimeout()` in tests
|
||||||
|
- Use `findBy*` queries for async content (built-in waiting)
|
||||||
|
- Use MSW (`msw`) for API mocking, not test-utils mocks
|
||||||
|
- Query by accessibility (role, label) not test IDs
|
||||||
|
- Fallback to `@vue/test-utils` ONLY for component library testing
|
||||||
|
|
||||||
|
## Async Testing
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ CORRECT: findBy* queries (built-in waiting)
|
||||||
|
expect(await screen.findByText('Loaded')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// ✅ CORRECT: waitFor with condition
|
||||||
|
import { waitFor } from '@testing-library/vue'
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Loaded')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ✅ CORRECT: flushPromises (when using test-utils)
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// ❌ WRONG: Arbitrary timeout
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
```
|
||||||
|
|
||||||
|
## MSW API Mocking
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
|
||||||
|
// Define handlers
|
||||||
|
const handlers = [
|
||||||
|
http.get('/api/users', () => {
|
||||||
|
return HttpResponse.json([
|
||||||
|
{ id: 1, name: 'John' },
|
||||||
|
{ id: 2, name: 'Jane' }
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.post('/api/users', async ({ request }) => {
|
||||||
|
const newUser = await request.json()
|
||||||
|
return HttpResponse.json({ id: 3, ...newUser }, { status: 201 })
|
||||||
|
}),
|
||||||
|
|
||||||
|
http.delete('/api/users/:id', ({ params }) => {
|
||||||
|
return new HttpResponse(null, { status: 204 })
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const server = setupServer(...handlers)
|
||||||
|
|
||||||
|
beforeAll(() => server.listen())
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
// Override handler for specific test
|
||||||
|
test('handles API error', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/users', () => {
|
||||||
|
return new HttpResponse(null, { status: 500 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
render(UserList)
|
||||||
|
expect(await screen.findByText('Error loading users')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Library Queries Priority
|
||||||
|
|
||||||
|
Use this priority order:
|
||||||
|
|
||||||
|
1. **Accessible queries (best)**
|
||||||
|
- `getByRole('button', { name: 'Submit' })`
|
||||||
|
- `getByLabelText('Email')`
|
||||||
|
- `getByPlaceholderText('Enter email')`
|
||||||
|
- `getByText('Welcome')`
|
||||||
|
|
||||||
|
2. **Semantic queries**
|
||||||
|
- `getByAltText('Profile photo')`
|
||||||
|
- `getByTitle('Close')`
|
||||||
|
|
||||||
|
3. **Test IDs (last resort)**
|
||||||
|
- `getByTestId('submit-btn')` - only when nothing else works
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ✅ BEST: Accessible queries
|
||||||
|
const submitBtn = screen.getByRole('button', { name: 'Submit' })
|
||||||
|
const emailInput = screen.getByLabelText('Email')
|
||||||
|
|
||||||
|
// ⚠️ FALLBACK: Test IDs only if accessibility not possible
|
||||||
|
const modal = screen.getByTestId('user-modal')
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Interactions
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
||||||
|
test('user interactions', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(ContactForm)
|
||||||
|
|
||||||
|
// Type text
|
||||||
|
await user.type(screen.getByLabelText('Name'), 'John Doe')
|
||||||
|
|
||||||
|
// Click
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Submit' }))
|
||||||
|
|
||||||
|
// Select dropdown
|
||||||
|
await user.selectOptions(screen.getByLabelText('Country'), 'USA')
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
const file = new File(['hello'], 'hello.png', { type: 'image/png' })
|
||||||
|
const input = screen.getByLabelText('Upload avatar')
|
||||||
|
await user.upload(input, file)
|
||||||
|
|
||||||
|
// Keyboard
|
||||||
|
await user.keyboard('{Enter}')
|
||||||
|
await user.keyboard('{Escape}')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Library Testing (Fallback)
|
||||||
|
|
||||||
|
When testing component libraries (not applications), @vue/test-utils is acceptable:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
test('Button component emits click event', async () => {
|
||||||
|
const wrapper = mount(Button, {
|
||||||
|
props: { label: 'Click me' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('click')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Only use @vue/test-utils when:**
|
||||||
|
- Testing reusable component libraries
|
||||||
|
- Need to test component API (props, emits, slots)
|
||||||
|
- Not testing application behavior
|
||||||
|
|
||||||
|
## Common Testing Mistakes
|
||||||
|
|
||||||
|
| Mistake | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| `setTimeout(1000)` | Use `findBy*` or `waitFor()` |
|
||||||
|
| Testing `wrapper.vm.isLoading` | Test visible UI, not internal state |
|
||||||
|
| `getByTestId` everywhere | Use `getByRole`, `getByLabelText` |
|
||||||
|
| Mocking with jest.mock | Use MSW for API mocking |
|
||||||
|
| `wrapper.trigger('click')` | Use `userEvent.click()` for realism |
|
||||||
|
| Testing implementation | Test user-visible behavior |
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Import from `@testing-library/vue`, not `@vue/test-utils`
|
||||||
|
- [ ] Import `userEvent` from `@testing-library/user-event`
|
||||||
|
- [ ] Use MSW for API mocking
|
||||||
|
- [ ] Query by `getByRole`, `getByLabelText`, not `getByTestId`
|
||||||
|
- [ ] Async content? Use `findBy*` queries
|
||||||
|
- [ ] NEVER use `setTimeout()` for waiting
|
||||||
|
- [ ] Test what users see, not component internals
|
||||||
Reference in New Issue
Block a user