test-casebook 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/test-reviewer.md +29 -0
- package/.claude/agents/test-writer.md +29 -0
- package/.claude/skills/test-casebook/SKILL.md +30 -0
- package/.claude/skills/test-task/SKILL.md +50 -0
- package/AGENTS.md +392 -0
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/bin/test-casebook.mjs +93 -0
- package/docs/conventions.md +127 -0
- package/docs/strategy.md +100 -0
- package/docs/testing-guide/README.md +2568 -0
- package/package.json +36 -0
|
@@ -0,0 +1,2568 @@
|
|
|
1
|
+
|
|
2
|
+
# Nuxt + Vitest Testing Guide
|
|
3
|
+
|
|
4
|
+
> Ready-to-use snippets for testing your Nuxt applications with Vitest
|
|
5
|
+
|
|
6
|
+
**Status**: Tested and validated in production
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Vision
|
|
11
|
+
|
|
12
|
+
This guide was created to **accelerate development** and improve **web application quality**. The objective is to provide ready-to-use snippets for all common use cases, allowing developers to:
|
|
13
|
+
|
|
14
|
+
- **Save time**: ready-to-use copy-paste snippets
|
|
15
|
+
- **Improve robustness**: better tested applications
|
|
16
|
+
- **Facilitate maintenance**: more maintainable code
|
|
17
|
+
- **Prevent regressions**: early bug detection
|
|
18
|
+
- **Simplify scaling**: industrializable approach
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Progressive Approach
|
|
23
|
+
|
|
24
|
+
This guide is part of a progressive testing approach:
|
|
25
|
+
|
|
26
|
+
1. **Phase 1: Nuxt + Vitest** (this guide)
|
|
27
|
+
2. **Phase 2: Next.js + Vitest** (coming soon)
|
|
28
|
+
3. **Phase 3: Debug & Troubleshooting** (coming soon)
|
|
29
|
+
4. **Phase 4: E2E Tests** (coming soon)
|
|
30
|
+
|
|
31
|
+
The idea is to start simple and progressively evolve towards more complex tests.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## How to Contribute
|
|
36
|
+
|
|
37
|
+
This guide is **community-driven**. If you have:
|
|
38
|
+
- A new use case to propose
|
|
39
|
+
- A correction to make
|
|
40
|
+
- An improvement to suggest
|
|
41
|
+
|
|
42
|
+
**Open an Issue or make a Pull Request!**
|
|
43
|
+
|
|
44
|
+
### Template for proposing a new case
|
|
45
|
+
|
|
46
|
+
```markdown
|
|
47
|
+
## Case Title
|
|
48
|
+
|
|
49
|
+
**When to use**: [One sentence description]
|
|
50
|
+
|
|
51
|
+
**Code**:
|
|
52
|
+
```js
|
|
53
|
+
// Your snippet here
|
|
54
|
+
```
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Basic Configuration
|
|
60
|
+
|
|
61
|
+
### Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### vitest.config.ts
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { defineVitestConfig } from '@nuxt/test-utils/config'
|
|
71
|
+
|
|
72
|
+
export default defineVitestConfig({
|
|
73
|
+
test: {
|
|
74
|
+
environment: 'nuxt',
|
|
75
|
+
environmentOptions: {
|
|
76
|
+
nuxt: {
|
|
77
|
+
domEnvironment: 'happy-dom'
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Basic imports
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
88
|
+
import { mountSuspended, mockNuxtImport, registerEndpoint } from '@nuxt/test-utils/runtime'
|
|
89
|
+
import { flushPromises } from '@vue/test-utils'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Data Attributes Conventions
|
|
95
|
+
|
|
96
|
+
This guide uses a simple convention for selecting elements in tests:
|
|
97
|
+
|
|
98
|
+
### `data-test-id` - Unique identifier
|
|
99
|
+
|
|
100
|
+
Pattern: `domain-element-action?`
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
<button data-test-id="user-form-submit">Submit</button>
|
|
104
|
+
<input data-test-id="product-name-input" />
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### `data-test-class` - Element group
|
|
108
|
+
|
|
109
|
+
Pattern: `domain-category-type`
|
|
110
|
+
|
|
111
|
+
```html
|
|
112
|
+
<div data-test-class="product-table-row">...</div>
|
|
113
|
+
<input data-test-class="filter-input" />
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `data-test-state` - Component state
|
|
117
|
+
|
|
118
|
+
Values: `loading | error | success | empty | invalid | disabled | selected`
|
|
119
|
+
|
|
120
|
+
```html
|
|
121
|
+
<div data-test-state="loading">Loading...</div>
|
|
122
|
+
<form data-test-state="invalid">...</form>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Table of Contents by Use Case
|
|
128
|
+
|
|
129
|
+
### Navigation & Routing
|
|
130
|
+
- [Navigate to a page](#nav-navigate-to-page)
|
|
131
|
+
- [Navigation with parameters](#nav-with-params)
|
|
132
|
+
- [Hierarchical navigation (breadcrumb)](#nav-breadcrumb)
|
|
133
|
+
- [Redirect middleware](#nav-middleware)
|
|
134
|
+
|
|
135
|
+
### Forms & Validation
|
|
136
|
+
- [Form with required field validation](#form-required-validation)
|
|
137
|
+
- [Form with backend errors](#form-backend-errors)
|
|
138
|
+
- [Form with submission](#form-submit)
|
|
139
|
+
- [Edit form with prefill](#form-edit-prefill)
|
|
140
|
+
- [Cancel form](#form-cancel)
|
|
141
|
+
- [Multi-step form](#form-multi-step)
|
|
142
|
+
|
|
143
|
+
### Tables & Lists
|
|
144
|
+
- [Table with data](#table-display-data)
|
|
145
|
+
- [Table with sorting](#table-sorting)
|
|
146
|
+
- [Table with filters](#table-filters)
|
|
147
|
+
- [Table with pagination](#table-pagination)
|
|
148
|
+
- [Table with multi-selection](#table-multi-select)
|
|
149
|
+
- [Table with loading](#table-loading)
|
|
150
|
+
- [Empty list](#table-empty-state)
|
|
151
|
+
|
|
152
|
+
### Modals & Dialogs
|
|
153
|
+
- [Open/close modal](#modal-open-close)
|
|
154
|
+
- [Modal with form](#modal-with-form)
|
|
155
|
+
- [Confirmation modal](#modal-confirmation)
|
|
156
|
+
- [Modal with dynamic content](#modal-dynamic-content)
|
|
157
|
+
|
|
158
|
+
### Permissions & Auth
|
|
159
|
+
- [Conditional display based on permissions](#auth-conditional-display)
|
|
160
|
+
- [Redirect if unauthorized](#auth-redirect)
|
|
161
|
+
- [Check multiple permissions](#auth-multiple-permissions)
|
|
162
|
+
- [Restricted action](#auth-restricted-action)
|
|
163
|
+
|
|
164
|
+
### API & Data Fetching
|
|
165
|
+
- [Component with useFetch](#api-use-fetch)
|
|
166
|
+
- [Component with $fetch](#api-dollar-fetch)
|
|
167
|
+
- [API error handling](#api-error-handling)
|
|
168
|
+
- [Retry on error](#api-retry)
|
|
169
|
+
- [Mock API endpoint](#api-mock-endpoint)
|
|
170
|
+
|
|
171
|
+
### States & Loading
|
|
172
|
+
- [Loading state](#state-loading)
|
|
173
|
+
- [Error state](#state-error)
|
|
174
|
+
- [Empty state (no data)](#state-empty)
|
|
175
|
+
- [Success state](#state-success)
|
|
176
|
+
- [State transitions](#state-transitions)
|
|
177
|
+
|
|
178
|
+
### User Interactions
|
|
179
|
+
- [Click button](#interaction-button-click)
|
|
180
|
+
- [Fill input field](#interaction-fill-input)
|
|
181
|
+
- [Select option](#interaction-select-option)
|
|
182
|
+
- [Check checkbox](#interaction-checkbox)
|
|
183
|
+
- [File upload](#interaction-file-upload)
|
|
184
|
+
- [Debounce on input](#interaction-debounce)
|
|
185
|
+
- [Drag & drop](#interaction-drag-drop)
|
|
186
|
+
|
|
187
|
+
### i18n & Translations
|
|
188
|
+
- [Check i18n keys](#i18n-check-keys)
|
|
189
|
+
- [Switch language](#i18n-switch-locale)
|
|
190
|
+
- [Translation with interpolation](#i18n-interpolation)
|
|
191
|
+
- [Plural translation](#i18n-plural)
|
|
192
|
+
|
|
193
|
+
### Utils & Helpers
|
|
194
|
+
- [Date formatter](#util-format-date)
|
|
195
|
+
- [File size formatter](#util-format-bytes)
|
|
196
|
+
- [Email validation](#util-validate-email)
|
|
197
|
+
- [IP validation](#util-validate-ip)
|
|
198
|
+
- [Data mapping](#util-data-mapping)
|
|
199
|
+
- [Group by key](#util-group-by)
|
|
200
|
+
- [Sort array](#util-sort-by)
|
|
201
|
+
- [Parse CSV](#util-parse-csv)
|
|
202
|
+
|
|
203
|
+
### Stores & State Management
|
|
204
|
+
- [Store: add item](#store-add-item)
|
|
205
|
+
- [Store: remove item](#store-remove-item)
|
|
206
|
+
- [Store: filter items](#store-filter-items)
|
|
207
|
+
- [Store: fetch data](#store-fetch-data)
|
|
208
|
+
- [Store: reset](#store-reset)
|
|
209
|
+
|
|
210
|
+
### Composables
|
|
211
|
+
- [Form composable](#composable-form)
|
|
212
|
+
- [API composable](#composable-api)
|
|
213
|
+
- [Permissions composable](#composable-permissions)
|
|
214
|
+
- [Notification composable](#composable-notification)
|
|
215
|
+
|
|
216
|
+
### Advanced Cases
|
|
217
|
+
- [Race conditions (concurrent requests)](#advanced-race-conditions)
|
|
218
|
+
- [CSV import with mapping](#advanced-csv-import)
|
|
219
|
+
- [Data export](#advanced-export-data)
|
|
220
|
+
- [Search with multiple filters](#advanced-multi-search)
|
|
221
|
+
- [Complete workflow (CRUD)](#advanced-crud-workflow)
|
|
222
|
+
|
|
223
|
+
### Vuetify & Components (without data attributes)
|
|
224
|
+
- [Testing component props](#vuetify-props)
|
|
225
|
+
- [Finding a Vuetify component (findComponent)](#vuetify-find-component)
|
|
226
|
+
- [Updating props (setProps)](#vuetify-set-props)
|
|
227
|
+
- [Testing emits](#vuetify-emits)
|
|
228
|
+
- [Testing slots](#vuetify-slots)
|
|
229
|
+
- [Disabled and loading state in Vuetify](#vuetify-disabled-loading)
|
|
230
|
+
- [Testing v-model](#vuetify-v-model)
|
|
231
|
+
- [VDialog and overlays](#vuetify-dialog)
|
|
232
|
+
- [VDataTable: items and headers](#vuetify-datatable)
|
|
233
|
+
- [CSS classes (classes())](#vuetify-classes)
|
|
234
|
+
- [HTML attributes (attributes())](#vuetify-attributes)
|
|
235
|
+
|
|
236
|
+
### Components with Teleport (Portals)
|
|
237
|
+
- [The teleported components problem](#teleport-problem)
|
|
238
|
+
- [Affected components](#teleport-components)
|
|
239
|
+
- [The method to apply](#teleport-method)
|
|
240
|
+
- [Complete example — VDialog with form](#teleport-example)
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
# Navigation & Routing
|
|
245
|
+
|
|
246
|
+
## <a name="nav-navigate-to-page"></a>Navigate to a page
|
|
247
|
+
|
|
248
|
+
**When to use**: Verify that clicking a link or button triggers navigation to the correct page.
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
test('navigates to user detail page', async () => {
|
|
252
|
+
const routerPush = vi.fn()
|
|
253
|
+
|
|
254
|
+
mockNuxtImport('useRouter', () => {
|
|
255
|
+
return () => ({
|
|
256
|
+
push: routerPush
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const wrapper = await mountSuspended(UserCard, {
|
|
261
|
+
props: { user: { id: 123, name: 'John Doe' } }
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
await wrapper.find('[data-test-id="view-details-btn"]').trigger('click')
|
|
265
|
+
|
|
266
|
+
expect(routerPush).toHaveBeenCalledWith('/users/123')
|
|
267
|
+
})
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## <a name="nav-with-params"></a>Navigation with parameters
|
|
273
|
+
|
|
274
|
+
**When to use**: Verify that a page correctly receives and uses route parameters.
|
|
275
|
+
|
|
276
|
+
```js
|
|
277
|
+
test('displays user from route params', async () => {
|
|
278
|
+
mockNuxtImport('useRoute', () => {
|
|
279
|
+
return () => ({
|
|
280
|
+
params: { id: '123' }
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
mockNuxtImport('useFetch', () => {
|
|
285
|
+
return () => ({
|
|
286
|
+
data: ref({ id: 123, name: 'John Doe' }),
|
|
287
|
+
pending: ref(false),
|
|
288
|
+
error: ref(null)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const wrapper = await mountSuspended(UserDetailPage)
|
|
293
|
+
|
|
294
|
+
expect(wrapper.find('[data-test-id="user-name"]').text()).toBe('John Doe')
|
|
295
|
+
})
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## <a name="nav-breadcrumb"></a>Hierarchical navigation (breadcrumb)
|
|
301
|
+
|
|
302
|
+
**When to use**: Verify that breadcrumb navigation correctly displays the navigation hierarchy.
|
|
303
|
+
|
|
304
|
+
```js
|
|
305
|
+
test('displays breadcrumb navigation', async () => {
|
|
306
|
+
mockNuxtImport('useRoute', () => {
|
|
307
|
+
return () => ({
|
|
308
|
+
path: '/products/electronics/smartphones/iphone',
|
|
309
|
+
matched: [
|
|
310
|
+
{ path: '/products', name: 'Products' },
|
|
311
|
+
{ path: '/products/electronics', name: 'Electronics' },
|
|
312
|
+
{ path: '/products/electronics/smartphones', name: 'Smartphones' }
|
|
313
|
+
]
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const wrapper = await mountSuspended(Breadcrumb)
|
|
318
|
+
|
|
319
|
+
const items = wrapper.findAll('[data-test-class="breadcrumb-item"]')
|
|
320
|
+
expect(items).toHaveLength(3)
|
|
321
|
+
expect(items[0].text()).toBe('Products')
|
|
322
|
+
expect(items[2].text()).toBe('Smartphones')
|
|
323
|
+
})
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## <a name="nav-middleware"></a>Redirect middleware
|
|
329
|
+
|
|
330
|
+
**When to use**: Verify that middleware correctly redirects based on conditions (auth, permissions, etc.).
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
test('middleware redirects unauthenticated users', async () => {
|
|
334
|
+
const mockNavigateTo = vi.fn()
|
|
335
|
+
|
|
336
|
+
mockNuxtImport('navigateTo', () => mockNavigateTo)
|
|
337
|
+
mockNuxtImport('useAuth', () => {
|
|
338
|
+
return () => ({
|
|
339
|
+
isAuthenticated: ref(false)
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const { default: authMiddleware } = await import('~/middleware/auth.ts')
|
|
344
|
+
|
|
345
|
+
await authMiddleware()
|
|
346
|
+
|
|
347
|
+
expect(mockNavigateTo).toHaveBeenCalledWith('/login')
|
|
348
|
+
})
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
# Forms & Validation
|
|
354
|
+
|
|
355
|
+
## <a name="form-required-validation"></a>Form with required field validation
|
|
356
|
+
|
|
357
|
+
**When to use**: Verify that a form prevents submission if required fields are empty.
|
|
358
|
+
|
|
359
|
+
```js
|
|
360
|
+
test('validates required fields before submit', async () => {
|
|
361
|
+
const wrapper = await mountSuspended(UserForm)
|
|
362
|
+
|
|
363
|
+
await wrapper.find('[data-test-id="user-form-submit"]').trigger('click')
|
|
364
|
+
|
|
365
|
+
expect(wrapper.find('[data-test-id="name-error"]').exists()).toBe(true)
|
|
366
|
+
expect(wrapper.find('[data-test-id="name-error"]').text()).toBe('Name is required')
|
|
367
|
+
expect(wrapper.find('[data-test-state="invalid"]').exists()).toBe(true)
|
|
368
|
+
|
|
369
|
+
await wrapper.find('[data-test-id="user-form-name"]').setValue('John Doe')
|
|
370
|
+
|
|
371
|
+
expect(wrapper.find('[data-test-id="name-error"]').exists()).toBe(false)
|
|
372
|
+
expect(wrapper.find('[data-test-state="invalid"]').exists()).toBe(false)
|
|
373
|
+
})
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## <a name="form-backend-errors"></a>Form with backend errors
|
|
379
|
+
|
|
380
|
+
**When to use**: Verify that a form correctly displays errors returned by the API.
|
|
381
|
+
|
|
382
|
+
```js
|
|
383
|
+
test('displays backend validation errors', async () => {
|
|
384
|
+
registerEndpoint('/api/users', {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
handler: () => {
|
|
387
|
+
throw createError({
|
|
388
|
+
statusCode: 422,
|
|
389
|
+
data: {
|
|
390
|
+
errors: {
|
|
391
|
+
email: 'Email already exists'
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
const wrapper = await mountSuspended(UserForm)
|
|
399
|
+
|
|
400
|
+
await wrapper.find('[data-test-id="user-form-name"]').setValue('John Doe')
|
|
401
|
+
await wrapper.find('[data-test-id="user-form-email"]').setValue('john@example.com')
|
|
402
|
+
await wrapper.find('[data-test-id="user-form-submit"]').trigger('click')
|
|
403
|
+
await flushPromises()
|
|
404
|
+
|
|
405
|
+
expect(wrapper.find('[data-test-id="email-error"]').text()).toBe('Email already exists')
|
|
406
|
+
})
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## <a name="form-submit"></a>Form with submission
|
|
412
|
+
|
|
413
|
+
**When to use**: Verify that a form correctly submits data and shows confirmation.
|
|
414
|
+
|
|
415
|
+
```js
|
|
416
|
+
test('submits form data successfully', async () => {
|
|
417
|
+
const mockCreate = vi.fn().mockResolvedValue({ id: 123 })
|
|
418
|
+
|
|
419
|
+
registerEndpoint('/api/users', {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
handler: () => mockCreate()
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
const wrapper = await mountSuspended(UserForm)
|
|
425
|
+
|
|
426
|
+
await wrapper.find('[data-test-id="user-form-name"]').setValue('John Doe')
|
|
427
|
+
await wrapper.find('[data-test-id="user-form-email"]').setValue('john@example.com')
|
|
428
|
+
await wrapper.find('[data-test-id="user-form-submit"]').trigger('click')
|
|
429
|
+
|
|
430
|
+
expect(wrapper.find('[data-test-state="loading"]').exists()).toBe(true)
|
|
431
|
+
|
|
432
|
+
await flushPromises()
|
|
433
|
+
|
|
434
|
+
expect(wrapper.find('[data-test-id="success-message"]').exists()).toBe(true)
|
|
435
|
+
expect(wrapper.find('[data-test-id="success-message"]').text()).toContain('User created')
|
|
436
|
+
})
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
## <a name="form-edit-prefill"></a>Edit form with prefill
|
|
442
|
+
|
|
443
|
+
**When to use**: Verify that an edit form loads and correctly displays existing data.
|
|
444
|
+
|
|
445
|
+
```js
|
|
446
|
+
test('prefills form with existing data', async () => {
|
|
447
|
+
mockNuxtImport('useFetch', () => {
|
|
448
|
+
return () => ({
|
|
449
|
+
data: ref({
|
|
450
|
+
id: 123,
|
|
451
|
+
name: 'John Doe',
|
|
452
|
+
email: 'john@example.com'
|
|
453
|
+
}),
|
|
454
|
+
pending: ref(false),
|
|
455
|
+
error: ref(null)
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
const wrapper = await mountSuspended(UserEditForm)
|
|
460
|
+
|
|
461
|
+
expect(wrapper.find('[data-test-id="user-form-name"]').element.value).toBe('John Doe')
|
|
462
|
+
expect(wrapper.find('[data-test-id="user-form-email"]').element.value).toBe('john@example.com')
|
|
463
|
+
})
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## <a name="form-cancel"></a>Cancel form
|
|
469
|
+
|
|
470
|
+
**When to use**: Verify that a cancel button abandons changes and redirects the user.
|
|
471
|
+
|
|
472
|
+
```js
|
|
473
|
+
test('cancels form without saving', async () => {
|
|
474
|
+
const routerPush = vi.fn()
|
|
475
|
+
|
|
476
|
+
mockNuxtImport('useRouter', () => {
|
|
477
|
+
return () => ({ push: routerPush })
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
const wrapper = await mountSuspended(UserEditForm, {
|
|
481
|
+
props: { user: { id: 123, name: 'John Doe' } }
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
await wrapper.find('[data-test-id="user-form-name"]').setValue('Modified Name')
|
|
485
|
+
await wrapper.find('[data-test-id="user-form-cancel"]').trigger('click')
|
|
486
|
+
|
|
487
|
+
expect(routerPush).toHaveBeenCalledWith('/users/123')
|
|
488
|
+
})
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
---
|
|
492
|
+
|
|
493
|
+
## <a name="form-multi-step"></a>Multi-step form
|
|
494
|
+
|
|
495
|
+
**When to use**: Verify navigation between form steps and validation of each step.
|
|
496
|
+
|
|
497
|
+
```js
|
|
498
|
+
test('navigates through form steps', async () => {
|
|
499
|
+
const wrapper = await mountSuspended(MultiStepForm)
|
|
500
|
+
|
|
501
|
+
// Step 1
|
|
502
|
+
expect(wrapper.find('[data-test-id="step-indicator"]').text()).toBe('Step 1 of 3')
|
|
503
|
+
|
|
504
|
+
await wrapper.find('[data-test-id="step1-name"]').setValue('John Doe')
|
|
505
|
+
await wrapper.find('[data-test-id="step-next"]').trigger('click')
|
|
506
|
+
|
|
507
|
+
// Step 2
|
|
508
|
+
expect(wrapper.find('[data-test-id="step-indicator"]').text()).toBe('Step 2 of 3')
|
|
509
|
+
|
|
510
|
+
await wrapper.find('[data-test-id="step2-email"]').setValue('john@example.com')
|
|
511
|
+
await wrapper.find('[data-test-id="step-next"]').trigger('click')
|
|
512
|
+
|
|
513
|
+
// Step 3
|
|
514
|
+
expect(wrapper.find('[data-test-id="step-indicator"]').text()).toBe('Step 3 of 3')
|
|
515
|
+
|
|
516
|
+
await wrapper.find('[data-test-id="step3-confirm"]').setChecked(true)
|
|
517
|
+
await wrapper.find('[data-test-id="step-submit"]').trigger('click')
|
|
518
|
+
|
|
519
|
+
await flushPromises()
|
|
520
|
+
|
|
521
|
+
expect(wrapper.find('[data-test-id="success-message"]').exists()).toBe(true)
|
|
522
|
+
})
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
# Tables & Lists
|
|
528
|
+
|
|
529
|
+
## <a name="table-display-data"></a>Table with data
|
|
530
|
+
|
|
531
|
+
**When to use**: Verify that a table correctly displays a list of data.
|
|
532
|
+
|
|
533
|
+
```js
|
|
534
|
+
test('displays list of users', async () => {
|
|
535
|
+
mockNuxtImport('useFetch', () => {
|
|
536
|
+
return () => ({
|
|
537
|
+
data: ref([
|
|
538
|
+
{ id: 1, name: 'John Doe', email: 'john@example.com' },
|
|
539
|
+
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
|
|
540
|
+
]),
|
|
541
|
+
pending: ref(false),
|
|
542
|
+
error: ref(null)
|
|
543
|
+
})
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
const wrapper = await mountSuspended(UserTable)
|
|
547
|
+
|
|
548
|
+
const rows = wrapper.findAll('[data-test-class="user-table-row"]')
|
|
549
|
+
expect(rows).toHaveLength(2)
|
|
550
|
+
expect(rows[0].text()).toContain('John Doe')
|
|
551
|
+
expect(rows[1].text()).toContain('Jane Smith')
|
|
552
|
+
})
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
---
|
|
556
|
+
|
|
557
|
+
## <a name="table-sorting"></a>Table with sorting
|
|
558
|
+
|
|
559
|
+
**When to use**: Verify that clicking a column header sorts the data correctly.
|
|
560
|
+
|
|
561
|
+
```js
|
|
562
|
+
test('sorts table by column', async () => {
|
|
563
|
+
const wrapper = await mountSuspended(UserTable, {
|
|
564
|
+
props: {
|
|
565
|
+
users: [
|
|
566
|
+
{ id: 3, name: 'Charlie' },
|
|
567
|
+
{ id: 1, name: 'Alice' },
|
|
568
|
+
{ id: 2, name: 'Bob' }
|
|
569
|
+
]
|
|
570
|
+
}
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
await wrapper.find('[data-test-id="table-header-name"]').trigger('click')
|
|
574
|
+
|
|
575
|
+
const rows = wrapper.findAll('[data-test-class="user-table-row"]')
|
|
576
|
+
expect(rows[0].text()).toContain('Alice')
|
|
577
|
+
expect(rows[1].text()).toContain('Bob')
|
|
578
|
+
expect(rows[2].text()).toContain('Charlie')
|
|
579
|
+
|
|
580
|
+
// Reverse sort
|
|
581
|
+
await wrapper.find('[data-test-id="table-header-name"]').trigger('click')
|
|
582
|
+
|
|
583
|
+
const rowsDesc = wrapper.findAll('[data-test-class="user-table-row"]')
|
|
584
|
+
expect(rowsDesc[0].text()).toContain('Charlie')
|
|
585
|
+
})
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## <a name="table-filters"></a>Table with filters
|
|
591
|
+
|
|
592
|
+
**When to use**: Verify that a filter correctly reduces displayed results.
|
|
593
|
+
|
|
594
|
+
```js
|
|
595
|
+
test('filters table by search term', async () => {
|
|
596
|
+
const wrapper = await mountSuspended(UserTable, {
|
|
597
|
+
props: {
|
|
598
|
+
users: [
|
|
599
|
+
{ id: 1, name: 'John Doe', role: 'admin' },
|
|
600
|
+
{ id: 2, name: 'Jane Smith', role: 'user' },
|
|
601
|
+
{ id: 3, name: 'John Smith', role: 'user' }
|
|
602
|
+
]
|
|
603
|
+
}
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
await wrapper.find('[data-test-id="filter-search"]').setValue('John')
|
|
607
|
+
|
|
608
|
+
const rows = wrapper.findAll('[data-test-class="user-table-row"]')
|
|
609
|
+
expect(rows).toHaveLength(2)
|
|
610
|
+
|
|
611
|
+
await wrapper.find('[data-test-id="filter-role"]').setValue('admin')
|
|
612
|
+
|
|
613
|
+
const filteredRows = wrapper.findAll('[data-test-class="user-table-row"]')
|
|
614
|
+
expect(filteredRows).toHaveLength(1)
|
|
615
|
+
expect(filteredRows[0].text()).toContain('John Doe')
|
|
616
|
+
})
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
---
|
|
620
|
+
|
|
621
|
+
## <a name="table-pagination"></a>Table with pagination
|
|
622
|
+
|
|
623
|
+
**When to use**: Verify that pagination correctly loads next/previous pages.
|
|
624
|
+
|
|
625
|
+
```js
|
|
626
|
+
test('paginates through table pages', async () => {
|
|
627
|
+
const fetchSpy = vi.fn()
|
|
628
|
+
.mockResolvedValueOnce({ data: [{ id: 1 }, { id: 2 }], total: 50 })
|
|
629
|
+
.mockResolvedValueOnce({ data: [{ id: 3 }, { id: 4 }], total: 50 })
|
|
630
|
+
|
|
631
|
+
registerEndpoint('/api/users', {
|
|
632
|
+
method: 'GET',
|
|
633
|
+
handler: () => fetchSpy()
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
const wrapper = await mountSuspended(UserTable)
|
|
637
|
+
|
|
638
|
+
expect(wrapper.find('[data-test-id="page-info"]').text()).toBe('Page 1 of 25')
|
|
639
|
+
|
|
640
|
+
await wrapper.find('[data-test-id="pagination-next"]').trigger('click')
|
|
641
|
+
await flushPromises()
|
|
642
|
+
|
|
643
|
+
expect(wrapper.find('[data-test-id="page-info"]').text()).toBe('Page 2 of 25')
|
|
644
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
|
645
|
+
})
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## <a name="table-multi-select"></a>Table with multi-selection
|
|
651
|
+
|
|
652
|
+
**When to use**: Verify that multiple rows can be selected and bulk actions performed.
|
|
653
|
+
|
|
654
|
+
```js
|
|
655
|
+
test('selects multiple rows and performs bulk action', async () => {
|
|
656
|
+
registerEndpoint('/api/users/bulk-delete', {
|
|
657
|
+
method: 'POST',
|
|
658
|
+
handler: () => ({ success: true })
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
const wrapper = await mountSuspended(UserTable, {
|
|
662
|
+
props: {
|
|
663
|
+
users: [
|
|
664
|
+
{ id: 1, name: 'John' },
|
|
665
|
+
{ id: 2, name: 'Jane' },
|
|
666
|
+
{ id: 3, name: 'Bob' }
|
|
667
|
+
]
|
|
668
|
+
}
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
await wrapper.find('[data-test-id="select-user-1"]').setChecked(true)
|
|
672
|
+
await wrapper.find('[data-test-id="select-user-2"]').setChecked(true)
|
|
673
|
+
|
|
674
|
+
expect(wrapper.find('[data-test-id="selected-count"]').text()).toBe('2 selected')
|
|
675
|
+
|
|
676
|
+
await wrapper.find('[data-test-id="bulk-delete-btn"]').trigger('click')
|
|
677
|
+
await flushPromises()
|
|
678
|
+
|
|
679
|
+
expect(wrapper.find('[data-test-id="success-message"]').exists()).toBe(true)
|
|
680
|
+
})
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
## <a name="table-loading"></a>Table with loading
|
|
686
|
+
|
|
687
|
+
**When to use**: Verify that a loading indicator displays while fetching data.
|
|
688
|
+
|
|
689
|
+
```js
|
|
690
|
+
test('displays loading state while fetching', async () => {
|
|
691
|
+
mockNuxtImport('useFetch', () => {
|
|
692
|
+
return () => ({
|
|
693
|
+
data: ref(null),
|
|
694
|
+
pending: ref(true),
|
|
695
|
+
error: ref(null)
|
|
696
|
+
})
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
const wrapper = await mountSuspended(UserTable)
|
|
700
|
+
|
|
701
|
+
expect(wrapper.find('[data-test-state="loading"]').exists()).toBe(true)
|
|
702
|
+
expect(wrapper.find('[data-test-id="loading-spinner"]').exists()).toBe(true)
|
|
703
|
+
})
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
---
|
|
707
|
+
|
|
708
|
+
## <a name="table-empty-state"></a>Empty list
|
|
709
|
+
|
|
710
|
+
**When to use**: Verify that an appropriate message displays when there's no data.
|
|
711
|
+
|
|
712
|
+
```js
|
|
713
|
+
test('displays empty state when no data', async () => {
|
|
714
|
+
mockNuxtImport('useFetch', () => {
|
|
715
|
+
return () => ({
|
|
716
|
+
data: ref([]),
|
|
717
|
+
pending: ref(false),
|
|
718
|
+
error: ref(null)
|
|
719
|
+
})
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
const wrapper = await mountSuspended(UserTable)
|
|
723
|
+
|
|
724
|
+
expect(wrapper.find('[data-test-state="empty"]').exists()).toBe(true)
|
|
725
|
+
expect(wrapper.find('[data-test-id="empty-message"]').text()).toContain('No users found')
|
|
726
|
+
})
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
|
|
731
|
+
# Modals & Dialogs
|
|
732
|
+
|
|
733
|
+
## <a name="modal-open-close"></a>Open/close modal
|
|
734
|
+
|
|
735
|
+
**When to use**: Verify that a modal opens and closes correctly.
|
|
736
|
+
|
|
737
|
+
```js
|
|
738
|
+
test('opens and closes modal', async () => {
|
|
739
|
+
const wrapper = await mountSuspended(UserModal)
|
|
740
|
+
|
|
741
|
+
expect(wrapper.findAll('[data-test-class="modal-content"]')).toHaveLength(0)
|
|
742
|
+
|
|
743
|
+
await wrapper.find('[data-test-id="open-modal-btn"]').trigger('click')
|
|
744
|
+
|
|
745
|
+
expect(wrapper.findAll('[data-test-class="modal-content"]')).toHaveLength(1)
|
|
746
|
+
expect(wrapper.find('[data-test-id="modal-title"]').text()).toBe('User Details')
|
|
747
|
+
|
|
748
|
+
await wrapper.find('[data-test-id="close-modal-btn"]').trigger('click')
|
|
749
|
+
|
|
750
|
+
expect(wrapper.findAll('[data-test-class="modal-content"]')).toHaveLength(0)
|
|
751
|
+
})
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
---
|
|
755
|
+
|
|
756
|
+
## <a name="modal-with-form"></a>Modal with form
|
|
757
|
+
|
|
758
|
+
**When to use**: Verify that a modal containing a form correctly submits data.
|
|
759
|
+
|
|
760
|
+
```js
|
|
761
|
+
test('submits form inside modal', async () => {
|
|
762
|
+
registerEndpoint('/api/users', {
|
|
763
|
+
method: 'POST',
|
|
764
|
+
handler: () => ({ id: 123, name: 'John Doe' })
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
const wrapper = await mountSuspended(UserCreateModal, {
|
|
768
|
+
props: { isOpen: true }
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
await wrapper.find('[data-test-id="modal-form-name"]').setValue('John Doe')
|
|
772
|
+
await wrapper.find('[data-test-id="modal-form-email"]').setValue('john@example.com')
|
|
773
|
+
await wrapper.find('[data-test-id="modal-form-submit"]').trigger('click')
|
|
774
|
+
|
|
775
|
+
await flushPromises()
|
|
776
|
+
|
|
777
|
+
expect(wrapper.emitted('user-created')).toBeTruthy()
|
|
778
|
+
expect(wrapper.emitted('user-created')[0]).toEqual([{ id: 123, name: 'John Doe' }])
|
|
779
|
+
})
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
---
|
|
783
|
+
|
|
784
|
+
## <a name="modal-confirmation"></a>Confirmation modal
|
|
785
|
+
|
|
786
|
+
**When to use**: Verify that a confirmation modal requests validation before a destructive action.
|
|
787
|
+
|
|
788
|
+
```js
|
|
789
|
+
test('confirms action before deleting', async () => {
|
|
790
|
+
const mockDelete = vi.fn().mockResolvedValue({})
|
|
791
|
+
|
|
792
|
+
registerEndpoint('/api/users/123', {
|
|
793
|
+
method: 'DELETE',
|
|
794
|
+
handler: () => mockDelete()
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
const wrapper = await mountSuspended(UserList)
|
|
798
|
+
|
|
799
|
+
await wrapper.find('[data-test-id="delete-user-123"]').trigger('click')
|
|
800
|
+
|
|
801
|
+
expect(wrapper.find('[data-test-class="confirmation-modal"]').exists()).toBe(true)
|
|
802
|
+
expect(wrapper.find('[data-test-id="confirm-message"]').text()).toContain('Are you sure')
|
|
803
|
+
|
|
804
|
+
await wrapper.find('[data-test-id="confirm-cancel"]').trigger('click')
|
|
805
|
+
|
|
806
|
+
expect(wrapper.find('[data-test-class="confirmation-modal"]').exists()).toBe(false)
|
|
807
|
+
expect(mockDelete).not.toHaveBeenCalled()
|
|
808
|
+
|
|
809
|
+
await wrapper.find('[data-test-id="delete-user-123"]').trigger('click')
|
|
810
|
+
await wrapper.find('[data-test-id="confirm-delete"]').trigger('click')
|
|
811
|
+
|
|
812
|
+
await flushPromises()
|
|
813
|
+
|
|
814
|
+
expect(mockDelete).toHaveBeenCalled()
|
|
815
|
+
})
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
---
|
|
819
|
+
|
|
820
|
+
## <a name="modal-dynamic-content"></a>Modal with dynamic content
|
|
821
|
+
|
|
822
|
+
**When to use**: Verify that a modal loads and displays different content based on context.
|
|
823
|
+
|
|
824
|
+
```js
|
|
825
|
+
test('displays different content based on user', async () => {
|
|
826
|
+
const wrapper = await mountSuspended(UserDetailsModal, {
|
|
827
|
+
props: {
|
|
828
|
+
isOpen: true,
|
|
829
|
+
userId: 123
|
|
830
|
+
}
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
mockNuxtImport('useFetch', () => {
|
|
834
|
+
return () => ({
|
|
835
|
+
data: ref({ id: 123, name: 'John Doe', role: 'admin' }),
|
|
836
|
+
pending: ref(false),
|
|
837
|
+
error: ref(null)
|
|
838
|
+
})
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
await wrapper.vm.$nextTick()
|
|
842
|
+
|
|
843
|
+
expect(wrapper.find('[data-test-id="user-name"]').text()).toBe('John Doe')
|
|
844
|
+
expect(wrapper.find('[data-test-id="user-role"]').text()).toBe('admin')
|
|
845
|
+
|
|
846
|
+
await wrapper.setProps({ userId: 456 })
|
|
847
|
+
|
|
848
|
+
// Modal should load new data
|
|
849
|
+
expect(wrapper.find('[data-test-state="loading"]').exists()).toBe(true)
|
|
850
|
+
})
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
---
|
|
854
|
+
|
|
855
|
+
# Permissions & Auth
|
|
856
|
+
|
|
857
|
+
## <a name="auth-conditional-display"></a>Conditional display based on permissions
|
|
858
|
+
|
|
859
|
+
**When to use**: Verify that UI elements only display if the user has the correct permissions.
|
|
860
|
+
|
|
861
|
+
```js
|
|
862
|
+
test('shows actions based on permissions', async () => {
|
|
863
|
+
mockNuxtImport('usePermissions', () => {
|
|
864
|
+
return () => ({
|
|
865
|
+
can: (permission) => permission === 'user.edit'
|
|
866
|
+
})
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
const wrapper = await mountSuspended(UserCard, {
|
|
870
|
+
props: { user: { id: 123, name: 'John Doe' } }
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
expect(wrapper.find('[data-test-id="edit-btn"]').exists()).toBe(true)
|
|
874
|
+
expect(wrapper.find('[data-test-id="delete-btn"]').exists()).toBe(false)
|
|
875
|
+
})
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
---
|
|
879
|
+
|
|
880
|
+
## <a name="auth-redirect"></a>Redirect if unauthorized
|
|
881
|
+
|
|
882
|
+
**When to use**: Verify that an unauthorized user is redirected to an error or login page.
|
|
883
|
+
|
|
884
|
+
```js
|
|
885
|
+
test('redirects unauthorized user', async () => {
|
|
886
|
+
const mockNavigateTo = vi.fn()
|
|
887
|
+
|
|
888
|
+
mockNuxtImport('navigateTo', () => mockNavigateTo)
|
|
889
|
+
mockNuxtImport('usePermissions', () => {
|
|
890
|
+
return () => ({
|
|
891
|
+
can: () => false
|
|
892
|
+
})
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
const wrapper = await mountSuspended(AdminPage)
|
|
896
|
+
await flushPromises()
|
|
897
|
+
|
|
898
|
+
expect(mockNavigateTo).toHaveBeenCalledWith('/403')
|
|
899
|
+
})
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
---
|
|
903
|
+
|
|
904
|
+
## <a name="auth-multiple-permissions"></a>Check multiple permissions
|
|
905
|
+
|
|
906
|
+
**When to use**: Verify that a user has all required permissions for an action.
|
|
907
|
+
|
|
908
|
+
```js
|
|
909
|
+
test('requires multiple permissions for action', async () => {
|
|
910
|
+
mockNuxtImport('usePermissions', () => {
|
|
911
|
+
return () => ({
|
|
912
|
+
canAll: (permissions) => {
|
|
913
|
+
const userPermissions = ['user.view', 'user.edit']
|
|
914
|
+
return permissions.every(p => userPermissions.includes(p))
|
|
915
|
+
}
|
|
916
|
+
})
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
const wrapper = await mountSuspended(UserManagement)
|
|
920
|
+
|
|
921
|
+
// User can view and edit
|
|
922
|
+
expect(wrapper.find('[data-test-id="edit-btn"]').exists()).toBe(true)
|
|
923
|
+
|
|
924
|
+
// But can't delete (permission missing)
|
|
925
|
+
expect(wrapper.find('[data-test-id="delete-btn"]').exists()).toBe(false)
|
|
926
|
+
})
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
---
|
|
930
|
+
|
|
931
|
+
## <a name="auth-restricted-action"></a>Restricted action
|
|
932
|
+
|
|
933
|
+
**When to use**: Verify that an action can only be performed by authorized users.
|
|
934
|
+
|
|
935
|
+
```js
|
|
936
|
+
test('prevents restricted action without permission', async () => {
|
|
937
|
+
mockNuxtImport('usePermissions', () => {
|
|
938
|
+
return () => ({
|
|
939
|
+
can: () => false
|
|
940
|
+
})
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
const wrapper = await mountSuspended(UserActions, {
|
|
944
|
+
props: { user: { id: 123 } }
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
await wrapper.find('[data-test-id="delete-btn"]').trigger('click')
|
|
948
|
+
|
|
949
|
+
expect(wrapper.find('[data-test-id="permission-error"]').exists()).toBe(true)
|
|
950
|
+
expect(wrapper.find('[data-test-id="permission-error"]').text()).toContain('not authorized')
|
|
951
|
+
})
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
---
|
|
955
|
+
|
|
956
|
+
# API & Data Fetching
|
|
957
|
+
|
|
958
|
+
## <a name="api-use-fetch"></a>Component with useFetch
|
|
959
|
+
|
|
960
|
+
**When to use**: Verify that a component correctly loads data with useFetch.
|
|
961
|
+
|
|
962
|
+
```js
|
|
963
|
+
test('loads data with useFetch', async () => {
|
|
964
|
+
mockNuxtImport('useFetch', () => {
|
|
965
|
+
return () => ({
|
|
966
|
+
data: ref([
|
|
967
|
+
{ id: 1, name: 'Product 1' },
|
|
968
|
+
{ id: 2, name: 'Product 2' }
|
|
969
|
+
]),
|
|
970
|
+
pending: ref(false),
|
|
971
|
+
error: ref(null)
|
|
972
|
+
})
|
|
973
|
+
})
|
|
974
|
+
|
|
975
|
+
const wrapper = await mountSuspended(ProductList)
|
|
976
|
+
|
|
977
|
+
expect(wrapper.findAll('[data-test-class="product-item"]')).toHaveLength(2)
|
|
978
|
+
})
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
---
|
|
982
|
+
|
|
983
|
+
## <a name="api-dollar-fetch"></a>Component with $fetch
|
|
984
|
+
|
|
985
|
+
**When to use**: Verify that a component uses $fetch for manual requests.
|
|
986
|
+
|
|
987
|
+
```js
|
|
988
|
+
test('fetches data on button click', async () => {
|
|
989
|
+
registerEndpoint('/api/products/search', {
|
|
990
|
+
method: 'GET',
|
|
991
|
+
handler: () => ([
|
|
992
|
+
{ id: 1, name: 'Product 1' }
|
|
993
|
+
])
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
const wrapper = await mountSuspended(ProductSearch)
|
|
997
|
+
|
|
998
|
+
await wrapper.find('[data-test-id="search-input"]').setValue('Product')
|
|
999
|
+
await wrapper.find('[data-test-id="search-btn"]').trigger('click')
|
|
1000
|
+
|
|
1001
|
+
await flushPromises()
|
|
1002
|
+
|
|
1003
|
+
expect(wrapper.findAll('[data-test-class="product-item"]')).toHaveLength(1)
|
|
1004
|
+
})
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
---
|
|
1008
|
+
|
|
1009
|
+
## <a name="api-error-handling"></a>API error handling
|
|
1010
|
+
|
|
1011
|
+
**When to use**: Verify that an API error is correctly displayed to the user.
|
|
1012
|
+
|
|
1013
|
+
```js
|
|
1014
|
+
test('displays API error message', async () => {
|
|
1015
|
+
mockNuxtImport('useFetch', () => {
|
|
1016
|
+
return () => ({
|
|
1017
|
+
data: ref(null),
|
|
1018
|
+
pending: ref(false),
|
|
1019
|
+
error: ref({
|
|
1020
|
+
statusCode: 500,
|
|
1021
|
+
message: 'Internal Server Error'
|
|
1022
|
+
})
|
|
1023
|
+
})
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
const wrapper = await mountSuspended(ProductList)
|
|
1027
|
+
|
|
1028
|
+
expect(wrapper.find('[data-test-state="error"]').exists()).toBe(true)
|
|
1029
|
+
expect(wrapper.find('[data-test-id="error-message"]').text()).toContain('Internal Server Error')
|
|
1030
|
+
})
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
---
|
|
1034
|
+
|
|
1035
|
+
## <a name="api-retry"></a>Retry on error
|
|
1036
|
+
|
|
1037
|
+
**When to use**: Verify that a button allows retrying after an error.
|
|
1038
|
+
|
|
1039
|
+
```js
|
|
1040
|
+
test('retries fetch on error', async () => {
|
|
1041
|
+
const refreshSpy = vi.fn()
|
|
1042
|
+
|
|
1043
|
+
mockNuxtImport('useFetch', () => {
|
|
1044
|
+
return () => ({
|
|
1045
|
+
data: ref(null),
|
|
1046
|
+
pending: ref(false),
|
|
1047
|
+
error: ref({ message: 'Network error' }),
|
|
1048
|
+
refresh: refreshSpy
|
|
1049
|
+
})
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
const wrapper = await mountSuspended(ProductList)
|
|
1053
|
+
|
|
1054
|
+
expect(wrapper.find('[data-test-id="error-message"]').exists()).toBe(true)
|
|
1055
|
+
|
|
1056
|
+
await wrapper.find('[data-test-id="retry-btn"]').trigger('click')
|
|
1057
|
+
|
|
1058
|
+
expect(refreshSpy).toHaveBeenCalled()
|
|
1059
|
+
})
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
---
|
|
1063
|
+
|
|
1064
|
+
## <a name="api-mock-endpoint"></a>Mock API endpoint
|
|
1065
|
+
|
|
1066
|
+
**When to use**: Mock a complete API response with status code and data.
|
|
1067
|
+
|
|
1068
|
+
```js
|
|
1069
|
+
test('mocks API endpoint response', async () => {
|
|
1070
|
+
registerEndpoint('/api/products/123', {
|
|
1071
|
+
method: 'GET',
|
|
1072
|
+
handler: () => ({
|
|
1073
|
+
id: 123,
|
|
1074
|
+
name: 'Product Name',
|
|
1075
|
+
price: 99.99,
|
|
1076
|
+
stock: 42
|
|
1077
|
+
})
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
const wrapper = await mountSuspended(ProductDetail, {
|
|
1081
|
+
props: { productId: 123 }
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
await flushPromises()
|
|
1085
|
+
|
|
1086
|
+
expect(wrapper.find('[data-test-id="product-name"]').text()).toBe('Product Name')
|
|
1087
|
+
expect(wrapper.find('[data-test-id="product-price"]').text()).toContain('99.99')
|
|
1088
|
+
})
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
---
|
|
1092
|
+
|
|
1093
|
+
# States & Loading
|
|
1094
|
+
|
|
1095
|
+
## <a name="state-loading"></a>Loading state
|
|
1096
|
+
|
|
1097
|
+
**When to use**: Verify that a loading indicator displays during an async operation.
|
|
1098
|
+
|
|
1099
|
+
```js
|
|
1100
|
+
test('displays loading state', async () => {
|
|
1101
|
+
mockNuxtImport('useFetch', () => {
|
|
1102
|
+
return () => ({
|
|
1103
|
+
data: ref(null),
|
|
1104
|
+
pending: ref(true),
|
|
1105
|
+
error: ref(null)
|
|
1106
|
+
})
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
const wrapper = await mountSuspended(ProductList)
|
|
1110
|
+
|
|
1111
|
+
expect(wrapper.find('[data-test-state="loading"]').exists()).toBe(true)
|
|
1112
|
+
expect(wrapper.find('[data-test-id="loading-spinner"]').exists()).toBe(true)
|
|
1113
|
+
})
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
---
|
|
1117
|
+
|
|
1118
|
+
## <a name="state-error"></a>Error state
|
|
1119
|
+
|
|
1120
|
+
**When to use**: Verify that an error message displays when an operation fails.
|
|
1121
|
+
|
|
1122
|
+
```js
|
|
1123
|
+
test('displays error state', async () => {
|
|
1124
|
+
mockNuxtImport('useFetch', () => {
|
|
1125
|
+
return () => ({
|
|
1126
|
+
data: ref(null),
|
|
1127
|
+
pending: ref(false),
|
|
1128
|
+
error: ref({ message: 'Failed to load products' })
|
|
1129
|
+
})
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
const wrapper = await mountSuspended(ProductList)
|
|
1133
|
+
|
|
1134
|
+
expect(wrapper.find('[data-test-state="error"]').exists()).toBe(true)
|
|
1135
|
+
expect(wrapper.find('[data-test-id="error-message"]').text()).toContain('Failed to load')
|
|
1136
|
+
})
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
---
|
|
1140
|
+
|
|
1141
|
+
## <a name="state-empty"></a>Empty state (no data)
|
|
1142
|
+
|
|
1143
|
+
**When to use**: Verify that an appropriate message displays when there's no data.
|
|
1144
|
+
|
|
1145
|
+
```js
|
|
1146
|
+
test('displays empty state', async () => {
|
|
1147
|
+
mockNuxtImport('useFetch', () => {
|
|
1148
|
+
return () => ({
|
|
1149
|
+
data: ref([]),
|
|
1150
|
+
pending: ref(false),
|
|
1151
|
+
error: ref(null)
|
|
1152
|
+
})
|
|
1153
|
+
})
|
|
1154
|
+
|
|
1155
|
+
const wrapper = await mountSuspended(ProductList)
|
|
1156
|
+
|
|
1157
|
+
expect(wrapper.find('[data-test-state="empty"]').exists()).toBe(true)
|
|
1158
|
+
expect(wrapper.find('[data-test-id="empty-message"]').text()).toContain('No products found')
|
|
1159
|
+
})
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
---
|
|
1163
|
+
|
|
1164
|
+
## <a name="state-success"></a>Success state
|
|
1165
|
+
|
|
1166
|
+
**When to use**: Verify that data displays correctly after successful loading.
|
|
1167
|
+
|
|
1168
|
+
```js
|
|
1169
|
+
test('displays success state with data', async () => {
|
|
1170
|
+
mockNuxtImport('useFetch', () => {
|
|
1171
|
+
return () => ({
|
|
1172
|
+
data: ref([{ id: 1, name: 'Product 1' }]),
|
|
1173
|
+
pending: ref(false),
|
|
1174
|
+
error: ref(null)
|
|
1175
|
+
})
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
const wrapper = await mountSuspended(ProductList)
|
|
1179
|
+
|
|
1180
|
+
expect(wrapper.find('[data-test-state="success"]').exists()).toBe(true)
|
|
1181
|
+
expect(wrapper.findAll('[data-test-class="product-item"]')).toHaveLength(1)
|
|
1182
|
+
})
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
---
|
|
1186
|
+
|
|
1187
|
+
## <a name="state-transitions"></a>State transitions
|
|
1188
|
+
|
|
1189
|
+
**When to use**: Verify that the component correctly transitions from one state to another.
|
|
1190
|
+
|
|
1191
|
+
```js
|
|
1192
|
+
test('transitions between loading and success states', async () => {
|
|
1193
|
+
const pending = ref(true)
|
|
1194
|
+
const data = ref(null)
|
|
1195
|
+
|
|
1196
|
+
mockNuxtImport('useFetch', () => {
|
|
1197
|
+
return () => ({
|
|
1198
|
+
data,
|
|
1199
|
+
pending,
|
|
1200
|
+
error: ref(null)
|
|
1201
|
+
})
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
const wrapper = await mountSuspended(ProductList)
|
|
1205
|
+
|
|
1206
|
+
expect(wrapper.find('[data-test-state="loading"]').exists()).toBe(true)
|
|
1207
|
+
|
|
1208
|
+
pending.value = false
|
|
1209
|
+
data.value = [{ id: 1, name: 'Product 1' }]
|
|
1210
|
+
await wrapper.vm.$nextTick()
|
|
1211
|
+
|
|
1212
|
+
expect(wrapper.find('[data-test-state="loading"]').exists()).toBe(false)
|
|
1213
|
+
expect(wrapper.find('[data-test-state="success"]').exists()).toBe(true)
|
|
1214
|
+
})
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
---
|
|
1218
|
+
|
|
1219
|
+
# User Interactions
|
|
1220
|
+
|
|
1221
|
+
## <a name="interaction-button-click"></a>Click button
|
|
1222
|
+
|
|
1223
|
+
**When to use**: Verify that clicking a button triggers an action.
|
|
1224
|
+
|
|
1225
|
+
```js
|
|
1226
|
+
test('triggers action on button click', async () => {
|
|
1227
|
+
const wrapper = await mountSuspended(Counter)
|
|
1228
|
+
|
|
1229
|
+
expect(wrapper.find('[data-test-id="count"]').text()).toBe('0')
|
|
1230
|
+
|
|
1231
|
+
await wrapper.find('[data-test-id="increment-btn"]').trigger('click')
|
|
1232
|
+
|
|
1233
|
+
expect(wrapper.find('[data-test-id="count"]').text()).toBe('1')
|
|
1234
|
+
})
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
---
|
|
1238
|
+
|
|
1239
|
+
## <a name="interaction-fill-input"></a>Fill input field
|
|
1240
|
+
|
|
1241
|
+
**When to use**: Verify that an input field correctly updates its value.
|
|
1242
|
+
|
|
1243
|
+
```js
|
|
1244
|
+
test('updates input value', async () => {
|
|
1245
|
+
const wrapper = await mountSuspended(SearchBar)
|
|
1246
|
+
|
|
1247
|
+
await wrapper.find('[data-test-id="search-input"]').setValue('test query')
|
|
1248
|
+
|
|
1249
|
+
expect(wrapper.find('[data-test-id="search-input"]').element.value).toBe('test query')
|
|
1250
|
+
expect(wrapper.emitted('search')).toBeTruthy()
|
|
1251
|
+
})
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
---
|
|
1255
|
+
|
|
1256
|
+
## <a name="interaction-select-option"></a>Select option
|
|
1257
|
+
|
|
1258
|
+
**When to use**: Verify that selecting from a dropdown updates the value.
|
|
1259
|
+
|
|
1260
|
+
```js
|
|
1261
|
+
test('selects dropdown option', async () => {
|
|
1262
|
+
const wrapper = await mountSuspended(CategoryFilter)
|
|
1263
|
+
|
|
1264
|
+
await wrapper.find('[data-test-id="category-select"]').setValue('electronics')
|
|
1265
|
+
|
|
1266
|
+
expect(wrapper.find('[data-test-id="category-select"]').element.value).toBe('electronics')
|
|
1267
|
+
expect(wrapper.emitted('filter-change')).toBeTruthy()
|
|
1268
|
+
})
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
---
|
|
1272
|
+
|
|
1273
|
+
## <a name="interaction-checkbox"></a>Check checkbox
|
|
1274
|
+
|
|
1275
|
+
**When to use**: Verify that a checkbox changes state when clicked.
|
|
1276
|
+
|
|
1277
|
+
```js
|
|
1278
|
+
test('toggles checkbox', async () => {
|
|
1279
|
+
const wrapper = await mountSuspended(TermsAcceptance)
|
|
1280
|
+
|
|
1281
|
+
expect(wrapper.find('[data-test-id="terms-checkbox"]').element.checked).toBe(false)
|
|
1282
|
+
|
|
1283
|
+
await wrapper.find('[data-test-id="terms-checkbox"]').setChecked(true)
|
|
1284
|
+
|
|
1285
|
+
expect(wrapper.find('[data-test-id="terms-checkbox"]').element.checked).toBe(true)
|
|
1286
|
+
expect(wrapper.find('[data-test-id="submit-btn"]').element.disabled).toBe(false)
|
|
1287
|
+
})
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
---
|
|
1291
|
+
|
|
1292
|
+
## <a name="interaction-file-upload"></a>File upload
|
|
1293
|
+
|
|
1294
|
+
**When to use**: Verify that file upload works correctly.
|
|
1295
|
+
|
|
1296
|
+
```js
|
|
1297
|
+
test('uploads file', async () => {
|
|
1298
|
+
const wrapper = await mountSuspended(FileUpload)
|
|
1299
|
+
|
|
1300
|
+
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
|
1301
|
+
await wrapper.find('[data-test-id="file-input"]').setValue([file])
|
|
1302
|
+
|
|
1303
|
+
expect(wrapper.find('[data-test-id="file-name"]').text()).toBe('test.txt')
|
|
1304
|
+
expect(wrapper.find('[data-test-id="upload-btn"]').element.disabled).toBe(false)
|
|
1305
|
+
})
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
---
|
|
1309
|
+
|
|
1310
|
+
## <a name="interaction-debounce"></a>Debounce on input
|
|
1311
|
+
|
|
1312
|
+
**When to use**: Verify that a debounced search only calls the API after a delay.
|
|
1313
|
+
|
|
1314
|
+
```js
|
|
1315
|
+
test('debounces search input', async () => {
|
|
1316
|
+
vi.useFakeTimers()
|
|
1317
|
+
|
|
1318
|
+
const mockSearch = vi.fn().mockResolvedValue([])
|
|
1319
|
+
|
|
1320
|
+
mockNuxtImport('useSearch', () => {
|
|
1321
|
+
return () => ({
|
|
1322
|
+
search: mockSearch
|
|
1323
|
+
})
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
const wrapper = await mountSuspended(SearchBar)
|
|
1327
|
+
|
|
1328
|
+
await wrapper.find('[data-test-id="search-input"]').setValue('a')
|
|
1329
|
+
vi.advanceTimersByTime(100)
|
|
1330
|
+
|
|
1331
|
+
await wrapper.find('[data-test-id="search-input"]').setValue('ab')
|
|
1332
|
+
vi.advanceTimersByTime(100)
|
|
1333
|
+
|
|
1334
|
+
expect(mockSearch).not.toHaveBeenCalled()
|
|
1335
|
+
|
|
1336
|
+
vi.advanceTimersByTime(200) // Total 300ms
|
|
1337
|
+
|
|
1338
|
+
expect(mockSearch).toHaveBeenCalledTimes(1)
|
|
1339
|
+
expect(mockSearch).toHaveBeenCalledWith('ab')
|
|
1340
|
+
|
|
1341
|
+
vi.useRealTimers()
|
|
1342
|
+
})
|
|
1343
|
+
```
|
|
1344
|
+
|
|
1345
|
+
---
|
|
1346
|
+
|
|
1347
|
+
## <a name="interaction-drag-drop"></a>Drag & drop
|
|
1348
|
+
|
|
1349
|
+
**When to use**: Verify that an element can be moved via drag and drop.
|
|
1350
|
+
|
|
1351
|
+
```js
|
|
1352
|
+
test('reorders items with drag and drop', async () => {
|
|
1353
|
+
const wrapper = await mountSuspended(SortableList, {
|
|
1354
|
+
props: {
|
|
1355
|
+
items: [
|
|
1356
|
+
{ id: 1, name: 'Item 1' },
|
|
1357
|
+
{ id: 2, name: 'Item 2' },
|
|
1358
|
+
{ id: 3, name: 'Item 3' }
|
|
1359
|
+
]
|
|
1360
|
+
}
|
|
1361
|
+
})
|
|
1362
|
+
|
|
1363
|
+
const items = wrapper.findAll('[data-test-class="sortable-item"]')
|
|
1364
|
+
|
|
1365
|
+
// Simulate drag & drop
|
|
1366
|
+
await items[0].trigger('dragstart')
|
|
1367
|
+
await items[2].trigger('drop')
|
|
1368
|
+
|
|
1369
|
+
const reorderedItems = wrapper.findAll('[data-test-class="sortable-item"]')
|
|
1370
|
+
expect(reorderedItems[2].text()).toContain('Item 1')
|
|
1371
|
+
})
|
|
1372
|
+
```
|
|
1373
|
+
|
|
1374
|
+
---
|
|
1375
|
+
|
|
1376
|
+
# i18n & Translations
|
|
1377
|
+
|
|
1378
|
+
## <a name="i18n-check-keys"></a>Check i18n keys
|
|
1379
|
+
|
|
1380
|
+
**When to use**: Verify that all i18n keys are defined and translated.
|
|
1381
|
+
|
|
1382
|
+
```js
|
|
1383
|
+
test('all i18n keys are translated', async () => {
|
|
1384
|
+
mockNuxtImport('useI18n', () => {
|
|
1385
|
+
return () => ({
|
|
1386
|
+
t: (key) => {
|
|
1387
|
+
const translations = {
|
|
1388
|
+
'user.name': 'Name',
|
|
1389
|
+
'user.email': 'Email',
|
|
1390
|
+
'common.save': 'Save'
|
|
1391
|
+
}
|
|
1392
|
+
return translations[key] || key
|
|
1393
|
+
}
|
|
1394
|
+
})
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
const wrapper = await mountSuspended(UserForm)
|
|
1398
|
+
|
|
1399
|
+
expect(wrapper.find('[data-test-id="name-label"]').text()).toBe('Name')
|
|
1400
|
+
expect(wrapper.find('[data-test-id="name-label"]').text()).not.toBe('user.name')
|
|
1401
|
+
})
|
|
1402
|
+
```
|
|
1403
|
+
|
|
1404
|
+
---
|
|
1405
|
+
|
|
1406
|
+
## <a name="i18n-switch-locale"></a>Switch language
|
|
1407
|
+
|
|
1408
|
+
**When to use**: Verify that switching language updates the interface.
|
|
1409
|
+
|
|
1410
|
+
```js
|
|
1411
|
+
test('switches locale', async () => {
|
|
1412
|
+
const locale = ref('en')
|
|
1413
|
+
|
|
1414
|
+
mockNuxtImport('useI18n', () => {
|
|
1415
|
+
return () => ({
|
|
1416
|
+
locale,
|
|
1417
|
+
t: (key) => {
|
|
1418
|
+
const translations = {
|
|
1419
|
+
en: { 'common.welcome': 'Welcome' },
|
|
1420
|
+
fr: { 'common.welcome': 'Bienvenue' }
|
|
1421
|
+
}
|
|
1422
|
+
return translations[locale.value][key] || key
|
|
1423
|
+
}
|
|
1424
|
+
})
|
|
1425
|
+
})
|
|
1426
|
+
|
|
1427
|
+
const wrapper = await mountSuspended(Header)
|
|
1428
|
+
|
|
1429
|
+
expect(wrapper.find('[data-test-id="welcome-text"]').text()).toBe('Welcome')
|
|
1430
|
+
|
|
1431
|
+
locale.value = 'fr'
|
|
1432
|
+
await wrapper.vm.$nextTick()
|
|
1433
|
+
|
|
1434
|
+
expect(wrapper.find('[data-test-id="welcome-text"]').text()).toBe('Bienvenue')
|
|
1435
|
+
})
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
---
|
|
1439
|
+
|
|
1440
|
+
## <a name="i18n-interpolation"></a>Translation with interpolation
|
|
1441
|
+
|
|
1442
|
+
**When to use**: Verify that a translation with dynamic variables works.
|
|
1443
|
+
|
|
1444
|
+
```js
|
|
1445
|
+
test('interpolates translation values', async () => {
|
|
1446
|
+
mockNuxtImport('useI18n', () => {
|
|
1447
|
+
return () => ({
|
|
1448
|
+
t: (key, values) => {
|
|
1449
|
+
if (key === 'user.greeting') {
|
|
1450
|
+
return `Hello ${values.name}!`
|
|
1451
|
+
}
|
|
1452
|
+
return key
|
|
1453
|
+
}
|
|
1454
|
+
})
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
const wrapper = await mountSuspended(UserGreeting, {
|
|
1458
|
+
props: { userName: 'John' }
|
|
1459
|
+
})
|
|
1460
|
+
|
|
1461
|
+
expect(wrapper.find('[data-test-id="greeting"]').text()).toBe('Hello John!')
|
|
1462
|
+
})
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
---
|
|
1466
|
+
|
|
1467
|
+
## <a name="i18n-plural"></a>Plural translation
|
|
1468
|
+
|
|
1469
|
+
**When to use**: Verify that plural forms are correctly handled.
|
|
1470
|
+
|
|
1471
|
+
```js
|
|
1472
|
+
test('handles plural translations', async () => {
|
|
1473
|
+
mockNuxtImport('useI18n', () => {
|
|
1474
|
+
return () => ({
|
|
1475
|
+
t: (key, count) => {
|
|
1476
|
+
if (key === 'item.count') {
|
|
1477
|
+
return count === 1 ? '1 item' : `${count} items`
|
|
1478
|
+
}
|
|
1479
|
+
return key
|
|
1480
|
+
}
|
|
1481
|
+
})
|
|
1482
|
+
})
|
|
1483
|
+
|
|
1484
|
+
const wrapper = await mountSuspended(ItemCounter, {
|
|
1485
|
+
props: { count: 1 }
|
|
1486
|
+
})
|
|
1487
|
+
|
|
1488
|
+
expect(wrapper.find('[data-test-id="item-count"]').text()).toBe('1 item')
|
|
1489
|
+
|
|
1490
|
+
await wrapper.setProps({ count: 5 })
|
|
1491
|
+
|
|
1492
|
+
expect(wrapper.find('[data-test-id="item-count"]').text()).toBe('5 items')
|
|
1493
|
+
})
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
---
|
|
1497
|
+
|
|
1498
|
+
# Utils & Helpers
|
|
1499
|
+
|
|
1500
|
+
## <a name="util-format-date"></a>Date formatter
|
|
1501
|
+
|
|
1502
|
+
**When to use**: Test a function that formats dates.
|
|
1503
|
+
|
|
1504
|
+
```js
|
|
1505
|
+
import { formatDate } from '@/utils/formatDate'
|
|
1506
|
+
|
|
1507
|
+
test('formats date correctly', () => {
|
|
1508
|
+
const date = new Date('2024-03-15')
|
|
1509
|
+
|
|
1510
|
+
expect(formatDate(date, 'DD/MM/YYYY')).toBe('15/03/2024')
|
|
1511
|
+
expect(formatDate(date, 'YYYY-MM-DD')).toBe('2024-03-15')
|
|
1512
|
+
expect(formatDate(date, 'MMM DD, YYYY')).toBe('Mar 15, 2024')
|
|
1513
|
+
})
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
---
|
|
1517
|
+
|
|
1518
|
+
## <a name="util-format-bytes"></a>File size formatter
|
|
1519
|
+
|
|
1520
|
+
**When to use**: Test a function that converts bytes to readable format.
|
|
1521
|
+
|
|
1522
|
+
```js
|
|
1523
|
+
import { formatBytes } from '@/utils/formatBytes'
|
|
1524
|
+
|
|
1525
|
+
test('formats bytes to readable size', () => {
|
|
1526
|
+
expect(formatBytes(0)).toBe('0 Bytes')
|
|
1527
|
+
expect(formatBytes(1024)).toBe('1 KB')
|
|
1528
|
+
expect(formatBytes(1048576)).toBe('1 MB')
|
|
1529
|
+
expect(formatBytes(1073741824)).toBe('1 GB')
|
|
1530
|
+
expect(formatBytes(1500, 2)).toBe('1.46 KB')
|
|
1531
|
+
})
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
---
|
|
1535
|
+
|
|
1536
|
+
## <a name="util-validate-email"></a>Email validation
|
|
1537
|
+
|
|
1538
|
+
**When to use**: Test an email validation function.
|
|
1539
|
+
|
|
1540
|
+
```js
|
|
1541
|
+
import { validateEmail } from '@/utils/validators'
|
|
1542
|
+
|
|
1543
|
+
test('validates email addresses', () => {
|
|
1544
|
+
expect(validateEmail('john@example.com')).toBe(true)
|
|
1545
|
+
expect(validateEmail('user+tag@domain.co.uk')).toBe(true)
|
|
1546
|
+
|
|
1547
|
+
expect(validateEmail('invalid')).toBe(false)
|
|
1548
|
+
expect(validateEmail('missing@domain')).toBe(false)
|
|
1549
|
+
expect(validateEmail('@example.com')).toBe(false)
|
|
1550
|
+
})
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
---
|
|
1554
|
+
|
|
1555
|
+
## <a name="util-validate-ip"></a>IP validation
|
|
1556
|
+
|
|
1557
|
+
**When to use**: Test an IP address validation function.
|
|
1558
|
+
|
|
1559
|
+
```js
|
|
1560
|
+
import { validateIPAddress } from '@/utils/validators'
|
|
1561
|
+
|
|
1562
|
+
test('validates IP addresses', () => {
|
|
1563
|
+
expect(validateIPAddress('192.168.1.1')).toBe(true)
|
|
1564
|
+
expect(validateIPAddress('255.255.255.255')).toBe(true)
|
|
1565
|
+
expect(validateIPAddress('0.0.0.0')).toBe(true)
|
|
1566
|
+
|
|
1567
|
+
expect(validateIPAddress('256.1.1.1')).toBe(false)
|
|
1568
|
+
expect(validateIPAddress('192.168.1')).toBe(false)
|
|
1569
|
+
expect(validateIPAddress('invalid')).toBe(false)
|
|
1570
|
+
})
|
|
1571
|
+
```
|
|
1572
|
+
|
|
1573
|
+
---
|
|
1574
|
+
|
|
1575
|
+
## <a name="util-data-mapping"></a>Data mapping
|
|
1576
|
+
|
|
1577
|
+
**When to use**: Test a function that transforms data from one format to another.
|
|
1578
|
+
|
|
1579
|
+
```js
|
|
1580
|
+
import { mapUserToForm, mapFormToUser } from '@/utils/userMapper'
|
|
1581
|
+
|
|
1582
|
+
test('maps user data to form format', () => {
|
|
1583
|
+
const user = { id: 123, firstName: 'John', lastName: 'Doe', age: 30 }
|
|
1584
|
+
const form = mapUserToForm(user)
|
|
1585
|
+
|
|
1586
|
+
expect(form.fullName).toBe('John Doe')
|
|
1587
|
+
expect(form.age).toBe('30')
|
|
1588
|
+
})
|
|
1589
|
+
|
|
1590
|
+
test('maps form data to user format', () => {
|
|
1591
|
+
const form = { fullName: 'John Doe', age: '30' }
|
|
1592
|
+
const user = mapFormToUser(form)
|
|
1593
|
+
|
|
1594
|
+
expect(user.firstName).toBe('John')
|
|
1595
|
+
expect(user.lastName).toBe('Doe')
|
|
1596
|
+
expect(user.age).toBe(30)
|
|
1597
|
+
})
|
|
1598
|
+
```
|
|
1599
|
+
|
|
1600
|
+
---
|
|
1601
|
+
|
|
1602
|
+
## <a name="util-group-by"></a>Group by key
|
|
1603
|
+
|
|
1604
|
+
**When to use**: Test a function that groups an array of objects by a key.
|
|
1605
|
+
|
|
1606
|
+
```js
|
|
1607
|
+
import { groupBy } from '@/utils/array'
|
|
1608
|
+
|
|
1609
|
+
test('groups array by key', () => {
|
|
1610
|
+
const users = [
|
|
1611
|
+
{ id: 1, role: 'admin', name: 'John' },
|
|
1612
|
+
{ id: 2, role: 'user', name: 'Jane' },
|
|
1613
|
+
{ id: 3, role: 'admin', name: 'Bob' }
|
|
1614
|
+
]
|
|
1615
|
+
|
|
1616
|
+
const grouped = groupBy(users, 'role')
|
|
1617
|
+
|
|
1618
|
+
expect(grouped.admin).toHaveLength(2)
|
|
1619
|
+
expect(grouped.user).toHaveLength(1)
|
|
1620
|
+
expect(grouped.admin[0].name).toBe('John')
|
|
1621
|
+
})
|
|
1622
|
+
```
|
|
1623
|
+
|
|
1624
|
+
---
|
|
1625
|
+
|
|
1626
|
+
## <a name="util-sort-by"></a>Sort array
|
|
1627
|
+
|
|
1628
|
+
**When to use**: Test an array sorting function.
|
|
1629
|
+
|
|
1630
|
+
```js
|
|
1631
|
+
import { sortBy } from '@/utils/array'
|
|
1632
|
+
|
|
1633
|
+
test('sorts array by key', () => {
|
|
1634
|
+
const users = [
|
|
1635
|
+
{ id: 3, name: 'Charlie' },
|
|
1636
|
+
{ id: 1, name: 'Alice' },
|
|
1637
|
+
{ id: 2, name: 'Bob' }
|
|
1638
|
+
]
|
|
1639
|
+
|
|
1640
|
+
const sorted = sortBy(users, 'id', 'asc')
|
|
1641
|
+
expect(sorted[0].id).toBe(1)
|
|
1642
|
+
expect(sorted[2].id).toBe(3)
|
|
1643
|
+
|
|
1644
|
+
const sortedDesc = sortBy(users, 'name', 'desc')
|
|
1645
|
+
expect(sortedDesc[0].name).toBe('Charlie')
|
|
1646
|
+
})
|
|
1647
|
+
```
|
|
1648
|
+
|
|
1649
|
+
---
|
|
1650
|
+
|
|
1651
|
+
## <a name="util-parse-csv"></a>Parse CSV
|
|
1652
|
+
|
|
1653
|
+
**When to use**: Test a function that parses a CSV line.
|
|
1654
|
+
|
|
1655
|
+
```js
|
|
1656
|
+
import { parseCsvRow } from '@/utils/csv'
|
|
1657
|
+
|
|
1658
|
+
test('parses CSV row correctly', () => {
|
|
1659
|
+
expect(parseCsvRow('a,b,c')).toEqual(['a', 'b', 'c'])
|
|
1660
|
+
expect(parseCsvRow('"a,b",c,d')).toEqual(['a,b', 'c', 'd'])
|
|
1661
|
+
expect(parseCsvRow('a, b , c')).toEqual(['a', 'b', 'c'])
|
|
1662
|
+
expect(parseCsvRow('a,"b""c",d')).toEqual(['a', 'b"c', 'd'])
|
|
1663
|
+
})
|
|
1664
|
+
```
|
|
1665
|
+
|
|
1666
|
+
---
|
|
1667
|
+
|
|
1668
|
+
# Stores & State Management
|
|
1669
|
+
|
|
1670
|
+
## <a name="store-add-item"></a>Store: add item
|
|
1671
|
+
|
|
1672
|
+
**When to use**: Test adding an item to a Pinia store.
|
|
1673
|
+
|
|
1674
|
+
```js
|
|
1675
|
+
import { useUserStore } from '@/stores/user'
|
|
1676
|
+
import { setActivePinia, createPinia } from 'pinia'
|
|
1677
|
+
|
|
1678
|
+
beforeEach(() => {
|
|
1679
|
+
setActivePinia(createPinia())
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
test('adds user to store', () => {
|
|
1683
|
+
const store = useUserStore()
|
|
1684
|
+
|
|
1685
|
+
expect(store.users).toHaveLength(0)
|
|
1686
|
+
|
|
1687
|
+
store.addUser({ id: 1, name: 'John Doe' })
|
|
1688
|
+
|
|
1689
|
+
expect(store.users).toHaveLength(1)
|
|
1690
|
+
expect(store.users[0].name).toBe('John Doe')
|
|
1691
|
+
})
|
|
1692
|
+
```
|
|
1693
|
+
|
|
1694
|
+
---
|
|
1695
|
+
|
|
1696
|
+
## <a name="store-remove-item"></a>Store: remove item
|
|
1697
|
+
|
|
1698
|
+
**When to use**: Test removing an item from a store.
|
|
1699
|
+
|
|
1700
|
+
```js
|
|
1701
|
+
test('removes user from store', () => {
|
|
1702
|
+
const store = useUserStore()
|
|
1703
|
+
|
|
1704
|
+
store.addUser({ id: 1, name: 'John' })
|
|
1705
|
+
store.addUser({ id: 2, name: 'Jane' })
|
|
1706
|
+
|
|
1707
|
+
expect(store.users).toHaveLength(2)
|
|
1708
|
+
|
|
1709
|
+
store.removeUser(1)
|
|
1710
|
+
|
|
1711
|
+
expect(store.users).toHaveLength(1)
|
|
1712
|
+
expect(store.users[0].id).toBe(2)
|
|
1713
|
+
})
|
|
1714
|
+
```
|
|
1715
|
+
|
|
1716
|
+
---
|
|
1717
|
+
|
|
1718
|
+
## <a name="store-filter-items"></a>Store: filter items
|
|
1719
|
+
|
|
1720
|
+
**When to use**: Test a computed that filters store items.
|
|
1721
|
+
|
|
1722
|
+
```js
|
|
1723
|
+
test('filters users by role', () => {
|
|
1724
|
+
const store = useUserStore()
|
|
1725
|
+
|
|
1726
|
+
store.users = [
|
|
1727
|
+
{ id: 1, name: 'John', role: 'admin' },
|
|
1728
|
+
{ id: 2, name: 'Jane', role: 'user' },
|
|
1729
|
+
{ id: 3, name: 'Bob', role: 'admin' }
|
|
1730
|
+
]
|
|
1731
|
+
|
|
1732
|
+
store.filterRole = 'admin'
|
|
1733
|
+
|
|
1734
|
+
expect(store.filteredUsers).toHaveLength(2)
|
|
1735
|
+
expect(store.filteredUsers[0].name).toBe('John')
|
|
1736
|
+
})
|
|
1737
|
+
```
|
|
1738
|
+
|
|
1739
|
+
---
|
|
1740
|
+
|
|
1741
|
+
## <a name="store-fetch-data"></a>Store: fetch data
|
|
1742
|
+
|
|
1743
|
+
**When to use**: Test loading data from an API in a store.
|
|
1744
|
+
|
|
1745
|
+
```js
|
|
1746
|
+
test('fetches users from API', async () => {
|
|
1747
|
+
registerEndpoint('/api/users', {
|
|
1748
|
+
method: 'GET',
|
|
1749
|
+
handler: () => ([
|
|
1750
|
+
{ id: 1, name: 'John' },
|
|
1751
|
+
{ id: 2, name: 'Jane' }
|
|
1752
|
+
])
|
|
1753
|
+
})
|
|
1754
|
+
|
|
1755
|
+
const store = useUserStore()
|
|
1756
|
+
|
|
1757
|
+
expect(store.isLoading).toBe(false)
|
|
1758
|
+
|
|
1759
|
+
const promise = store.fetchUsers()
|
|
1760
|
+
expect(store.isLoading).toBe(true)
|
|
1761
|
+
|
|
1762
|
+
await promise
|
|
1763
|
+
|
|
1764
|
+
expect(store.isLoading).toBe(false)
|
|
1765
|
+
expect(store.users).toHaveLength(2)
|
|
1766
|
+
})
|
|
1767
|
+
```
|
|
1768
|
+
|
|
1769
|
+
---
|
|
1770
|
+
|
|
1771
|
+
## <a name="store-reset"></a>Store: reset
|
|
1772
|
+
|
|
1773
|
+
**When to use**: Test resetting a store to its initial state.
|
|
1774
|
+
|
|
1775
|
+
```js
|
|
1776
|
+
test('resets store to initial state', () => {
|
|
1777
|
+
const store = useUserStore()
|
|
1778
|
+
|
|
1779
|
+
store.users = [{ id: 1, name: 'John' }]
|
|
1780
|
+
store.filterRole = 'admin'
|
|
1781
|
+
|
|
1782
|
+
store.$reset()
|
|
1783
|
+
|
|
1784
|
+
expect(store.users).toHaveLength(0)
|
|
1785
|
+
expect(store.filterRole).toBe('')
|
|
1786
|
+
})
|
|
1787
|
+
```
|
|
1788
|
+
|
|
1789
|
+
---
|
|
1790
|
+
|
|
1791
|
+
# Composables
|
|
1792
|
+
|
|
1793
|
+
## <a name="composable-form"></a>Form composable
|
|
1794
|
+
|
|
1795
|
+
**When to use**: Test a composable that manages form logic.
|
|
1796
|
+
|
|
1797
|
+
```js
|
|
1798
|
+
import { useForm } from '@/composables/useForm'
|
|
1799
|
+
|
|
1800
|
+
test('validates form data', () => {
|
|
1801
|
+
const { form, errors, validate, isValid } = useForm({
|
|
1802
|
+
name: '',
|
|
1803
|
+
email: ''
|
|
1804
|
+
})
|
|
1805
|
+
|
|
1806
|
+
validate()
|
|
1807
|
+
expect(isValid.value).toBe(false)
|
|
1808
|
+
expect(errors.name).toBe('Name is required')
|
|
1809
|
+
|
|
1810
|
+
form.name = 'John'
|
|
1811
|
+
validate()
|
|
1812
|
+
expect(errors.name).toBe('')
|
|
1813
|
+
|
|
1814
|
+
form.email = 'john@example.com'
|
|
1815
|
+
validate()
|
|
1816
|
+
expect(isValid.value).toBe(true)
|
|
1817
|
+
})
|
|
1818
|
+
```
|
|
1819
|
+
|
|
1820
|
+
---
|
|
1821
|
+
|
|
1822
|
+
## <a name="composable-api"></a>API composable
|
|
1823
|
+
|
|
1824
|
+
**When to use**: Test a composable that encapsulates API calls.
|
|
1825
|
+
|
|
1826
|
+
```js
|
|
1827
|
+
test('fetches user successfully', async () => {
|
|
1828
|
+
registerEndpoint('/api/users/123', {
|
|
1829
|
+
method: 'GET',
|
|
1830
|
+
handler: () => ({ id: 123, name: 'John Doe' })
|
|
1831
|
+
})
|
|
1832
|
+
|
|
1833
|
+
const { user, isLoading, error, fetchUser } = useUserApi()
|
|
1834
|
+
|
|
1835
|
+
expect(isLoading.value).toBe(false)
|
|
1836
|
+
expect(user.value).toBeNull()
|
|
1837
|
+
|
|
1838
|
+
await fetchUser(123)
|
|
1839
|
+
|
|
1840
|
+
expect(isLoading.value).toBe(false)
|
|
1841
|
+
expect(error.value).toBeNull()
|
|
1842
|
+
expect(user.value.name).toBe('John Doe')
|
|
1843
|
+
})
|
|
1844
|
+
```
|
|
1845
|
+
|
|
1846
|
+
---
|
|
1847
|
+
|
|
1848
|
+
## <a name="composable-permissions"></a>Permissions composable
|
|
1849
|
+
|
|
1850
|
+
**When to use**: Test a composable that manages user permissions.
|
|
1851
|
+
|
|
1852
|
+
```js
|
|
1853
|
+
import { usePermissions } from '@/composables/usePermissions'
|
|
1854
|
+
|
|
1855
|
+
test('checks user permissions', () => {
|
|
1856
|
+
const { setPermissions, can, canAny, canAll } = usePermissions()
|
|
1857
|
+
|
|
1858
|
+
setPermissions(['user.view', 'user.edit'])
|
|
1859
|
+
|
|
1860
|
+
expect(can('user.view')).toBe(true)
|
|
1861
|
+
expect(can('user.delete')).toBe(false)
|
|
1862
|
+
|
|
1863
|
+
expect(canAny(['user.edit', 'user.delete'])).toBe(true)
|
|
1864
|
+
expect(canAll(['user.view', 'user.edit'])).toBe(true)
|
|
1865
|
+
expect(canAll(['user.view', 'user.delete'])).toBe(false)
|
|
1866
|
+
})
|
|
1867
|
+
```
|
|
1868
|
+
|
|
1869
|
+
---
|
|
1870
|
+
|
|
1871
|
+
## <a name="composable-notification"></a>Notification composable
|
|
1872
|
+
|
|
1873
|
+
**When to use**: Test a composable that displays notifications.
|
|
1874
|
+
|
|
1875
|
+
```js
|
|
1876
|
+
import { useNotification } from '@/composables/useNotification'
|
|
1877
|
+
|
|
1878
|
+
test('shows notification', () => {
|
|
1879
|
+
const { showSuccess, showError, notifications } = useNotification()
|
|
1880
|
+
|
|
1881
|
+
expect(notifications.value).toHaveLength(0)
|
|
1882
|
+
|
|
1883
|
+
showSuccess('Operation successful')
|
|
1884
|
+
|
|
1885
|
+
expect(notifications.value).toHaveLength(1)
|
|
1886
|
+
expect(notifications.value[0].type).toBe('success')
|
|
1887
|
+
expect(notifications.value[0].message).toBe('Operation successful')
|
|
1888
|
+
|
|
1889
|
+
showError('Something went wrong')
|
|
1890
|
+
|
|
1891
|
+
expect(notifications.value).toHaveLength(2)
|
|
1892
|
+
expect(notifications.value[1].type).toBe('error')
|
|
1893
|
+
})
|
|
1894
|
+
```
|
|
1895
|
+
|
|
1896
|
+
---
|
|
1897
|
+
|
|
1898
|
+
# Advanced Cases
|
|
1899
|
+
|
|
1900
|
+
## <a name="advanced-race-conditions"></a>Race conditions (concurrent requests)
|
|
1901
|
+
|
|
1902
|
+
**When to use**: Verify that only the last request updates the interface in case of concurrent requests.
|
|
1903
|
+
|
|
1904
|
+
```js
|
|
1905
|
+
test('only last search request updates results', async () => {
|
|
1906
|
+
let resolveFirst
|
|
1907
|
+
let resolveSecond
|
|
1908
|
+
|
|
1909
|
+
const firstPromise = new Promise(resolve => { resolveFirst = resolve })
|
|
1910
|
+
const secondPromise = new Promise(resolve => { resolveSecond = resolve })
|
|
1911
|
+
|
|
1912
|
+
const mockSearch = vi.fn()
|
|
1913
|
+
.mockReturnValueOnce(firstPromise)
|
|
1914
|
+
.mockReturnValueOnce(secondPromise)
|
|
1915
|
+
|
|
1916
|
+
mockNuxtImport('useSearch', () => {
|
|
1917
|
+
return () => ({
|
|
1918
|
+
search: mockSearch
|
|
1919
|
+
})
|
|
1920
|
+
})
|
|
1921
|
+
|
|
1922
|
+
const wrapper = await mountSuspended(SearchComponent)
|
|
1923
|
+
|
|
1924
|
+
await wrapper.find('[data-test-id="search-input"]').setValue('query1')
|
|
1925
|
+
await wrapper.find('[data-test-id="search-input"]').setValue('query2')
|
|
1926
|
+
|
|
1927
|
+
resolveSecond([{ id: 2, name: 'Result 2' }])
|
|
1928
|
+
await flushPromises()
|
|
1929
|
+
|
|
1930
|
+
expect(wrapper.text()).toContain('Result 2')
|
|
1931
|
+
|
|
1932
|
+
resolveFirst([{ id: 1, name: 'Result 1' }])
|
|
1933
|
+
await flushPromises()
|
|
1934
|
+
|
|
1935
|
+
expect(wrapper.text()).toContain('Result 2')
|
|
1936
|
+
expect(wrapper.text()).not.toContain('Result 1')
|
|
1937
|
+
})
|
|
1938
|
+
```
|
|
1939
|
+
|
|
1940
|
+
---
|
|
1941
|
+
|
|
1942
|
+
## <a name="advanced-csv-import"></a>CSV import with mapping
|
|
1943
|
+
|
|
1944
|
+
**When to use**: Test a complete CSV import workflow with column mapping.
|
|
1945
|
+
|
|
1946
|
+
```js
|
|
1947
|
+
test('imports CSV with column mapping', async () => {
|
|
1948
|
+
registerEndpoint('/api/users/import', {
|
|
1949
|
+
method: 'POST',
|
|
1950
|
+
handler: () => ({ imported: 10, failed: 0 })
|
|
1951
|
+
})
|
|
1952
|
+
|
|
1953
|
+
const wrapper = await mountSuspended(CsvImportPage)
|
|
1954
|
+
|
|
1955
|
+
const file = new File(
|
|
1956
|
+
['name,email\nJohn Doe,john@example.com'],
|
|
1957
|
+
'users.csv',
|
|
1958
|
+
{ type: 'text/csv' }
|
|
1959
|
+
)
|
|
1960
|
+
|
|
1961
|
+
await wrapper.find('[data-test-id="csv-file-input"]').setValue([file])
|
|
1962
|
+
await flushPromises()
|
|
1963
|
+
|
|
1964
|
+
await wrapper.find('[data-test-id="csv-column-name"]').setValue('name')
|
|
1965
|
+
await wrapper.find('[data-test-id="csv-column-email"]').setValue('email')
|
|
1966
|
+
await wrapper.find('[data-test-id="mapping-next"]').trigger('click')
|
|
1967
|
+
await flushPromises()
|
|
1968
|
+
|
|
1969
|
+
expect(wrapper.find('[data-test-id="preview-table"]').exists()).toBe(true)
|
|
1970
|
+
|
|
1971
|
+
await wrapper.find('[data-test-id="import-confirm"]').trigger('click')
|
|
1972
|
+
await flushPromises()
|
|
1973
|
+
|
|
1974
|
+
expect(wrapper.find('[data-test-id="import-success"]').text()).toContain('10 users imported')
|
|
1975
|
+
})
|
|
1976
|
+
```
|
|
1977
|
+
|
|
1978
|
+
---
|
|
1979
|
+
|
|
1980
|
+
## <a name="advanced-export-data"></a>Data export
|
|
1981
|
+
|
|
1982
|
+
**When to use**: Test exporting data to a CSV file.
|
|
1983
|
+
|
|
1984
|
+
```js
|
|
1985
|
+
test('exports data to CSV', async () => {
|
|
1986
|
+
registerEndpoint('/api/users/export', {
|
|
1987
|
+
method: 'POST',
|
|
1988
|
+
handler: () => new Blob(['name,email\nJohn,john@example.com'])
|
|
1989
|
+
})
|
|
1990
|
+
|
|
1991
|
+
const wrapper = await mountSuspended(UserTable, {
|
|
1992
|
+
props: {
|
|
1993
|
+
users: [{ id: 1, name: 'John', email: 'john@example.com' }]
|
|
1994
|
+
}
|
|
1995
|
+
})
|
|
1996
|
+
|
|
1997
|
+
await wrapper.find('[data-test-id="export-csv-btn"]').trigger('click')
|
|
1998
|
+
await flushPromises()
|
|
1999
|
+
|
|
2000
|
+
expect(wrapper.find('[data-test-id="export-success"]').exists()).toBe(true)
|
|
2001
|
+
})
|
|
2002
|
+
```
|
|
2003
|
+
|
|
2004
|
+
---
|
|
2005
|
+
|
|
2006
|
+
## <a name="advanced-multi-search"></a>Search with multiple filters
|
|
2007
|
+
|
|
2008
|
+
**When to use**: Test a search with multiple combined filters.
|
|
2009
|
+
|
|
2010
|
+
```js
|
|
2011
|
+
test('searches with multiple filters', async () => {
|
|
2012
|
+
const wrapper = await mountSuspended(ProductSearch, {
|
|
2013
|
+
props: {
|
|
2014
|
+
products: [
|
|
2015
|
+
{ id: 1, name: 'Laptop', category: 'electronics', price: 999 },
|
|
2016
|
+
{ id: 2, name: 'Phone', category: 'electronics', price: 599 },
|
|
2017
|
+
{ id: 3, name: 'Desk', category: 'furniture', price: 299 }
|
|
2018
|
+
]
|
|
2019
|
+
}
|
|
2020
|
+
})
|
|
2021
|
+
|
|
2022
|
+
await wrapper.find('[data-test-id="search-input"]').setValue('Laptop')
|
|
2023
|
+
|
|
2024
|
+
---
|
|
2025
|
+
|
|
2026
|
+
# Vuetify & Components (without data attributes)
|
|
2027
|
+
|
|
2028
|
+
> These patterns allow testing Vue/Vuetify components by relying on **props**, **emits**, **slots** and **findComponent** — without needing to add data attributes to the template.
|
|
2029
|
+
|
|
2030
|
+
---
|
|
2031
|
+
|
|
2032
|
+
## <a name="vuetify-props"></a>Testing component props
|
|
2033
|
+
|
|
2034
|
+
**When to use**: Verify that the props received by a component match the expected values.
|
|
2035
|
+
|
|
2036
|
+
```js
|
|
2037
|
+
test('receives correct props', async () => {
|
|
2038
|
+
const wrapper = await mountSuspended(MyButton, {
|
|
2039
|
+
props: { label: 'Confirm', disabled: false }
|
|
2040
|
+
})
|
|
2041
|
+
|
|
2042
|
+
expect(wrapper.props('label')).toBe('Confirm')
|
|
2043
|
+
expect(wrapper.props('disabled')).toBe(false)
|
|
2044
|
+
})
|
|
2045
|
+
```
|
|
2046
|
+
|
|
2047
|
+
---
|
|
2048
|
+
|
|
2049
|
+
## <a name="vuetify-find-component"></a>Finding a Vuetify component (findComponent)
|
|
2050
|
+
|
|
2051
|
+
**When to use**: Inspect props passed to a child Vuetify component without modifying the parent template.
|
|
2052
|
+
|
|
2053
|
+
```js
|
|
2054
|
+
import { VBtn, VIcon } from 'vuetify/components'
|
|
2055
|
+
|
|
2056
|
+
test('passes correct color to VBtn', async () => {
|
|
2057
|
+
const wrapper = await mountSuspended(SaveButton, {
|
|
2058
|
+
props: { variant: 'primary' }
|
|
2059
|
+
})
|
|
2060
|
+
|
|
2061
|
+
const btn = wrapper.findComponent(VBtn)
|
|
2062
|
+
expect(btn.props('color')).toBe('primary')
|
|
2063
|
+
expect(btn.exists()).toBe(true)
|
|
2064
|
+
})
|
|
2065
|
+
|
|
2066
|
+
test('renders icon inside button', async () => {
|
|
2067
|
+
const wrapper = await mountSuspended(IconButton, {
|
|
2068
|
+
props: { icon: 'mdi-delete' }
|
|
2069
|
+
})
|
|
2070
|
+
|
|
2071
|
+
const icon = wrapper.findComponent(VIcon)
|
|
2072
|
+
expect(icon.text()).toBe('mdi-delete')
|
|
2073
|
+
})
|
|
2074
|
+
```
|
|
2075
|
+
|
|
2076
|
+
---
|
|
2077
|
+
|
|
2078
|
+
## <a name="vuetify-set-props"></a>Updating props (setProps)
|
|
2079
|
+
|
|
2080
|
+
**When to use**: Verify that the component reacts correctly when its props change after mounting.
|
|
2081
|
+
|
|
2082
|
+
```js
|
|
2083
|
+
import { VChip, VAlert } from 'vuetify/components'
|
|
2084
|
+
|
|
2085
|
+
test('updates chip color when status changes', async () => {
|
|
2086
|
+
const wrapper = await mountSuspended(StatusBadge, {
|
|
2087
|
+
props: { status: 'success' }
|
|
2088
|
+
})
|
|
2089
|
+
|
|
2090
|
+
expect(wrapper.findComponent(VChip).props('color')).toBe('success')
|
|
2091
|
+
|
|
2092
|
+
await wrapper.setProps({ status: 'error' })
|
|
2093
|
+
|
|
2094
|
+
expect(wrapper.findComponent(VChip).props('color')).toBe('error')
|
|
2095
|
+
})
|
|
2096
|
+
|
|
2097
|
+
test('shows alert only when error prop is set', async () => {
|
|
2098
|
+
const wrapper = await mountSuspended(MyForm, {
|
|
2099
|
+
props: { error: null }
|
|
2100
|
+
})
|
|
2101
|
+
|
|
2102
|
+
expect(wrapper.findComponent(VAlert).exists()).toBe(false)
|
|
2103
|
+
|
|
2104
|
+
await wrapper.setProps({ error: 'Server error' })
|
|
2105
|
+
|
|
2106
|
+
expect(wrapper.findComponent(VAlert).exists()).toBe(true)
|
|
2107
|
+
expect(wrapper.findComponent(VAlert).props('text')).toBe('Server error')
|
|
2108
|
+
})
|
|
2109
|
+
```
|
|
2110
|
+
|
|
2111
|
+
---
|
|
2112
|
+
|
|
2113
|
+
## <a name="vuetify-emits"></a>Testing emits
|
|
2114
|
+
|
|
2115
|
+
**When to use**: Verify that a component emits the correct event with the correct payload after an interaction.
|
|
2116
|
+
|
|
2117
|
+
```js
|
|
2118
|
+
import { VBtn } from 'vuetify/components'
|
|
2119
|
+
|
|
2120
|
+
test('emits delete event with item id', async () => {
|
|
2121
|
+
const wrapper = await mountSuspended(UserCard, {
|
|
2122
|
+
props: { user: { id: 42, name: 'John' } }
|
|
2123
|
+
})
|
|
2124
|
+
|
|
2125
|
+
await wrapper.findComponent(VBtn).trigger('click')
|
|
2126
|
+
|
|
2127
|
+
expect(wrapper.emitted('delete')).toBeTruthy()
|
|
2128
|
+
expect(wrapper.emitted('delete')[0]).toEqual([42])
|
|
2129
|
+
})
|
|
2130
|
+
|
|
2131
|
+
test('emits confirm and cancel events', async () => {
|
|
2132
|
+
const wrapper = await mountSuspended(ConfirmCard)
|
|
2133
|
+
|
|
2134
|
+
const btns = wrapper.findAllComponents(VBtn)
|
|
2135
|
+
|
|
2136
|
+
await btns[0].trigger('click')
|
|
2137
|
+
expect(wrapper.emitted('confirm')).toBeTruthy()
|
|
2138
|
+
|
|
2139
|
+
await btns[1].trigger('click')
|
|
2140
|
+
expect(wrapper.emitted('cancel')).toBeTruthy()
|
|
2141
|
+
})
|
|
2142
|
+
```
|
|
2143
|
+
|
|
2144
|
+
---
|
|
2145
|
+
|
|
2146
|
+
## <a name="vuetify-slots"></a>Testing slots
|
|
2147
|
+
|
|
2148
|
+
**When to use**: Verify that a component with slots correctly renders the injected content.
|
|
2149
|
+
|
|
2150
|
+
```js
|
|
2151
|
+
test('renders default slot content', async () => {
|
|
2152
|
+
const wrapper = await mountSuspended(MyCard, {
|
|
2153
|
+
slots: {
|
|
2154
|
+
default: '<p>Custom content</p>'
|
|
2155
|
+
}
|
|
2156
|
+
})
|
|
2157
|
+
|
|
2158
|
+
expect(wrapper.text()).toContain('Custom content')
|
|
2159
|
+
})
|
|
2160
|
+
|
|
2161
|
+
test('renders named slots', async () => {
|
|
2162
|
+
const wrapper = await mountSuspended(MyCard, {
|
|
2163
|
+
slots: {
|
|
2164
|
+
title: 'My Title',
|
|
2165
|
+
actions: '<button>Validate</button>'
|
|
2166
|
+
}
|
|
2167
|
+
})
|
|
2168
|
+
|
|
2169
|
+
expect(wrapper.text()).toContain('My Title')
|
|
2170
|
+
expect(wrapper.text()).toContain('Validate')
|
|
2171
|
+
})
|
|
2172
|
+
```
|
|
2173
|
+
|
|
2174
|
+
---
|
|
2175
|
+
|
|
2176
|
+
## <a name="vuetify-disabled-loading"></a>Disabled and loading state in Vuetify
|
|
2177
|
+
|
|
2178
|
+
**When to use**: Verify that loading or disabled states are correctly passed to Vuetify components.
|
|
2179
|
+
|
|
2180
|
+
```js
|
|
2181
|
+
import { VBtn } from 'vuetify/components'
|
|
2182
|
+
|
|
2183
|
+
test('button is loading during submission', async () => {
|
|
2184
|
+
const wrapper = await mountSuspended(SubmitButton, {
|
|
2185
|
+
props: { isLoading: true }
|
|
2186
|
+
})
|
|
2187
|
+
|
|
2188
|
+
const btn = wrapper.findComponent(VBtn)
|
|
2189
|
+
expect(btn.props('loading')).toBe(true)
|
|
2190
|
+
})
|
|
2191
|
+
|
|
2192
|
+
test('button is disabled when form is invalid', async () => {
|
|
2193
|
+
const wrapper = await mountSuspended(MyForm, {
|
|
2194
|
+
props: { isValid: false }
|
|
2195
|
+
})
|
|
2196
|
+
|
|
2197
|
+
const btn = wrapper.findComponent(VBtn)
|
|
2198
|
+
expect(btn.props('disabled')).toBe(true)
|
|
2199
|
+
})
|
|
2200
|
+
|
|
2201
|
+
test('button becomes active when form is valid', async () => {
|
|
2202
|
+
const wrapper = await mountSuspended(MyForm, {
|
|
2203
|
+
props: { isValid: false }
|
|
2204
|
+
})
|
|
2205
|
+
|
|
2206
|
+
expect(wrapper.findComponent(VBtn).props('disabled')).toBe(true)
|
|
2207
|
+
|
|
2208
|
+
await wrapper.setProps({ isValid: true })
|
|
2209
|
+
|
|
2210
|
+
expect(wrapper.findComponent(VBtn).props('disabled')).toBe(false)
|
|
2211
|
+
})
|
|
2212
|
+
```
|
|
2213
|
+
|
|
2214
|
+
---
|
|
2215
|
+
|
|
2216
|
+
## <a name="vuetify-v-model"></a>Testing v-model
|
|
2217
|
+
|
|
2218
|
+
**When to use**: Verify that a component emits `update:modelValue` at the right time and with the correct value.
|
|
2219
|
+
|
|
2220
|
+
```js
|
|
2221
|
+
test('emits update:modelValue on input', async () => {
|
|
2222
|
+
const wrapper = await mountSuspended(MyTextField, {
|
|
2223
|
+
props: { modelValue: '' }
|
|
2224
|
+
})
|
|
2225
|
+
|
|
2226
|
+
const input = wrapper.find('input')
|
|
2227
|
+
await input.setValue('new value')
|
|
2228
|
+
|
|
2229
|
+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
2230
|
+
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['new value'])
|
|
2231
|
+
})
|
|
2232
|
+
|
|
2233
|
+
test('reflects modelValue prop in input', async () => {
|
|
2234
|
+
const wrapper = await mountSuspended(MyTextField, {
|
|
2235
|
+
props: { modelValue: 'initial value' }
|
|
2236
|
+
})
|
|
2237
|
+
|
|
2238
|
+
expect(wrapper.find('input').element.value).toBe('initial value')
|
|
2239
|
+
})
|
|
2240
|
+
```
|
|
2241
|
+
|
|
2242
|
+
---
|
|
2243
|
+
|
|
2244
|
+
## <a name="vuetify-dialog"></a>VDialog and overlays
|
|
2245
|
+
|
|
2246
|
+
**When to use**: Verify the opening/closing of a Vuetify modal or overlay via props.
|
|
2247
|
+
|
|
2248
|
+
```js
|
|
2249
|
+
import { VDialog } from 'vuetify/components'
|
|
2250
|
+
|
|
2251
|
+
test('dialog is closed by default', async () => {
|
|
2252
|
+
const wrapper = await mountSuspended(ConfirmDialog, {
|
|
2253
|
+
props: { modelValue: false }
|
|
2254
|
+
})
|
|
2255
|
+
|
|
2256
|
+
expect(wrapper.findComponent(VDialog).props('modelValue')).toBe(false)
|
|
2257
|
+
})
|
|
2258
|
+
|
|
2259
|
+
test('dialog opens when modelValue is true', async () => {
|
|
2260
|
+
const wrapper = await mountSuspended(ConfirmDialog, {
|
|
2261
|
+
props: { modelValue: false }
|
|
2262
|
+
})
|
|
2263
|
+
|
|
2264
|
+
await wrapper.setProps({ modelValue: true })
|
|
2265
|
+
|
|
2266
|
+
expect(wrapper.findComponent(VDialog).props('modelValue')).toBe(true)
|
|
2267
|
+
})
|
|
2268
|
+
|
|
2269
|
+
test('emits close event when dialog closes', async () => {
|
|
2270
|
+
const wrapper = await mountSuspended(ConfirmDialog, {
|
|
2271
|
+
props: { modelValue: true }
|
|
2272
|
+
})
|
|
2273
|
+
|
|
2274
|
+
wrapper.findComponent(VDialog).vm.$emit('update:modelValue', false)
|
|
2275
|
+
await wrapper.vm.$nextTick()
|
|
2276
|
+
|
|
2277
|
+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
2278
|
+
expect(wrapper.emitted('update:modelValue')[0]).toEqual([false])
|
|
2279
|
+
})
|
|
2280
|
+
```
|
|
2281
|
+
|
|
2282
|
+
---
|
|
2283
|
+
|
|
2284
|
+
## <a name="vuetify-datatable"></a>VDataTable: items and headers
|
|
2285
|
+
|
|
2286
|
+
**When to use**: Verify that data and table configuration are correctly passed to VDataTable.
|
|
2287
|
+
|
|
2288
|
+
```js
|
|
2289
|
+
import { VDataTable } from 'vuetify/components'
|
|
2290
|
+
|
|
2291
|
+
test('passes items to VDataTable', async () => {
|
|
2292
|
+
const items = [
|
|
2293
|
+
{ id: 1, name: 'John', email: 'john@example.com' },
|
|
2294
|
+
{ id: 2, name: 'Jane', email: 'jane@example.com' }
|
|
2295
|
+
]
|
|
2296
|
+
|
|
2297
|
+
const wrapper = await mountSuspended(UserTable, { props: { items } })
|
|
2298
|
+
|
|
2299
|
+
const table = wrapper.findComponent(VDataTable)
|
|
2300
|
+
expect(table.props('items')).toHaveLength(2)
|
|
2301
|
+
expect(table.props('items')[0].name).toBe('John')
|
|
2302
|
+
})
|
|
2303
|
+
|
|
2304
|
+
test('passes correct headers to VDataTable', async () => {
|
|
2305
|
+
const wrapper = await mountSuspended(UserTable)
|
|
2306
|
+
|
|
2307
|
+
const table = wrapper.findComponent(VDataTable)
|
|
2308
|
+
const headers = table.props('headers')
|
|
2309
|
+
|
|
2310
|
+
expect(headers.some(h => h.key === 'name')).toBe(true)
|
|
2311
|
+
expect(headers.some(h => h.key === 'email')).toBe(true)
|
|
2312
|
+
})
|
|
2313
|
+
|
|
2314
|
+
test('passes loading prop to VDataTable', async () => {
|
|
2315
|
+
const wrapper = await mountSuspended(UserTable, {
|
|
2316
|
+
props: { isLoading: true }
|
|
2317
|
+
})
|
|
2318
|
+
|
|
2319
|
+
expect(wrapper.findComponent(VDataTable).props('loading')).toBe(true)
|
|
2320
|
+
})
|
|
2321
|
+
```
|
|
2322
|
+
|
|
2323
|
+
---
|
|
2324
|
+
|
|
2325
|
+
## <a name="vuetify-classes"></a>CSS classes (classes())
|
|
2326
|
+
|
|
2327
|
+
**When to use**: Verify that dynamic CSS classes are correctly applied based on props or state.
|
|
2328
|
+
|
|
2329
|
+
```js
|
|
2330
|
+
test('applies error class when status is error', async () => {
|
|
2331
|
+
const wrapper = await mountSuspended(StatusIndicator, {
|
|
2332
|
+
props: { status: 'error' }
|
|
2333
|
+
})
|
|
2334
|
+
|
|
2335
|
+
expect(wrapper.classes()).toContain('text-error')
|
|
2336
|
+
})
|
|
2337
|
+
|
|
2338
|
+
test('applies active class when item is selected', async () => {
|
|
2339
|
+
const wrapper = await mountSuspended(MenuItem, {
|
|
2340
|
+
props: { isActive: true }
|
|
2341
|
+
})
|
|
2342
|
+
|
|
2343
|
+
expect(wrapper.classes()).toContain('v-list-item--active')
|
|
2344
|
+
})
|
|
2345
|
+
|
|
2346
|
+
test('does not apply disabled class when enabled', async () => {
|
|
2347
|
+
const wrapper = await mountSuspended(MyButton, {
|
|
2348
|
+
props: { disabled: false }
|
|
2349
|
+
})
|
|
2350
|
+
|
|
2351
|
+
expect(wrapper.classes()).not.toContain('v-btn--disabled')
|
|
2352
|
+
})
|
|
2353
|
+
```
|
|
2354
|
+
|
|
2355
|
+
---
|
|
2356
|
+
|
|
2357
|
+
## <a name="vuetify-attributes"></a>HTML attributes (attributes())
|
|
2358
|
+
|
|
2359
|
+
**When to use**: Verify native HTML attributes like `aria-*`, `type`, `href` or `data-*` on the root element.
|
|
2360
|
+
|
|
2361
|
+
```js
|
|
2362
|
+
test('has correct aria-label', async () => {
|
|
2363
|
+
const wrapper = await mountSuspended(CloseButton, {
|
|
2364
|
+
props: { ariaLabel: 'Close dialog' }
|
|
2365
|
+
})
|
|
2366
|
+
|
|
2367
|
+
expect(wrapper.attributes('aria-label')).toBe('Close dialog')
|
|
2368
|
+
})
|
|
2369
|
+
|
|
2370
|
+
test('renders as link with correct href', async () => {
|
|
2371
|
+
const wrapper = await mountSuspended(NavLink, {
|
|
2372
|
+
props: { href: '/dashboard' }
|
|
2373
|
+
})
|
|
2374
|
+
|
|
2375
|
+
expect(wrapper.attributes('href')).toBe('/dashboard')
|
|
2376
|
+
})
|
|
2377
|
+
|
|
2378
|
+
test('button has correct type attribute', async () => {
|
|
2379
|
+
const wrapper = await mountSuspended(MyButton, {
|
|
2380
|
+
props: { type: 'submit' }
|
|
2381
|
+
})
|
|
2382
|
+
|
|
2383
|
+
expect(wrapper.find('button').attributes('type')).toBe('submit')
|
|
2384
|
+
})
|
|
2385
|
+
```
|
|
2386
|
+
|
|
2387
|
+
---
|
|
2388
|
+
|
|
2389
|
+
# Components with Teleport (Portals)
|
|
2390
|
+
|
|
2391
|
+
> **Applicable to all frameworks** — The example below uses Vuetify, but the same problem can occur with any component that uses a teleport mechanism (`<Teleport>` Vue, `createPortal` React, etc.).
|
|
2392
|
+
|
|
2393
|
+
## <a name="teleport-problem"></a>The problem
|
|
2394
|
+
|
|
2395
|
+
Some components do not inject their content into the parent component's DOM, but directly into `document.body`. Result: `wrapper.find(...)` finds nothing, even if the component is visually present.
|
|
2396
|
+
|
|
2397
|
+
```js
|
|
2398
|
+
// Returns exists() = false — content is teleported outside the wrapper
|
|
2399
|
+
wrapper.find('[data-test-id="dialog-content"]')
|
|
2400
|
+
```
|
|
2401
|
+
|
|
2402
|
+
**Warning sign:** if `find()` returns `exists() = false` on a component that displays correctly in dev, it's very likely a teleport problem.
|
|
2403
|
+
|
|
2404
|
+
---
|
|
2405
|
+
|
|
2406
|
+
## <a name="teleport-components"></a>Affected components (non-exhaustive list)
|
|
2407
|
+
|
|
2408
|
+
| Framework | Affected components |
|
|
2409
|
+
|-----------|---------------------|
|
|
2410
|
+
| **Vuetify** | `VDialog`, `VOverlay`, `VMenu`, `VTooltip`, `VSnackbar`, `VBottomSheet`, `VDatePicker` |
|
|
2411
|
+
| **Native Vue** | Any component using `<Teleport to="body">` |
|
|
2412
|
+
| **React** | Any component using `ReactDOM.createPortal(...)` |
|
|
2413
|
+
| **Others** | Check component docs if `find()` fails inexplicably |
|
|
2414
|
+
|
|
2415
|
+
---
|
|
2416
|
+
|
|
2417
|
+
## <a name="teleport-method"></a>The method to apply
|
|
2418
|
+
|
|
2419
|
+
**1. Mount with `attachTo: document.body`**
|
|
2420
|
+
|
|
2421
|
+
```js
|
|
2422
|
+
const wrapper = await mountSuspended(MyComponent, {
|
|
2423
|
+
attachTo: document.body // required for teleports to work
|
|
2424
|
+
})
|
|
2425
|
+
```
|
|
2426
|
+
|
|
2427
|
+
**2. Wait for overlay render (triple await)**
|
|
2428
|
+
|
|
2429
|
+
```js
|
|
2430
|
+
await wrapper.find('[data-test-id="open-dialog-btn"]').trigger('click')
|
|
2431
|
+
await nextTick() // Vue processes reactivity
|
|
2432
|
+
await flushPromises() // promises resolve
|
|
2433
|
+
await nextTick() // second tick for the teleport itself
|
|
2434
|
+
```
|
|
2435
|
+
|
|
2436
|
+
**3. Search in `document.body` rather than `wrapper`**
|
|
2437
|
+
|
|
2438
|
+
```js
|
|
2439
|
+
// Does not find — content teleported outside wrapper
|
|
2440
|
+
wrapper.find('[data-test-id="dialog-title"]')
|
|
2441
|
+
|
|
2442
|
+
// Finds — searching in document.body
|
|
2443
|
+
document.body.querySelector('[data-test-id="dialog-title"]')
|
|
2444
|
+
|
|
2445
|
+
// Or via findComponent + props if no data-test-id
|
|
2446
|
+
import { VDialog } from 'vuetify/components'
|
|
2447
|
+
const dialog = wrapper.findComponent(VDialog)
|
|
2448
|
+
expect(dialog.props('modelValue')).toBe(true)
|
|
2449
|
+
```
|
|
2450
|
+
|
|
2451
|
+
**4. Clean up after each test**
|
|
2452
|
+
|
|
2453
|
+
```js
|
|
2454
|
+
afterEach(() => {
|
|
2455
|
+
wrapper.unmount()
|
|
2456
|
+
document.body.innerHTML = '' // prevents leaks between tests
|
|
2457
|
+
})
|
|
2458
|
+
```
|
|
2459
|
+
|
|
2460
|
+
---
|
|
2461
|
+
|
|
2462
|
+
### Mnemonic rule
|
|
2463
|
+
|
|
2464
|
+
```
|
|
2465
|
+
find() returns exists() = false on a Vuetify (or other) component?
|
|
2466
|
+
→ 1. Add attachTo: document.body to mount
|
|
2467
|
+
→ 2. Triple await: nextTick → flushPromises → nextTick
|
|
2468
|
+
→ 3. Replace wrapper.find() with document.body.querySelector()
|
|
2469
|
+
→ 4. Clean up with afterEach: unmount() + document.body.innerHTML = ''
|
|
2470
|
+
```
|
|
2471
|
+
|
|
2472
|
+
---
|
|
2473
|
+
|
|
2474
|
+
## <a name="teleport-example"></a>Complete example — VDialog with form (Vuetify + Nuxt)
|
|
2475
|
+
|
|
2476
|
+
```js
|
|
2477
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
2478
|
+
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
|
2479
|
+
import { flushPromises } from '@vue/test-utils'
|
|
2480
|
+
import { nextTick } from 'vue'
|
|
2481
|
+
import { VDialog } from 'vuetify/components'
|
|
2482
|
+
import UserCreateModal from '@/components/UserCreateModal.vue'
|
|
2483
|
+
|
|
2484
|
+
describe('UserCreateModal', () => {
|
|
2485
|
+
let wrapper
|
|
2486
|
+
|
|
2487
|
+
afterEach(() => {
|
|
2488
|
+
wrapper?.unmount()
|
|
2489
|
+
document.body.innerHTML = ''
|
|
2490
|
+
})
|
|
2491
|
+
|
|
2492
|
+
it('opens and displays the form', async () => {
|
|
2493
|
+
wrapper = await mountSuspended(UserCreateModal, {
|
|
2494
|
+
attachTo: document.body
|
|
2495
|
+
})
|
|
2496
|
+
|
|
2497
|
+
// Before opening: dialog closed
|
|
2498
|
+
expect(wrapper.findComponent(VDialog).props('modelValue')).toBe(false)
|
|
2499
|
+
|
|
2500
|
+
// Trigger opening
|
|
2501
|
+
await wrapper.find('[data-test-id="open-dialog-btn"]').trigger('click')
|
|
2502
|
+
await nextTick()
|
|
2503
|
+
await flushPromises()
|
|
2504
|
+
await nextTick()
|
|
2505
|
+
|
|
2506
|
+
// Verify via VDialog props
|
|
2507
|
+
expect(wrapper.findComponent(VDialog).props('modelValue')).toBe(true)
|
|
2508
|
+
|
|
2509
|
+
// Verify content via document.body
|
|
2510
|
+
const title = document.body.querySelector('[data-test-id="dialog-title"]')
|
|
2511
|
+
expect(title?.textContent).toContain('Create a user')
|
|
2512
|
+
})
|
|
2513
|
+
|
|
2514
|
+
it('submits the form and closes the modal', async () => {
|
|
2515
|
+
wrapper = await mountSuspended(UserCreateModal, {
|
|
2516
|
+
attachTo: document.body,
|
|
2517
|
+
props: { modelValue: true } // open directly without click
|
|
2518
|
+
})
|
|
2519
|
+
|
|
2520
|
+
await nextTick()
|
|
2521
|
+
await flushPromises()
|
|
2522
|
+
await nextTick()
|
|
2523
|
+
|
|
2524
|
+
// Interact via document.body
|
|
2525
|
+
const input = document.body.querySelector('[data-test-id="modal-form-name"]')
|
|
2526
|
+
input.value = 'John Doe'
|
|
2527
|
+
input.dispatchEvent(new Event('input'))
|
|
2528
|
+
await nextTick()
|
|
2529
|
+
|
|
2530
|
+
const submitBtn = document.body.querySelector('[data-test-id="modal-form-submit"]')
|
|
2531
|
+
submitBtn.click()
|
|
2532
|
+
await flushPromises()
|
|
2533
|
+
|
|
2534
|
+
// The modal must emit its closing
|
|
2535
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
|
|
2536
|
+
})
|
|
2537
|
+
})
|
|
2538
|
+
```
|
|
2539
|
+
|
|
2540
|
+
---
|
|
2541
|
+
|
|
2542
|
+
## Roadmap
|
|
2543
|
+
|
|
2544
|
+
### Coming soon
|
|
2545
|
+
|
|
2546
|
+
- **Next.js Guide**: Adaptation of all cases for Next.js + Vitest
|
|
2547
|
+
- **Debug Guide**: Debugging and troubleshooting techniques
|
|
2548
|
+
- **E2E Guide**: End-to-end tests with Playwright/Cypress
|
|
2549
|
+
|
|
2550
|
+
### Contribute!
|
|
2551
|
+
|
|
2552
|
+
This guide evolves thanks to the community. Feel free to:
|
|
2553
|
+
- Propose new use cases
|
|
2554
|
+
- Improve existing snippets
|
|
2555
|
+
- Report bugs or inaccuracies
|
|
2556
|
+
- Share your experience
|
|
2557
|
+
|
|
2558
|
+
**Open an Issue or a Pull Request on the repository!**
|
|
2559
|
+
|
|
2560
|
+
---
|
|
2561
|
+
|
|
2562
|
+
## License
|
|
2563
|
+
|
|
2564
|
+
This guide is open-source and free to use. Share it, improve it, adapt it to your needs!
|
|
2565
|
+
|
|
2566
|
+
---
|
|
2567
|
+
|
|
2568
|
+
**Last updated**: February 2026
|