qa-workflow-cc 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.
Files changed (39) hide show
  1. package/README.md +461 -0
  2. package/VERSION +1 -0
  3. package/bin/install.js +116 -0
  4. package/commands/qa/continue.md +77 -0
  5. package/commands/qa/full.md +149 -0
  6. package/commands/qa/init.md +105 -0
  7. package/commands/qa/resume.md +91 -0
  8. package/commands/qa/status.md +66 -0
  9. package/package.json +28 -0
  10. package/skills/qa/SKILL.md +420 -0
  11. package/skills/qa/references/continuation-format.md +58 -0
  12. package/skills/qa/references/exit-criteria.md +53 -0
  13. package/skills/qa/references/lifecycle.md +181 -0
  14. package/skills/qa/references/model-profiles.md +77 -0
  15. package/skills/qa/templates/agent-skeleton.md +733 -0
  16. package/skills/qa/templates/component-test.md +1088 -0
  17. package/skills/qa/templates/domain-research-queries.md +101 -0
  18. package/skills/qa/templates/domain-security-profiles.md +182 -0
  19. package/skills/qa/templates/e2e-test.md +1200 -0
  20. package/skills/qa/templates/nielsen-heuristics.md +274 -0
  21. package/skills/qa/templates/performance-benchmarks-base.md +321 -0
  22. package/skills/qa/templates/qa-report-template.md +271 -0
  23. package/skills/qa/templates/security-checklist-owasp.md +451 -0
  24. package/skills/qa/templates/stop-points/bootstrap-complete.md +36 -0
  25. package/skills/qa/templates/stop-points/certified.md +25 -0
  26. package/skills/qa/templates/stop-points/escalated.md +32 -0
  27. package/skills/qa/templates/stop-points/fix-ready.md +43 -0
  28. package/skills/qa/templates/stop-points/phase-transition.md +4 -0
  29. package/skills/qa/templates/stop-points/status-dashboard.md +32 -0
  30. package/skills/qa/templates/test-standards.md +652 -0
  31. package/skills/qa/templates/unit-test.md +998 -0
  32. package/skills/qa/templates/visual-regression.md +418 -0
  33. package/skills/qa/workflows/bootstrap.md +45 -0
  34. package/skills/qa/workflows/decision-gate.md +66 -0
  35. package/skills/qa/workflows/fix-execute.md +132 -0
  36. package/skills/qa/workflows/fix-plan.md +52 -0
  37. package/skills/qa/workflows/report-phase.md +64 -0
  38. package/skills/qa/workflows/test-phase.md +86 -0
  39. package/skills/qa/workflows/verify-phase.md +65 -0
@@ -0,0 +1,1088 @@
1
+ ---
2
+ name: component-test
3
+ description: Create tests for React components using Testing Library. Use when testing UI components, user interactions, and rendering logic.
4
+ context: fork
5
+ agent: test-qa
6
+ allowed-tools:
7
+ - Read
8
+ - Glob
9
+ - Grep
10
+ - Write
11
+ - Edit
12
+ - Bash
13
+ ---
14
+
15
+ # Component Testing (Testing Library)
16
+
17
+ This skill guides creation of tests for React components using {{componentTestLib}}.
18
+
19
+ ## Template Variables
20
+
21
+ | Variable | Description | Default |
22
+ |----------|-------------|---------|
23
+ | `{{componentTestLib}}` | Testing library package | @testing-library/react |
24
+ | `{{testRunner}}` | Test runner name (vitest, jest) | vitest |
25
+ | `{{testCommand}}` | Command to run tests | pnpm test |
26
+ | `{{framework}}` | Framework (react, react-native, next) | react |
27
+ | `{{srcAlias}}` | Import alias for source files | @/ |
28
+
29
+ ---
30
+
31
+ ## Prerequisites
32
+
33
+ - Understand the component being tested
34
+ - Know the expected rendering behavior
35
+ - Identify user interaction patterns
36
+ - Check for required context providers
37
+
38
+ ---
39
+
40
+ ## Web Component Test Structure
41
+
42
+ ```typescript
43
+ // components/__tests__/ItemCard.test.tsx
44
+ import { render, screen, fireEvent, waitFor } from '{{componentTestLib}}'
45
+ import userEvent from '@testing-library/user-event'
46
+ import { describe, it, expect, vi } from '{{testRunner}}'
47
+ import { ItemCard } from '../ItemCard'
48
+
49
+ describe('ItemCard', () => {
50
+ const defaultProps = {
51
+ item: {
52
+ id: '1',
53
+ name: 'Sample Item',
54
+ email: 'sample@example.com',
55
+ status: 'ACTIVE' as const,
56
+ createdAt: new Date('2024-01-15'),
57
+ },
58
+ onEdit: vi.fn(),
59
+ onDelete: vi.fn(),
60
+ }
61
+
62
+ it('renders item information', () => {
63
+ render(<ItemCard {...defaultProps} />)
64
+
65
+ expect(screen.getByText('Sample Item')).toBeInTheDocument()
66
+ expect(screen.getByText('sample@example.com')).toBeInTheDocument()
67
+ expect(screen.getByText('ACTIVE')).toBeInTheDocument()
68
+ })
69
+
70
+ it('calls onEdit when edit button clicked', async () => {
71
+ const user = userEvent.setup()
72
+ render(<ItemCard {...defaultProps} />)
73
+
74
+ await user.click(screen.getByRole('button', { name: /edit/i }))
75
+
76
+ expect(defaultProps.onEdit).toHaveBeenCalledWith(defaultProps.item)
77
+ })
78
+
79
+ it('shows confirmation before delete', async () => {
80
+ const user = userEvent.setup()
81
+ render(<ItemCard {...defaultProps} />)
82
+
83
+ await user.click(screen.getByRole('button', { name: /delete/i }))
84
+
85
+ expect(screen.getByText(/are you sure/i)).toBeInTheDocument()
86
+ })
87
+
88
+ it('calls onDelete after confirmation', async () => {
89
+ const user = userEvent.setup()
90
+ render(<ItemCard {...defaultProps} />)
91
+
92
+ await user.click(screen.getByRole('button', { name: /delete/i }))
93
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
94
+
95
+ expect(defaultProps.onDelete).toHaveBeenCalledWith(defaultProps.item.id)
96
+ })
97
+ })
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Query Priority (Best Practices)
103
+
104
+ Use queries in this order of preference:
105
+
106
+ | Priority | Query | Use When |
107
+ |----------|-------|----------|
108
+ | 1 | `getByRole` | Element has semantic role (button, heading, textbox, etc.) |
109
+ | 2 | `getByLabelText` | Form inputs with labels |
110
+ | 3 | `getByPlaceholderText` | Inputs with placeholder text |
111
+ | 4 | `getByText` | Non-interactive text content |
112
+ | 5 | `getByDisplayValue` | Input with current value |
113
+ | 6 | `getByAltText` | Images with alt text |
114
+ | 7 | `getByTitle` | Elements with title attribute |
115
+ | 8 | `getByTestId` | Last resort, for complex elements |
116
+
117
+ ```typescript
118
+ // GOOD - Uses semantic queries
119
+ screen.getByRole('button', { name: /submit/i })
120
+ screen.getByRole('textbox', { name: /email/i })
121
+ screen.getByRole('heading', { level: 1, name: /dashboard/i })
122
+ screen.getByLabelText(/password/i)
123
+
124
+ // AVOID - Too implementation-specific
125
+ screen.getByTestId('submit-button')
126
+ document.querySelector('.btn-primary')
127
+ ```
128
+
129
+ ### Query Variants
130
+
131
+ | Variant | Returns | Throws if not found? | Use for |
132
+ |---------|---------|---------------------|---------|
133
+ | `getBy*` | Element | Yes | Elements that should exist |
134
+ | `queryBy*` | Element or null | No | Asserting absence |
135
+ | `findBy*` | Promise<Element> | Yes (timeout) | Async elements |
136
+ | `getAllBy*` | Element[] | Yes | Multiple elements |
137
+ | `queryAllBy*` | Element[] (empty ok) | No | Optional multiple elements |
138
+ | `findAllBy*` | Promise<Element[]> | Yes (timeout) | Async multiple elements |
139
+
140
+ ```typescript
141
+ // Assert element exists
142
+ expect(screen.getByText('Hello')).toBeInTheDocument()
143
+
144
+ // Assert element does NOT exist
145
+ expect(screen.queryByText('Goodbye')).not.toBeInTheDocument()
146
+
147
+ // Wait for async element
148
+ const element = await screen.findByText('Loaded data')
149
+ expect(element).toBeInTheDocument()
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Testing User Interactions
155
+
156
+ Always prefer `userEvent` over `fireEvent` for realistic interaction simulation:
157
+
158
+ ```typescript
159
+ import userEvent from '@testing-library/user-event'
160
+
161
+ describe('RegistrationForm', () => {
162
+ it('submits form with valid data', async () => {
163
+ const user = userEvent.setup()
164
+ const onSubmit = vi.fn()
165
+ render(<RegistrationForm onSubmit={onSubmit} />)
166
+
167
+ await user.type(screen.getByLabelText(/name/i), 'Jane Smith')
168
+ await user.type(screen.getByLabelText(/email/i), 'jane@example.com')
169
+ await user.type(screen.getByLabelText(/message/i), 'Hello!')
170
+ await user.click(screen.getByRole('button', { name: /submit/i }))
171
+
172
+ expect(onSubmit).toHaveBeenCalledWith({
173
+ name: 'Jane Smith',
174
+ email: 'jane@example.com',
175
+ message: 'Hello!',
176
+ })
177
+ })
178
+
179
+ it('shows validation errors for empty required fields', async () => {
180
+ const user = userEvent.setup()
181
+ render(<RegistrationForm onSubmit={vi.fn()} />)
182
+
183
+ // Submit empty form
184
+ await user.click(screen.getByRole('button', { name: /submit/i }))
185
+
186
+ expect(screen.getByText(/name is required/i)).toBeInTheDocument()
187
+ expect(screen.getByText(/email is required/i)).toBeInTheDocument()
188
+ })
189
+
190
+ it('disables submit button while loading', () => {
191
+ render(<RegistrationForm onSubmit={vi.fn()} isLoading={true} />)
192
+
193
+ expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled()
194
+ })
195
+
196
+ it('clears error when user starts typing', async () => {
197
+ const user = userEvent.setup()
198
+ render(<RegistrationForm onSubmit={vi.fn()} />)
199
+
200
+ // Trigger error
201
+ await user.click(screen.getByRole('button', { name: /submit/i }))
202
+ expect(screen.getByText(/name is required/i)).toBeInTheDocument()
203
+
204
+ // Start typing to clear error
205
+ await user.type(screen.getByLabelText(/name/i), 'J')
206
+ expect(screen.queryByText(/name is required/i)).not.toBeInTheDocument()
207
+ })
208
+ })
209
+ ```
210
+
211
+ ### Common User Interactions
212
+
213
+ ```typescript
214
+ const user = userEvent.setup()
215
+
216
+ // Typing
217
+ await user.type(input, 'text')
218
+ await user.clear(input)
219
+
220
+ // Clicking
221
+ await user.click(button)
222
+ await user.dblClick(element)
223
+ await user.tripleClick(element)
224
+
225
+ // Keyboard
226
+ await user.keyboard('{Enter}')
227
+ await user.keyboard('{Escape}')
228
+ await user.keyboard('{Tab}')
229
+ await user.keyboard('{Shift>}{Tab}{/Shift}') // Shift+Tab
230
+
231
+ // Selection
232
+ await user.selectOptions(select, 'option-value')
233
+ await user.deselectOptions(multiSelect, 'option-value')
234
+
235
+ // Clipboard
236
+ await user.copy()
237
+ await user.paste()
238
+
239
+ // Hovering
240
+ await user.hover(element)
241
+ await user.unhover(element)
242
+
243
+ // File upload
244
+ const file = new File(['content'], 'file.png', { type: 'image/png' })
245
+ await user.upload(fileInput, file)
246
+
247
+ // Pointer (advanced)
248
+ await user.pointer({ keys: '[MouseLeft]', target: element })
249
+ ```
250
+
251
+ ---
252
+
253
+ ## Testing Async Behavior
254
+
255
+ ```typescript
256
+ import { render, screen, waitFor, waitForElementToBeRemoved } from '{{componentTestLib}}'
257
+
258
+ describe('DataList', () => {
259
+ it('shows loading state then data', async () => {
260
+ render(<DataList />)
261
+
262
+ // Loading state
263
+ expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument()
264
+
265
+ // Wait for data
266
+ await waitFor(() => {
267
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
268
+ })
269
+
270
+ // Loading gone
271
+ expect(screen.queryByTestId('loading-skeleton')).not.toBeInTheDocument()
272
+ })
273
+
274
+ it('shows error state on fetch failure', async () => {
275
+ // Mock failed fetch
276
+ server.use(
277
+ rest.get('/api/items', (req, res, ctx) => {
278
+ return res(ctx.status(500))
279
+ })
280
+ )
281
+
282
+ render(<DataList />)
283
+
284
+ await waitFor(() => {
285
+ expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
286
+ })
287
+ })
288
+
289
+ it('shows empty state when no data', async () => {
290
+ server.use(
291
+ rest.get('/api/items', (req, res, ctx) => {
292
+ return res(ctx.json([]))
293
+ })
294
+ )
295
+
296
+ render(<DataList />)
297
+
298
+ await waitFor(() => {
299
+ expect(screen.getByText(/no items yet/i)).toBeInTheDocument()
300
+ })
301
+ })
302
+
303
+ it('can retry after error', async () => {
304
+ let callCount = 0
305
+ server.use(
306
+ rest.get('/api/items', (req, res, ctx) => {
307
+ callCount++
308
+ if (callCount === 1) return res(ctx.status(500))
309
+ return res(ctx.json([{ id: '1', name: 'Item 1' }]))
310
+ })
311
+ )
312
+
313
+ const user = userEvent.setup()
314
+ render(<DataList />)
315
+
316
+ await waitFor(() => {
317
+ expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
318
+ })
319
+
320
+ await user.click(screen.getByRole('button', { name: /retry/i }))
321
+
322
+ await waitFor(() => {
323
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
324
+ })
325
+ })
326
+ })
327
+ ```
328
+
329
+ ### waitFor Best Practices
330
+
331
+ ```typescript
332
+ // GOOD - Single assertion inside waitFor
333
+ await waitFor(() => {
334
+ expect(screen.getByText('Data loaded')).toBeInTheDocument()
335
+ })
336
+
337
+ // BAD - Multiple assertions inside waitFor (only last one matters for retry)
338
+ await waitFor(() => {
339
+ expect(screen.getByText('Header')).toBeInTheDocument()
340
+ expect(screen.getByText('Data loaded')).toBeInTheDocument()
341
+ })
342
+
343
+ // GOOD - Use findBy for single async element (shorthand for waitFor + getBy)
344
+ const element = await screen.findByText('Data loaded')
345
+ expect(element).toBeInTheDocument()
346
+
347
+ // GOOD - waitForElementToBeRemoved for disappearing elements
348
+ await waitForElementToBeRemoved(() => screen.queryByTestId('spinner'))
349
+ ```
350
+
351
+ ---
352
+
353
+ ## Testing with Providers
354
+
355
+ Create a custom render function that wraps components in required providers:
356
+
357
+ ```typescript
358
+ // test-utils.tsx
359
+ import { render as rtlRender, RenderOptions } from '{{componentTestLib}}'
360
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
361
+ import { ThemeProvider } from '{{srcAlias}}lib/providers/ThemeProvider'
362
+
363
+ const createTestQueryClient = () =>
364
+ new QueryClient({
365
+ defaultOptions: {
366
+ queries: { retry: false },
367
+ mutations: { retry: false },
368
+ },
369
+ })
370
+
371
+ interface CustomRenderOptions extends RenderOptions {
372
+ queryClient?: QueryClient
373
+ theme?: 'light' | 'dark'
374
+ }
375
+
376
+ function AllProviders({
377
+ children,
378
+ queryClient,
379
+ theme = 'light',
380
+ }: {
381
+ children: React.ReactNode
382
+ queryClient: QueryClient
383
+ theme?: string
384
+ }) {
385
+ return (
386
+ <QueryClientProvider client={queryClient}>
387
+ <ThemeProvider defaultTheme={theme}>
388
+ {children}
389
+ </ThemeProvider>
390
+ </QueryClientProvider>
391
+ )
392
+ }
393
+
394
+ export function render(
395
+ ui: React.ReactElement,
396
+ options?: CustomRenderOptions
397
+ ) {
398
+ const queryClient = options?.queryClient ?? createTestQueryClient()
399
+ const theme = options?.theme ?? 'light'
400
+
401
+ return rtlRender(ui, {
402
+ wrapper: ({ children }) => (
403
+ <AllProviders queryClient={queryClient} theme={theme}>
404
+ {children}
405
+ </AllProviders>
406
+ ),
407
+ ...options,
408
+ })
409
+ }
410
+
411
+ // Re-export everything from testing-library
412
+ export * from '{{componentTestLib}}'
413
+ export { default as userEvent } from '@testing-library/user-event'
414
+
415
+ // Usage in tests:
416
+ import { render, screen, userEvent } from './test-utils'
417
+
418
+ it('renders with providers', () => {
419
+ render(<Dashboard />)
420
+ // Component has access to query client, theme, etc.
421
+ })
422
+ ```
423
+
424
+ ---
425
+
426
+ ## Testing Modals/Dialogs
427
+
428
+ ```typescript
429
+ describe('ConfirmDialog', () => {
430
+ it('renders when open', () => {
431
+ render(
432
+ <ConfirmDialog
433
+ isOpen={true}
434
+ title="Delete Item"
435
+ message="Are you sure?"
436
+ onConfirm={vi.fn()}
437
+ onCancel={vi.fn()}
438
+ />
439
+ )
440
+
441
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
442
+ expect(screen.getByText('Delete Item')).toBeInTheDocument()
443
+ expect(screen.getByText('Are you sure?')).toBeInTheDocument()
444
+ })
445
+
446
+ it('does not render when closed', () => {
447
+ render(
448
+ <ConfirmDialog
449
+ isOpen={false}
450
+ title="Delete Item"
451
+ message="Are you sure?"
452
+ onConfirm={vi.fn()}
453
+ onCancel={vi.fn()}
454
+ />
455
+ )
456
+
457
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
458
+ })
459
+
460
+ it('calls onConfirm when confirm button clicked', async () => {
461
+ const user = userEvent.setup()
462
+ const onConfirm = vi.fn()
463
+ render(
464
+ <ConfirmDialog
465
+ isOpen={true}
466
+ title="Delete"
467
+ message="Sure?"
468
+ onConfirm={onConfirm}
469
+ onCancel={vi.fn()}
470
+ />
471
+ )
472
+
473
+ await user.click(screen.getByRole('button', { name: /confirm/i }))
474
+
475
+ expect(onConfirm).toHaveBeenCalledOnce()
476
+ })
477
+
478
+ it('calls onCancel when cancel button clicked', async () => {
479
+ const user = userEvent.setup()
480
+ const onCancel = vi.fn()
481
+ render(
482
+ <ConfirmDialog
483
+ isOpen={true}
484
+ title="Delete"
485
+ message="Sure?"
486
+ onConfirm={vi.fn()}
487
+ onCancel={onCancel}
488
+ />
489
+ )
490
+
491
+ await user.click(screen.getByRole('button', { name: /cancel/i }))
492
+
493
+ expect(onCancel).toHaveBeenCalledOnce()
494
+ })
495
+
496
+ it('traps focus within modal', async () => {
497
+ const user = userEvent.setup()
498
+ render(
499
+ <ConfirmDialog
500
+ isOpen={true}
501
+ title="Delete"
502
+ message="Sure?"
503
+ onConfirm={vi.fn()}
504
+ onCancel={vi.fn()}
505
+ />
506
+ )
507
+
508
+ const cancelBtn = screen.getByRole('button', { name: /cancel/i })
509
+ const confirmBtn = screen.getByRole('button', { name: /confirm/i })
510
+
511
+ // Focus should be on first focusable element
512
+ expect(cancelBtn).toHaveFocus()
513
+
514
+ // Tab should cycle through modal elements
515
+ await user.tab()
516
+ expect(confirmBtn).toHaveFocus()
517
+
518
+ await user.tab()
519
+ expect(cancelBtn).toHaveFocus() // Wraps back
520
+ })
521
+
522
+ it('closes on escape key', async () => {
523
+ const user = userEvent.setup()
524
+ const onCancel = vi.fn()
525
+ render(
526
+ <ConfirmDialog
527
+ isOpen={true}
528
+ title="Delete"
529
+ message="Sure?"
530
+ onConfirm={vi.fn()}
531
+ onCancel={onCancel}
532
+ />
533
+ )
534
+
535
+ await user.keyboard('{Escape}')
536
+
537
+ expect(onCancel).toHaveBeenCalled()
538
+ })
539
+
540
+ it('closes on backdrop click', async () => {
541
+ const user = userEvent.setup()
542
+ const onCancel = vi.fn()
543
+ render(
544
+ <ConfirmDialog
545
+ isOpen={true}
546
+ title="Delete"
547
+ message="Sure?"
548
+ onConfirm={vi.fn()}
549
+ onCancel={onCancel}
550
+ />
551
+ )
552
+
553
+ // Click the backdrop (outside the dialog content)
554
+ await user.click(screen.getByTestId('dialog-backdrop'))
555
+
556
+ expect(onCancel).toHaveBeenCalled()
557
+ })
558
+ })
559
+ ```
560
+
561
+ ---
562
+
563
+ ## Testing Accessibility
564
+
565
+ ```typescript
566
+ import { axe, toHaveNoViolations } from 'jest-axe'
567
+
568
+ expect.extend(toHaveNoViolations)
569
+
570
+ describe('Form Accessibility', () => {
571
+ it('has no accessibility violations', async () => {
572
+ const { container } = render(<RegistrationForm onSubmit={vi.fn()} />)
573
+
574
+ const results = await axe(container)
575
+
576
+ expect(results).toHaveNoViolations()
577
+ })
578
+
579
+ it('associates labels with inputs', () => {
580
+ render(<RegistrationForm onSubmit={vi.fn()} />)
581
+
582
+ const emailInput = screen.getByLabelText(/email/i)
583
+ expect(emailInput).toHaveAttribute('id')
584
+ expect(emailInput.tagName.toLowerCase()).toBe('input')
585
+ })
586
+
587
+ it('has proper ARIA attributes on errors', async () => {
588
+ const user = userEvent.setup()
589
+ render(<RegistrationForm onSubmit={vi.fn()} />)
590
+
591
+ await user.click(screen.getByRole('button', { name: /submit/i }))
592
+
593
+ const emailInput = screen.getByLabelText(/email/i)
594
+ expect(emailInput).toHaveAttribute('aria-invalid', 'true')
595
+ expect(emailInput).toHaveAttribute('aria-describedby')
596
+ })
597
+
598
+ it('marks required fields with aria-required', () => {
599
+ render(<RegistrationForm onSubmit={vi.fn()} />)
600
+
601
+ expect(screen.getByLabelText(/name/i)).toHaveAttribute('aria-required', 'true')
602
+ expect(screen.getByLabelText(/email/i)).toHaveAttribute('aria-required', 'true')
603
+ })
604
+
605
+ it('uses proper heading hierarchy', () => {
606
+ render(<Dashboard />)
607
+
608
+ const headings = screen.getAllByRole('heading')
609
+ // First heading should be h1
610
+ expect(headings[0]).toHaveAttribute('level') // or check tagName
611
+ })
612
+
613
+ it('provides alt text for images', () => {
614
+ render(<UserProfile user={mockUser} />)
615
+
616
+ const avatar = screen.getByRole('img', { name: /profile photo/i })
617
+ expect(avatar).toHaveAttribute('alt')
618
+ expect(avatar.getAttribute('alt')).not.toBe('')
619
+ })
620
+ })
621
+ ```
622
+
623
+ ---
624
+
625
+ ## Testing Hooks in Components
626
+
627
+ When a hook is too complex to test with `renderHook` alone, test it through a simple test component:
628
+
629
+ ```typescript
630
+ describe('useLocalStorage', () => {
631
+ it('persists value to localStorage', () => {
632
+ function TestComponent() {
633
+ const [value, setValue] = useLocalStorage('test-key', 'default')
634
+ return (
635
+ <div>
636
+ <span data-testid="value">{value}</span>
637
+ <button onClick={() => setValue('updated')}>Update</button>
638
+ </div>
639
+ )
640
+ }
641
+
642
+ render(<TestComponent />)
643
+
644
+ expect(screen.getByTestId('value')).toHaveTextContent('default')
645
+
646
+ fireEvent.click(screen.getByRole('button'))
647
+
648
+ expect(screen.getByTestId('value')).toHaveTextContent('updated')
649
+ expect(localStorage.getItem('test-key')).toBe('"updated"')
650
+ })
651
+
652
+ it('reads initial value from localStorage', () => {
653
+ localStorage.setItem('test-key', '"persisted"')
654
+
655
+ function TestComponent() {
656
+ const [value] = useLocalStorage('test-key', 'default')
657
+ return <span data-testid="value">{value}</span>
658
+ }
659
+
660
+ render(<TestComponent />)
661
+
662
+ expect(screen.getByTestId('value')).toHaveTextContent('persisted')
663
+ })
664
+ })
665
+ ```
666
+
667
+ ---
668
+
669
+ ## Testing Conditional Rendering
670
+
671
+ ```typescript
672
+ describe('StatusMessage', () => {
673
+ it('renders success variant', () => {
674
+ render(<StatusMessage type="success" message="Done!" />)
675
+
676
+ const el = screen.getByText('Done!')
677
+ expect(el).toBeInTheDocument()
678
+ expect(el).toHaveClass('status-success')
679
+ })
680
+
681
+ it('renders error variant with retry button', () => {
682
+ render(<StatusMessage type="error" message="Failed" onRetry={vi.fn()} />)
683
+
684
+ expect(screen.getByText('Failed')).toBeInTheDocument()
685
+ expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
686
+ })
687
+
688
+ it('does not render retry button for success', () => {
689
+ render(<StatusMessage type="success" message="Done!" onRetry={vi.fn()} />)
690
+
691
+ expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument()
692
+ })
693
+
694
+ it('renders nothing when message is empty', () => {
695
+ const { container } = render(<StatusMessage type="info" message="" />)
696
+
697
+ expect(container.firstChild).toBeNull()
698
+ })
699
+ })
700
+ ```
701
+
702
+ ---
703
+
704
+ ## Testing Lists and Iteration
705
+
706
+ ```typescript
707
+ describe('ItemList', () => {
708
+ const items = [
709
+ { id: '1', name: 'Alpha', status: 'active' },
710
+ { id: '2', name: 'Beta', status: 'inactive' },
711
+ { id: '3', name: 'Gamma', status: 'active' },
712
+ ]
713
+
714
+ it('renders all items', () => {
715
+ render(<ItemList items={items} />)
716
+
717
+ expect(screen.getAllByRole('listitem')).toHaveLength(3)
718
+ expect(screen.getByText('Alpha')).toBeInTheDocument()
719
+ expect(screen.getByText('Beta')).toBeInTheDocument()
720
+ expect(screen.getByText('Gamma')).toBeInTheDocument()
721
+ })
722
+
723
+ it('renders empty state when no items', () => {
724
+ render(<ItemList items={[]} />)
725
+
726
+ expect(screen.queryAllByRole('listitem')).toHaveLength(0)
727
+ expect(screen.getByText(/no items/i)).toBeInTheDocument()
728
+ })
729
+
730
+ it('applies correct styles based on status', () => {
731
+ render(<ItemList items={items} />)
732
+
733
+ const activeItems = screen.getAllByTestId('status-active')
734
+ expect(activeItems).toHaveLength(2)
735
+ })
736
+ })
737
+ ```
738
+
739
+ ---
740
+
741
+ ## Snapshot Testing
742
+
743
+ Use snapshots sparingly -- prefer explicit assertions. Good for detecting unintended UI changes:
744
+
745
+ ```typescript
746
+ describe('StatusBadge Snapshots', () => {
747
+ it('matches snapshot for each status', () => {
748
+ const statuses = ['active', 'inactive', 'pending'] as const
749
+
750
+ statuses.forEach(status => {
751
+ const { container } = render(<StatusBadge status={status} />)
752
+ expect(container).toMatchSnapshot()
753
+ })
754
+ })
755
+
756
+ // Inline snapshots for small components
757
+ it('matches inline snapshot', () => {
758
+ const { container } = render(<StatusBadge status="active" />)
759
+
760
+ expect(container.innerHTML).toMatchInlineSnapshot(
761
+ `"<span class=\"badge badge-active\">Active</span>"`
762
+ )
763
+ })
764
+ })
765
+ ```
766
+
767
+ **When to use snapshots:**
768
+ - Small, presentational components with stable output
769
+ - Detecting unintended changes in rendered HTML
770
+ - NOT for components with dynamic content (dates, random IDs)
771
+
772
+ **When NOT to use snapshots:**
773
+ - Large components (snapshots become unreadable)
774
+ - Components with frequently changing markup
775
+ - As the only test for a component (combine with behavior tests)
776
+
777
+ ---
778
+
779
+ ## React Native Testing (Expo / React Native)
780
+
781
+ > This section applies when `{{framework}}` is `react-native` or when the project uses Expo.
782
+
783
+ ### Setup
784
+
785
+ ```typescript
786
+ // jest.setup.ts (or vitest setup)
787
+ import '@testing-library/react-native/extend-expect'
788
+
789
+ // Mock native modules that don't work in test environment
790
+ vi.mock('expo-haptics', () => ({
791
+ impactAsync: vi.fn(),
792
+ notificationAsync: vi.fn(),
793
+ selectionAsync: vi.fn(),
794
+ ImpactFeedbackStyle: { Light: 'light', Medium: 'medium', Heavy: 'heavy' },
795
+ NotificationFeedbackType: { Success: 'success', Warning: 'warning', Error: 'error' },
796
+ }))
797
+
798
+ vi.mock('expo-image-picker', () => ({
799
+ launchImageLibraryAsync: vi.fn().mockResolvedValue({
800
+ canceled: false,
801
+ assets: [{ uri: 'file:///mock/image.jpg', width: 100, height: 100 }],
802
+ }),
803
+ launchCameraAsync: vi.fn().mockResolvedValue({
804
+ canceled: false,
805
+ assets: [{ uri: 'file:///mock/photo.jpg', width: 100, height: 100 }],
806
+ }),
807
+ MediaTypeOptions: { Images: 'images', Videos: 'videos', All: 'all' },
808
+ }))
809
+
810
+ vi.mock('expo-linking', () => ({
811
+ openURL: vi.fn(),
812
+ createURL: vi.fn((path: string) => `exp://localhost:8081/${path}`),
813
+ }))
814
+
815
+ vi.mock('expo-router', () => ({
816
+ useRouter: vi.fn(() => ({
817
+ push: vi.fn(),
818
+ replace: vi.fn(),
819
+ back: vi.fn(),
820
+ })),
821
+ useLocalSearchParams: vi.fn(() => ({})),
822
+ useSegments: vi.fn(() => []),
823
+ Link: ({ children }: any) => children,
824
+ }))
825
+
826
+ vi.mock('expo-secure-store', () => ({
827
+ getItemAsync: vi.fn(),
828
+ setItemAsync: vi.fn(),
829
+ deleteItemAsync: vi.fn(),
830
+ }))
831
+
832
+ // Mock native animated helper to avoid warnings
833
+ vi.mock('react-native/Libraries/Animated/NativeAnimatedHelper')
834
+ ```
835
+
836
+ ### React Native Component Test
837
+
838
+ ```typescript
839
+ // components/__tests__/ProfileCard.test.tsx
840
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'
841
+ import { describe, it, expect, vi } from '{{testRunner}}'
842
+ import { ProfileCard } from '../ProfileCard'
843
+
844
+ describe('ProfileCard', () => {
845
+ const mockProfile = {
846
+ id: '1',
847
+ name: 'Jane Smith',
848
+ phone: '555-123-4567',
849
+ status: 'ACTIVE' as const,
850
+ }
851
+
852
+ it('renders profile name and phone', () => {
853
+ render(<ProfileCard profile={mockProfile} onPress={vi.fn()} />)
854
+
855
+ expect(screen.getByText('Jane Smith')).toBeTruthy()
856
+ expect(screen.getByText('555-123-4567')).toBeTruthy()
857
+ })
858
+
859
+ it('calls onPress with profile id when tapped', () => {
860
+ const onPress = vi.fn()
861
+ render(<ProfileCard profile={mockProfile} onPress={onPress} />)
862
+
863
+ fireEvent.press(screen.getByText('Jane Smith'))
864
+
865
+ expect(onPress).toHaveBeenCalledWith('1')
866
+ })
867
+
868
+ it('shows status badge with correct text', () => {
869
+ render(<ProfileCard profile={mockProfile} onPress={vi.fn()} />)
870
+
871
+ expect(screen.getByText('ACTIVE')).toBeTruthy()
872
+ })
873
+
874
+ it('renders placeholder when image fails to load', () => {
875
+ render(<ProfileCard profile={{ ...mockProfile, avatarUrl: 'broken' }} onPress={vi.fn()} />)
876
+
877
+ // Should fall back to initials or placeholder
878
+ expect(screen.getByText('JS')).toBeTruthy() // Initials
879
+ })
880
+ })
881
+ ```
882
+
883
+ ### Testing Expo Router Navigation
884
+
885
+ ```typescript
886
+ import { render, screen, fireEvent } from '@testing-library/react-native'
887
+ import { useRouter } from 'expo-router'
888
+ import { describe, it, expect, vi } from '{{testRunner}}'
889
+ import { ItemListScreen } from '../ItemListScreen'
890
+
891
+ describe('ItemListScreen navigation', () => {
892
+ it('navigates to detail screen on item tap', () => {
893
+ const mockPush = vi.fn()
894
+ vi.mocked(useRouter).mockReturnValue({
895
+ push: mockPush,
896
+ replace: vi.fn(),
897
+ back: vi.fn(),
898
+ } as any)
899
+
900
+ render(<ItemListScreen />)
901
+
902
+ fireEvent.press(screen.getByText('Jane Smith'))
903
+
904
+ expect(mockPush).toHaveBeenCalledWith('/items/1')
905
+ })
906
+
907
+ it('navigates to create screen on add button tap', () => {
908
+ const mockPush = vi.fn()
909
+ vi.mocked(useRouter).mockReturnValue({
910
+ push: mockPush,
911
+ replace: vi.fn(),
912
+ back: vi.fn(),
913
+ } as any)
914
+
915
+ render(<ItemListScreen />)
916
+
917
+ fireEvent.press(screen.getByTestId('add-button'))
918
+
919
+ expect(mockPush).toHaveBeenCalledWith('/items/new')
920
+ })
921
+ })
922
+ ```
923
+
924
+ ### React Native Query Priority
925
+
926
+ | Priority | Query | Use When |
927
+ |----------|-------|----------|
928
+ | 1 | `getByText` | Visible text content |
929
+ | 2 | `getByRole` | Accessibility role set on component |
930
+ | 3 | `getByLabelText` | Accessible label (accessibilityLabel) |
931
+ | 4 | `getByPlaceholderText` | Input placeholders |
932
+ | 5 | `getByTestId` | Last resort (testID prop) |
933
+
934
+ **Key differences from web:**
935
+ - React Native does not have DOM concepts like `getByClassName`
936
+ - Use `fireEvent.press` instead of `fireEvent.click`
937
+ - Use `toBeTruthy()` instead of `toBeInTheDocument()` (unless using extend-expect)
938
+ - `testID` prop (camelCase) maps to `getByTestId`
939
+
940
+ ### Mocking Native Modules
941
+
942
+ For any Expo/RN module that uses native code:
943
+
944
+ ```typescript
945
+ // Mock audio/video
946
+ vi.mock('expo-av', () => ({
947
+ Audio: {
948
+ Sound: {
949
+ createAsync: vi.fn().mockResolvedValue({
950
+ sound: { playAsync: vi.fn(), unloadAsync: vi.fn() },
951
+ status: { isLoaded: true },
952
+ }),
953
+ },
954
+ },
955
+ }))
956
+
957
+ // Mock Dimensions for responsive tests
958
+ vi.mock('react-native', async () => {
959
+ const actual = await vi.importActual('react-native')
960
+ return {
961
+ ...actual,
962
+ Dimensions: {
963
+ get: vi.fn().mockReturnValue({ width: 375, height: 812 }),
964
+ addEventListener: vi.fn(),
965
+ },
966
+ }
967
+ })
968
+
969
+ // Mock camera
970
+ vi.mock('expo-camera', () => ({
971
+ Camera: {
972
+ requestCameraPermissionsAsync: vi.fn().mockResolvedValue({ status: 'granted' }),
973
+ },
974
+ CameraView: ({ children }: any) => children,
975
+ }))
976
+
977
+ // Mock location
978
+ vi.mock('expo-location', () => ({
979
+ requestForegroundPermissionsAsync: vi.fn().mockResolvedValue({ status: 'granted' }),
980
+ getCurrentPositionAsync: vi.fn().mockResolvedValue({
981
+ coords: { latitude: 37.7749, longitude: -122.4194 },
982
+ }),
983
+ }))
984
+ ```
985
+
986
+ ### Testing React Native Animations
987
+
988
+ ```typescript
989
+ import { Animated } from 'react-native'
990
+
991
+ describe('AnimatedCard', () => {
992
+ beforeEach(() => {
993
+ vi.useFakeTimers()
994
+ })
995
+
996
+ afterEach(() => {
997
+ vi.useRealTimers()
998
+ })
999
+
1000
+ it('fades in on mount', () => {
1001
+ const spy = vi.spyOn(Animated, 'timing')
1002
+ render(<AnimatedCard />)
1003
+
1004
+ expect(spy).toHaveBeenCalledWith(
1005
+ expect.any(Object),
1006
+ expect.objectContaining({ toValue: 1, duration: 300 })
1007
+ )
1008
+ })
1009
+ })
1010
+ ```
1011
+
1012
+ ---
1013
+
1014
+ ## Testing Responsive Behavior (Web)
1015
+
1016
+ ```typescript
1017
+ describe('Navigation', () => {
1018
+ it('shows hamburger menu on small screens', () => {
1019
+ // Set viewport width
1020
+ Object.defineProperty(window, 'innerWidth', { value: 375, writable: true })
1021
+ window.dispatchEvent(new Event('resize'))
1022
+
1023
+ render(<Navigation />)
1024
+
1025
+ expect(screen.getByTestId('hamburger-button')).toBeInTheDocument()
1026
+ expect(screen.queryByTestId('desktop-nav')).not.toBeInTheDocument()
1027
+ })
1028
+
1029
+ it('shows full nav on desktop', () => {
1030
+ Object.defineProperty(window, 'innerWidth', { value: 1280, writable: true })
1031
+ window.dispatchEvent(new Event('resize'))
1032
+
1033
+ render(<Navigation />)
1034
+
1035
+ expect(screen.queryByTestId('hamburger-button')).not.toBeInTheDocument()
1036
+ expect(screen.getByTestId('desktop-nav')).toBeInTheDocument()
1037
+ })
1038
+ })
1039
+ ```
1040
+
1041
+ ---
1042
+
1043
+ ## Checklist
1044
+
1045
+ Before submitting component tests:
1046
+
1047
+ - [ ] Uses semantic queries (getByRole, getByLabelText first)
1048
+ - [ ] Tests user interactions with userEvent (not fireEvent where possible)
1049
+ - [ ] Tests loading state
1050
+ - [ ] Tests error state
1051
+ - [ ] Tests empty state
1052
+ - [ ] Tests accessibility (a11y violations)
1053
+ - [ ] Avoids testing implementation details (no internal state, no className)
1054
+ - [ ] Mock functions verified with correct arguments
1055
+ - [ ] Async behavior tested with waitFor/findBy
1056
+ - [ ] No direct DOM queries (no querySelector)
1057
+ - [ ] Providers wrapped via test-utils render
1058
+ - [ ] For RN: Uses fireEvent.press, not fireEvent.click
1059
+ - [ ] For RN: Mocks native modules in setup file
1060
+
1061
+ ---
1062
+
1063
+ ## Running Component Tests
1064
+
1065
+ ```bash
1066
+ # Run all component tests
1067
+ {{testCommand}}
1068
+
1069
+ # Run specific component tests
1070
+ {{testCommand}} ItemCard.test.tsx
1071
+
1072
+ # Run with coverage
1073
+ {{testCommand}} --coverage
1074
+
1075
+ # Run in watch mode
1076
+ {{testCommand}} --watch
1077
+
1078
+ # Run tests matching a pattern
1079
+ {{testCommand}} --testNamePattern="submits form"
1080
+ ```
1081
+
1082
+ ---
1083
+
1084
+ ## See Also
1085
+
1086
+ - `unit-test.md` - Unit testing patterns
1087
+ - `e2e-test.md` - End-to-end testing patterns
1088
+ - `test-standards.md` - Coverage thresholds and test type requirements