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.
@@ -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