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.
- package/README.md +461 -0
- package/VERSION +1 -0
- package/bin/install.js +116 -0
- package/commands/qa/continue.md +77 -0
- package/commands/qa/full.md +149 -0
- package/commands/qa/init.md +105 -0
- package/commands/qa/resume.md +91 -0
- package/commands/qa/status.md +66 -0
- package/package.json +28 -0
- package/skills/qa/SKILL.md +420 -0
- package/skills/qa/references/continuation-format.md +58 -0
- package/skills/qa/references/exit-criteria.md +53 -0
- package/skills/qa/references/lifecycle.md +181 -0
- package/skills/qa/references/model-profiles.md +77 -0
- package/skills/qa/templates/agent-skeleton.md +733 -0
- package/skills/qa/templates/component-test.md +1088 -0
- package/skills/qa/templates/domain-research-queries.md +101 -0
- package/skills/qa/templates/domain-security-profiles.md +182 -0
- package/skills/qa/templates/e2e-test.md +1200 -0
- package/skills/qa/templates/nielsen-heuristics.md +274 -0
- package/skills/qa/templates/performance-benchmarks-base.md +321 -0
- package/skills/qa/templates/qa-report-template.md +271 -0
- package/skills/qa/templates/security-checklist-owasp.md +451 -0
- package/skills/qa/templates/stop-points/bootstrap-complete.md +36 -0
- package/skills/qa/templates/stop-points/certified.md +25 -0
- package/skills/qa/templates/stop-points/escalated.md +32 -0
- package/skills/qa/templates/stop-points/fix-ready.md +43 -0
- package/skills/qa/templates/stop-points/phase-transition.md +4 -0
- package/skills/qa/templates/stop-points/status-dashboard.md +32 -0
- package/skills/qa/templates/test-standards.md +652 -0
- package/skills/qa/templates/unit-test.md +998 -0
- package/skills/qa/templates/visual-regression.md +418 -0
- package/skills/qa/workflows/bootstrap.md +45 -0
- package/skills/qa/workflows/decision-gate.md +66 -0
- package/skills/qa/workflows/fix-execute.md +132 -0
- package/skills/qa/workflows/fix-plan.md +52 -0
- package/skills/qa/workflows/report-phase.md +64 -0
- package/skills/qa/workflows/test-phase.md +86 -0
- 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
|