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,998 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: unit-test
|
|
3
|
+
description: Create {{testRunner}} unit tests following project patterns. Use when writing tests for utilities, hooks, or pure functions.
|
|
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
|
+
# Unit Test Creation ({{testRunner}})
|
|
16
|
+
|
|
17
|
+
This skill guides creation of unit tests using {{testRunner}}.
|
|
18
|
+
|
|
19
|
+
## Template Variables
|
|
20
|
+
|
|
21
|
+
| Variable | Description | Default |
|
|
22
|
+
|----------|-------------|---------|
|
|
23
|
+
| `{{testRunner}}` | Test runner name (vitest, jest) | vitest |
|
|
24
|
+
| `{{testCommand}}` | Command to run tests | pnpm test |
|
|
25
|
+
| `{{testCoverageCommand}}` | Command for coverage report | pnpm test:coverage |
|
|
26
|
+
| `{{testWatchCommand}}` | Command for watch mode | pnpm test:watch |
|
|
27
|
+
| `{{srcAlias}}` | Import alias for source files | @/ |
|
|
28
|
+
| `{{sharedPackage}}` | Shared package import path | @myapp/shared |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Prerequisites
|
|
33
|
+
|
|
34
|
+
- Understand the function/module being tested
|
|
35
|
+
- Know input/output expectations
|
|
36
|
+
- Identify edge cases and boundary conditions
|
|
37
|
+
- Check existing test patterns in the codebase
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Test File Structure
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
// __tests__/utils/formatCurrency.test.ts
|
|
45
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from '{{testRunner}}'
|
|
46
|
+
import { formatCurrency, parseCurrency } from '{{srcAlias}}lib/utils/currency'
|
|
47
|
+
|
|
48
|
+
describe('formatCurrency', () => {
|
|
49
|
+
// Group related tests
|
|
50
|
+
describe('basic formatting', () => {
|
|
51
|
+
it('formats positive numbers with $ and commas', () => {
|
|
52
|
+
expect(formatCurrency(1234.56)).toBe('$1,234.56')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('formats zero as $0.00', () => {
|
|
56
|
+
expect(formatCurrency(0)).toBe('$0.00')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('formats negative numbers with minus sign', () => {
|
|
60
|
+
expect(formatCurrency(-500)).toBe('-$500.00')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Edge cases
|
|
65
|
+
describe('edge cases', () => {
|
|
66
|
+
it('handles undefined by returning $0.00', () => {
|
|
67
|
+
expect(formatCurrency(undefined as any)).toBe('$0.00')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('handles NaN by returning $0.00', () => {
|
|
71
|
+
expect(formatCurrency(NaN)).toBe('$0.00')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('handles very large numbers', () => {
|
|
75
|
+
expect(formatCurrency(1000000000)).toBe('$1,000,000,000.00')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Test Naming Convention
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
[file-being-tested].test.ts
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| Source File | Test File |
|
|
90
|
+
|-------------|-----------|
|
|
91
|
+
| `lib/utils/currency.ts` | `__tests__/utils/currency.test.ts` |
|
|
92
|
+
| `lib/hooks/useDebounce.ts` | `__tests__/hooks/useDebounce.test.ts` |
|
|
93
|
+
| `lib/api/transform.ts` | `__tests__/api/transform.test.ts` |
|
|
94
|
+
| `services/email.ts` | `__tests__/services/email.test.ts` |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Testing Patterns
|
|
99
|
+
|
|
100
|
+
### Testing Pure Functions
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { describe, it, expect } from '{{testRunner}}'
|
|
104
|
+
import { calculateTax, calculateTotal } from '{{srcAlias}}lib/utils/pricing'
|
|
105
|
+
|
|
106
|
+
describe('calculateTax', () => {
|
|
107
|
+
it('calculates percentage-based tax correctly', () => {
|
|
108
|
+
const result = calculateTax(100, 0.0825)
|
|
109
|
+
expect(result).toBe(8.25)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('rounds to 2 decimal places', () => {
|
|
113
|
+
const result = calculateTax(99.99, 0.0825)
|
|
114
|
+
expect(result).toBeCloseTo(8.25, 2)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('returns 0 for zero amount', () => {
|
|
118
|
+
const result = calculateTax(0, 0.0825)
|
|
119
|
+
expect(result).toBe(0)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('handles negative amounts', () => {
|
|
123
|
+
const result = calculateTax(-100, 0.0825)
|
|
124
|
+
expect(result).toBe(-8.25)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Testing with Mocks
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { describe, it, expect, vi, beforeEach } from '{{testRunner}}'
|
|
133
|
+
import { fetchUserData } from '{{srcAlias}}lib/api/users'
|
|
134
|
+
|
|
135
|
+
// Mock the API client module
|
|
136
|
+
vi.mock('{{srcAlias}}lib/api/client', () => ({
|
|
137
|
+
apiClient: {
|
|
138
|
+
get: vi.fn(),
|
|
139
|
+
post: vi.fn(),
|
|
140
|
+
put: vi.fn(),
|
|
141
|
+
delete: vi.fn(),
|
|
142
|
+
},
|
|
143
|
+
}))
|
|
144
|
+
|
|
145
|
+
import { apiClient } from '{{srcAlias}}lib/api/client'
|
|
146
|
+
|
|
147
|
+
describe('fetchUserData', () => {
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
vi.clearAllMocks()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('returns user data on success', async () => {
|
|
153
|
+
const mockUser = { id: '1', name: 'Jane Smith', email: 'jane@example.com' }
|
|
154
|
+
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUser })
|
|
155
|
+
|
|
156
|
+
const result = await fetchUserData('1')
|
|
157
|
+
|
|
158
|
+
expect(apiClient.get).toHaveBeenCalledWith('/users/1')
|
|
159
|
+
expect(result).toEqual(mockUser)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('throws on API error', async () => {
|
|
163
|
+
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))
|
|
164
|
+
|
|
165
|
+
await expect(fetchUserData('1')).rejects.toThrow('Network error')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('passes query parameters correctly', async () => {
|
|
169
|
+
vi.mocked(apiClient.get).mockResolvedValue({ data: {} })
|
|
170
|
+
|
|
171
|
+
await fetchUserData('1', { include: 'profile' })
|
|
172
|
+
|
|
173
|
+
expect(apiClient.get).toHaveBeenCalledWith('/users/1', {
|
|
174
|
+
params: { include: 'profile' },
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Testing Async Functions
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { describe, it, expect, vi } from '{{testRunner}}'
|
|
184
|
+
import { waitForCondition } from '{{srcAlias}}lib/utils/async'
|
|
185
|
+
|
|
186
|
+
describe('waitForCondition', () => {
|
|
187
|
+
it('resolves when condition becomes true', async () => {
|
|
188
|
+
let counter = 0
|
|
189
|
+
const condition = () => {
|
|
190
|
+
counter++
|
|
191
|
+
return counter >= 3
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await expect(waitForCondition(condition, 100)).resolves.toBe(true)
|
|
195
|
+
expect(counter).toBe(3)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('rejects after timeout', async () => {
|
|
199
|
+
const neverTrue = () => false
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
waitForCondition(neverTrue, 50)
|
|
203
|
+
).rejects.toThrow('Timeout')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('retryWithBackoff', () => {
|
|
208
|
+
it('succeeds on first attempt', async () => {
|
|
209
|
+
const fn = vi.fn().mockResolvedValue('success')
|
|
210
|
+
|
|
211
|
+
const result = await retryWithBackoff(fn, { maxRetries: 3 })
|
|
212
|
+
|
|
213
|
+
expect(result).toBe('success')
|
|
214
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('retries on failure and eventually succeeds', async () => {
|
|
218
|
+
const fn = vi.fn()
|
|
219
|
+
.mockRejectedValueOnce(new Error('fail 1'))
|
|
220
|
+
.mockRejectedValueOnce(new Error('fail 2'))
|
|
221
|
+
.mockResolvedValue('success')
|
|
222
|
+
|
|
223
|
+
const result = await retryWithBackoff(fn, { maxRetries: 3 })
|
|
224
|
+
|
|
225
|
+
expect(result).toBe('success')
|
|
226
|
+
expect(fn).toHaveBeenCalledTimes(3)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('throws after exhausting retries', async () => {
|
|
230
|
+
const fn = vi.fn().mockRejectedValue(new Error('persistent failure'))
|
|
231
|
+
|
|
232
|
+
await expect(
|
|
233
|
+
retryWithBackoff(fn, { maxRetries: 2 })
|
|
234
|
+
).rejects.toThrow('persistent failure')
|
|
235
|
+
expect(fn).toHaveBeenCalledTimes(3) // initial + 2 retries
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Testing with Timers
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from '{{testRunner}}'
|
|
244
|
+
import { debounce } from '{{srcAlias}}lib/utils/debounce'
|
|
245
|
+
|
|
246
|
+
describe('debounce', () => {
|
|
247
|
+
beforeEach(() => {
|
|
248
|
+
vi.useFakeTimers()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
afterEach(() => {
|
|
252
|
+
vi.useRealTimers()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('delays function execution', () => {
|
|
256
|
+
const fn = vi.fn()
|
|
257
|
+
const debounced = debounce(fn, 300)
|
|
258
|
+
|
|
259
|
+
debounced()
|
|
260
|
+
expect(fn).not.toHaveBeenCalled()
|
|
261
|
+
|
|
262
|
+
vi.advanceTimersByTime(300)
|
|
263
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('resets timer on subsequent calls', () => {
|
|
267
|
+
const fn = vi.fn()
|
|
268
|
+
const debounced = debounce(fn, 300)
|
|
269
|
+
|
|
270
|
+
debounced()
|
|
271
|
+
vi.advanceTimersByTime(200)
|
|
272
|
+
debounced()
|
|
273
|
+
vi.advanceTimersByTime(200)
|
|
274
|
+
|
|
275
|
+
expect(fn).not.toHaveBeenCalled()
|
|
276
|
+
|
|
277
|
+
vi.advanceTimersByTime(100)
|
|
278
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('passes arguments from the last call', () => {
|
|
282
|
+
const fn = vi.fn()
|
|
283
|
+
const debounced = debounce(fn, 300)
|
|
284
|
+
|
|
285
|
+
debounced('first')
|
|
286
|
+
debounced('second')
|
|
287
|
+
debounced('third')
|
|
288
|
+
|
|
289
|
+
vi.advanceTimersByTime(300)
|
|
290
|
+
|
|
291
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
292
|
+
expect(fn).toHaveBeenCalledWith('third')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('can be cancelled', () => {
|
|
296
|
+
const fn = vi.fn()
|
|
297
|
+
const debounced = debounce(fn, 300)
|
|
298
|
+
|
|
299
|
+
debounced()
|
|
300
|
+
debounced.cancel()
|
|
301
|
+
|
|
302
|
+
vi.advanceTimersByTime(300)
|
|
303
|
+
expect(fn).not.toHaveBeenCalled()
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Testing with Date/Time
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from '{{testRunner}}'
|
|
312
|
+
import { formatRelativeTime, isExpired } from '{{srcAlias}}lib/utils/date'
|
|
313
|
+
|
|
314
|
+
describe('formatRelativeTime', () => {
|
|
315
|
+
beforeEach(() => {
|
|
316
|
+
vi.useFakeTimers()
|
|
317
|
+
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'))
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
afterEach(() => {
|
|
321
|
+
vi.useRealTimers()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('returns "just now" for recent timestamps', () => {
|
|
325
|
+
const now = new Date()
|
|
326
|
+
expect(formatRelativeTime(now)).toBe('just now')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('returns "5 minutes ago" for 5-minute-old timestamps', () => {
|
|
330
|
+
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000)
|
|
331
|
+
expect(formatRelativeTime(fiveMinAgo)).toBe('5 minutes ago')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('returns "yesterday" for 24-hour-old timestamps', () => {
|
|
335
|
+
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
|
336
|
+
expect(formatRelativeTime(yesterday)).toBe('yesterday')
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('isExpired', () => {
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
vi.useFakeTimers()
|
|
343
|
+
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'))
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
afterEach(() => {
|
|
347
|
+
vi.useRealTimers()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('returns true for past dates', () => {
|
|
351
|
+
expect(isExpired(new Date('2024-06-14T00:00:00Z'))).toBe(true)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('returns false for future dates', () => {
|
|
355
|
+
expect(isExpired(new Date('2024-06-16T00:00:00Z'))).toBe(false)
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Testing Hooks
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
import { describe, it, expect } from '{{testRunner}}'
|
|
366
|
+
import { renderHook, act } from '@testing-library/react'
|
|
367
|
+
import { useCounter } from '{{srcAlias}}lib/hooks/useCounter'
|
|
368
|
+
|
|
369
|
+
describe('useCounter', () => {
|
|
370
|
+
it('initializes with default value', () => {
|
|
371
|
+
const { result } = renderHook(() => useCounter())
|
|
372
|
+
expect(result.current.count).toBe(0)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('initializes with provided value', () => {
|
|
376
|
+
const { result } = renderHook(() => useCounter(10))
|
|
377
|
+
expect(result.current.count).toBe(10)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('increments count', () => {
|
|
381
|
+
const { result } = renderHook(() => useCounter())
|
|
382
|
+
|
|
383
|
+
act(() => {
|
|
384
|
+
result.current.increment()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
expect(result.current.count).toBe(1)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('decrements count', () => {
|
|
391
|
+
const { result } = renderHook(() => useCounter(5))
|
|
392
|
+
|
|
393
|
+
act(() => {
|
|
394
|
+
result.current.decrement()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
expect(result.current.count).toBe(4)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('resets to initial value', () => {
|
|
401
|
+
const { result } = renderHook(() => useCounter(10))
|
|
402
|
+
|
|
403
|
+
act(() => {
|
|
404
|
+
result.current.increment()
|
|
405
|
+
result.current.increment()
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
expect(result.current.count).toBe(12)
|
|
409
|
+
|
|
410
|
+
act(() => {
|
|
411
|
+
result.current.reset()
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
expect(result.current.count).toBe(10)
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Testing Hooks with Dependencies
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
import { describe, it, expect, vi } from '{{testRunner}}'
|
|
423
|
+
import { renderHook, act, waitFor } from '@testing-library/react'
|
|
424
|
+
import { useDebounce } from '{{srcAlias}}lib/hooks/useDebounce'
|
|
425
|
+
|
|
426
|
+
describe('useDebounce', () => {
|
|
427
|
+
beforeEach(() => {
|
|
428
|
+
vi.useFakeTimers()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
afterEach(() => {
|
|
432
|
+
vi.useRealTimers()
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('returns initial value immediately', () => {
|
|
436
|
+
const { result } = renderHook(() => useDebounce('hello', 300))
|
|
437
|
+
expect(result.current).toBe('hello')
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('debounces value updates', () => {
|
|
441
|
+
const { result, rerender } = renderHook(
|
|
442
|
+
({ value, delay }) => useDebounce(value, delay),
|
|
443
|
+
{ initialProps: { value: 'hello', delay: 300 } }
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
rerender({ value: 'world', delay: 300 })
|
|
447
|
+
expect(result.current).toBe('hello') // Still old value
|
|
448
|
+
|
|
449
|
+
act(() => {
|
|
450
|
+
vi.advanceTimersByTime(300)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
expect(result.current).toBe('world') // Now updated
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Testing Hooks with Context Providers
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
import { describe, it, expect } from '{{testRunner}}'
|
|
462
|
+
import { renderHook } from '@testing-library/react'
|
|
463
|
+
import { useTheme } from '{{srcAlias}}lib/hooks/useTheme'
|
|
464
|
+
import { ThemeProvider } from '{{srcAlias}}lib/providers/ThemeProvider'
|
|
465
|
+
|
|
466
|
+
describe('useTheme', () => {
|
|
467
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
468
|
+
<ThemeProvider defaultTheme="light">{children}</ThemeProvider>
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
it('returns current theme from context', () => {
|
|
472
|
+
const { result } = renderHook(() => useTheme(), { wrapper })
|
|
473
|
+
expect(result.current.theme).toBe('light')
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('throws when used outside ThemeProvider', () => {
|
|
477
|
+
expect(() => {
|
|
478
|
+
renderHook(() => useTheme())
|
|
479
|
+
}).toThrow('useTheme must be used within a ThemeProvider')
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## Assertions Reference
|
|
487
|
+
|
|
488
|
+
| Assertion | Use For |
|
|
489
|
+
|-----------|---------|
|
|
490
|
+
| `expect(x).toBe(y)` | Primitive equality (===) |
|
|
491
|
+
| `expect(x).toEqual(y)` | Deep equality (objects/arrays) |
|
|
492
|
+
| `expect(x).toStrictEqual(y)` | Deep equality + type checking |
|
|
493
|
+
| `expect(x).toBeCloseTo(y, n)` | Float comparison (n decimal places) |
|
|
494
|
+
| `expect(x).toBeTruthy()` | Truthy values |
|
|
495
|
+
| `expect(x).toBeFalsy()` | Falsy values |
|
|
496
|
+
| `expect(x).toBeNull()` | Null check |
|
|
497
|
+
| `expect(x).toBeUndefined()` | Undefined check |
|
|
498
|
+
| `expect(x).toBeDefined()` | Defined check |
|
|
499
|
+
| `expect(x).toBeInstanceOf(C)` | Instance of class |
|
|
500
|
+
| `expect(x).toContain(y)` | Array/string contains |
|
|
501
|
+
| `expect(x).toContainEqual(y)` | Array contains object (deep) |
|
|
502
|
+
| `expect(x).toHaveLength(n)` | Array/string length |
|
|
503
|
+
| `expect(x).toHaveProperty(k)` | Object has property |
|
|
504
|
+
| `expect(x).toHaveProperty(k, v)` | Object has property with value |
|
|
505
|
+
| `expect(x).toMatch(/regex/)` | String matches regex |
|
|
506
|
+
| `expect(x).toMatchObject(y)` | Object contains subset |
|
|
507
|
+
| `expect(fn).toThrow()` | Exception thrown |
|
|
508
|
+
| `expect(fn).toThrow('msg')` | Specific exception message |
|
|
509
|
+
| `expect(fn).toThrow(ErrorClass)` | Specific error type |
|
|
510
|
+
| `expect(mock).toHaveBeenCalled()` | Mock was called |
|
|
511
|
+
| `expect(mock).toHaveBeenCalledWith(x)` | Mock called with args |
|
|
512
|
+
| `expect(mock).toHaveBeenCalledTimes(n)` | Call count |
|
|
513
|
+
| `expect(mock).toHaveBeenLastCalledWith(x)` | Last call args |
|
|
514
|
+
| `expect(mock).toHaveReturnedWith(x)` | Mock returned value |
|
|
515
|
+
|
|
516
|
+
### Negation
|
|
517
|
+
|
|
518
|
+
All assertions can be negated with `.not`:
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
expect(value).not.toBe(other)
|
|
522
|
+
expect(array).not.toContain(item)
|
|
523
|
+
expect(fn).not.toThrow()
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Asymmetric Matchers
|
|
527
|
+
|
|
528
|
+
```typescript
|
|
529
|
+
expect(obj).toEqual({
|
|
530
|
+
id: expect.any(String),
|
|
531
|
+
name: 'Test',
|
|
532
|
+
createdAt: expect.any(Date),
|
|
533
|
+
tags: expect.arrayContaining(['important']),
|
|
534
|
+
metadata: expect.objectContaining({ version: 1 }),
|
|
535
|
+
})
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
---
|
|
539
|
+
|
|
540
|
+
## Testing Zod Schemas
|
|
541
|
+
|
|
542
|
+
Validate Zod schemas independently to verify input validation logic:
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
import { describe, it, expect } from '{{testRunner}}'
|
|
546
|
+
import { z } from 'zod'
|
|
547
|
+
|
|
548
|
+
// Import the schema (or define inline if testing a schema module)
|
|
549
|
+
const createItemSchema = z.object({
|
|
550
|
+
name: z.string().min(1).max(255),
|
|
551
|
+
email: z.string().email().optional(),
|
|
552
|
+
phone: z.string().regex(/^\+?[0-9]{10,15}$/).optional(),
|
|
553
|
+
status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).default('DRAFT'),
|
|
554
|
+
description: z.string().max(5000).optional(),
|
|
555
|
+
tags: z.array(z.string()).max(10).optional(),
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
describe('createItemSchema', () => {
|
|
559
|
+
describe('valid inputs', () => {
|
|
560
|
+
it('accepts minimal valid input', () => {
|
|
561
|
+
const result = createItemSchema.safeParse({ name: 'Test Item' })
|
|
562
|
+
expect(result.success).toBe(true)
|
|
563
|
+
if (result.success) {
|
|
564
|
+
expect(result.data.status).toBe('DRAFT') // default applied
|
|
565
|
+
}
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('accepts full valid input', () => {
|
|
569
|
+
const result = createItemSchema.safeParse({
|
|
570
|
+
name: 'Full Item',
|
|
571
|
+
email: 'user@example.com',
|
|
572
|
+
phone: '+15551234567',
|
|
573
|
+
status: 'ACTIVE',
|
|
574
|
+
description: 'A full description',
|
|
575
|
+
tags: ['important', 'urgent'],
|
|
576
|
+
})
|
|
577
|
+
expect(result.success).toBe(true)
|
|
578
|
+
})
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
describe('invalid inputs', () => {
|
|
582
|
+
it('rejects empty name', () => {
|
|
583
|
+
const result = createItemSchema.safeParse({ name: '' })
|
|
584
|
+
expect(result.success).toBe(false)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('rejects name over 255 chars', () => {
|
|
588
|
+
const result = createItemSchema.safeParse({ name: 'x'.repeat(256) })
|
|
589
|
+
expect(result.success).toBe(false)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('rejects invalid email', () => {
|
|
593
|
+
const result = createItemSchema.safeParse({
|
|
594
|
+
name: 'Test',
|
|
595
|
+
email: 'not-an-email',
|
|
596
|
+
})
|
|
597
|
+
expect(result.success).toBe(false)
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('rejects invalid phone format', () => {
|
|
601
|
+
const result = createItemSchema.safeParse({
|
|
602
|
+
name: 'Test',
|
|
603
|
+
phone: '123',
|
|
604
|
+
})
|
|
605
|
+
expect(result.success).toBe(false)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it('rejects invalid status enum', () => {
|
|
609
|
+
const result = createItemSchema.safeParse({
|
|
610
|
+
name: 'Test',
|
|
611
|
+
status: 'INVALID_STATUS',
|
|
612
|
+
})
|
|
613
|
+
expect(result.success).toBe(false)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
it('rejects description over 5000 chars', () => {
|
|
617
|
+
const result = createItemSchema.safeParse({
|
|
618
|
+
name: 'Test',
|
|
619
|
+
description: 'x'.repeat(5001),
|
|
620
|
+
})
|
|
621
|
+
expect(result.success).toBe(false)
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
it('rejects more than 10 tags', () => {
|
|
625
|
+
const result = createItemSchema.safeParse({
|
|
626
|
+
name: 'Test',
|
|
627
|
+
tags: Array.from({ length: 11 }, (_, i) => `tag-${i}`),
|
|
628
|
+
})
|
|
629
|
+
expect(result.success).toBe(false)
|
|
630
|
+
})
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
describe('type coercion and stripping', () => {
|
|
634
|
+
it('strips unknown fields', () => {
|
|
635
|
+
const result = createItemSchema.safeParse({
|
|
636
|
+
name: 'Test',
|
|
637
|
+
unknownField: 'should be removed',
|
|
638
|
+
})
|
|
639
|
+
expect(result.success).toBe(true)
|
|
640
|
+
if (result.success) {
|
|
641
|
+
expect(result.data).not.toHaveProperty('unknownField')
|
|
642
|
+
}
|
|
643
|
+
})
|
|
644
|
+
})
|
|
645
|
+
})
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### Shared Schema Testing Pattern
|
|
649
|
+
|
|
650
|
+
For schemas exported from shared packages:
|
|
651
|
+
|
|
652
|
+
```typescript
|
|
653
|
+
import { describe, it, expect } from '{{testRunner}}'
|
|
654
|
+
import { phoneSchema, emailSchema, urlSchema } from '{{sharedPackage}}'
|
|
655
|
+
|
|
656
|
+
describe('shared validation schemas', () => {
|
|
657
|
+
describe('phoneSchema', () => {
|
|
658
|
+
it.each([
|
|
659
|
+
['+15551234567', true],
|
|
660
|
+
['5551234567', true],
|
|
661
|
+
['+1234', false], // Too short
|
|
662
|
+
['abc', false], // Not numeric
|
|
663
|
+
['', false], // Empty
|
|
664
|
+
])('validates %s -> %s', (input, expected) => {
|
|
665
|
+
expect(phoneSchema.safeParse(input).success).toBe(expected)
|
|
666
|
+
})
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
describe('emailSchema', () => {
|
|
670
|
+
it.each([
|
|
671
|
+
['user@example.com', true],
|
|
672
|
+
['name+tag@domain.co', true],
|
|
673
|
+
['invalid', false],
|
|
674
|
+
['@no-user.com', false],
|
|
675
|
+
['', false],
|
|
676
|
+
])('validates %s -> %s', (input, expected) => {
|
|
677
|
+
expect(emailSchema.safeParse(input).success).toBe(expected)
|
|
678
|
+
})
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
describe('urlSchema', () => {
|
|
682
|
+
it.each([
|
|
683
|
+
['https://example.com', true],
|
|
684
|
+
['http://localhost:3000', true],
|
|
685
|
+
['ftp://invalid.com', false],
|
|
686
|
+
['not-a-url', false],
|
|
687
|
+
])('validates %s -> %s', (input, expected) => {
|
|
688
|
+
expect(urlSchema.safeParse(input).success).toBe(expected)
|
|
689
|
+
})
|
|
690
|
+
})
|
|
691
|
+
})
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
---
|
|
695
|
+
|
|
696
|
+
## Testing Error Handling
|
|
697
|
+
|
|
698
|
+
```typescript
|
|
699
|
+
import { describe, it, expect, vi } from '{{testRunner}}'
|
|
700
|
+
import { processPayment } from '{{srcAlias}}lib/services/payment'
|
|
701
|
+
|
|
702
|
+
describe('processPayment', () => {
|
|
703
|
+
it('throws typed error for insufficient funds', async () => {
|
|
704
|
+
const error = await processPayment({ amount: 99999 }).catch(e => e)
|
|
705
|
+
|
|
706
|
+
expect(error).toBeInstanceOf(PaymentError)
|
|
707
|
+
expect(error.code).toBe('INSUFFICIENT_FUNDS')
|
|
708
|
+
expect(error.message).toContain('insufficient')
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('wraps unexpected errors in a generic PaymentError', async () => {
|
|
712
|
+
vi.spyOn(gateway, 'charge').mockRejectedValue(new Error('connection reset'))
|
|
713
|
+
|
|
714
|
+
const error = await processPayment({ amount: 10 }).catch(e => e)
|
|
715
|
+
|
|
716
|
+
expect(error).toBeInstanceOf(PaymentError)
|
|
717
|
+
expect(error.code).toBe('GATEWAY_ERROR')
|
|
718
|
+
})
|
|
719
|
+
})
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
---
|
|
723
|
+
|
|
724
|
+
## Testing Environment Variables
|
|
725
|
+
|
|
726
|
+
```typescript
|
|
727
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from '{{testRunner}}'
|
|
728
|
+
import { getConfig } from '{{srcAlias}}lib/config'
|
|
729
|
+
|
|
730
|
+
describe('getConfig', () => {
|
|
731
|
+
const originalEnv = process.env
|
|
732
|
+
|
|
733
|
+
beforeEach(() => {
|
|
734
|
+
vi.resetModules()
|
|
735
|
+
process.env = { ...originalEnv }
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
afterEach(() => {
|
|
739
|
+
process.env = originalEnv
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it('reads configuration from env vars', () => {
|
|
743
|
+
process.env.API_URL = 'https://api.example.com'
|
|
744
|
+
process.env.API_KEY = 'test-key-123'
|
|
745
|
+
|
|
746
|
+
const config = getConfig()
|
|
747
|
+
|
|
748
|
+
expect(config.apiUrl).toBe('https://api.example.com')
|
|
749
|
+
expect(config.apiKey).toBe('test-key-123')
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
it('throws if required env var is missing', () => {
|
|
753
|
+
delete process.env.API_URL
|
|
754
|
+
|
|
755
|
+
expect(() => getConfig()).toThrow('API_URL is required')
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('uses default values for optional config', () => {
|
|
759
|
+
process.env.API_URL = 'https://api.example.com'
|
|
760
|
+
process.env.API_KEY = 'test-key'
|
|
761
|
+
|
|
762
|
+
const config = getConfig()
|
|
763
|
+
|
|
764
|
+
expect(config.timeout).toBe(5000) // default
|
|
765
|
+
expect(config.retries).toBe(3) // default
|
|
766
|
+
})
|
|
767
|
+
})
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
---
|
|
771
|
+
|
|
772
|
+
## Testing Data Transformations
|
|
773
|
+
|
|
774
|
+
```typescript
|
|
775
|
+
import { describe, it, expect } from '{{testRunner}}'
|
|
776
|
+
import { transformApiResponse, normalizeUser } from '{{srcAlias}}lib/transforms'
|
|
777
|
+
|
|
778
|
+
describe('transformApiResponse', () => {
|
|
779
|
+
it('maps API snake_case to camelCase', () => {
|
|
780
|
+
const apiData = {
|
|
781
|
+
user_name: 'Jane',
|
|
782
|
+
created_at: '2024-01-15T00:00:00Z',
|
|
783
|
+
is_active: true,
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const result = transformApiResponse(apiData)
|
|
787
|
+
|
|
788
|
+
expect(result).toEqual({
|
|
789
|
+
userName: 'Jane',
|
|
790
|
+
createdAt: '2024-01-15T00:00:00Z',
|
|
791
|
+
isActive: true,
|
|
792
|
+
})
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
it('handles nested objects', () => {
|
|
796
|
+
const apiData = {
|
|
797
|
+
user_profile: {
|
|
798
|
+
first_name: 'Jane',
|
|
799
|
+
last_name: 'Smith',
|
|
800
|
+
},
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const result = transformApiResponse(apiData)
|
|
804
|
+
|
|
805
|
+
expect(result).toEqual({
|
|
806
|
+
userProfile: {
|
|
807
|
+
firstName: 'Jane',
|
|
808
|
+
lastName: 'Smith',
|
|
809
|
+
},
|
|
810
|
+
})
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('handles empty objects', () => {
|
|
814
|
+
expect(transformApiResponse({})).toEqual({})
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('handles null and undefined', () => {
|
|
818
|
+
expect(transformApiResponse(null)).toBeNull()
|
|
819
|
+
expect(transformApiResponse(undefined)).toBeUndefined()
|
|
820
|
+
})
|
|
821
|
+
})
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
---
|
|
825
|
+
|
|
826
|
+
## Testing Background Job Processors
|
|
827
|
+
|
|
828
|
+
For projects using background job frameworks (BullMQ, Temporal, etc.), test processors by mocking the job object and its dependencies:
|
|
829
|
+
|
|
830
|
+
```typescript
|
|
831
|
+
import { describe, it, expect, vi, beforeEach } from '{{testRunner}}'
|
|
832
|
+
|
|
833
|
+
// Mock external services before importing processor
|
|
834
|
+
vi.mock('{{srcAlias}}services/database', () => ({
|
|
835
|
+
db: {
|
|
836
|
+
user: {
|
|
837
|
+
findFirst: vi.fn(),
|
|
838
|
+
update: vi.fn(),
|
|
839
|
+
},
|
|
840
|
+
activityLog: {
|
|
841
|
+
create: vi.fn(),
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
}))
|
|
845
|
+
|
|
846
|
+
vi.mock('{{srcAlias}}services/external-api', () => ({
|
|
847
|
+
enrichData: vi.fn().mockResolvedValue({
|
|
848
|
+
summary: 'Test enrichment summary',
|
|
849
|
+
score: 85,
|
|
850
|
+
}),
|
|
851
|
+
}))
|
|
852
|
+
|
|
853
|
+
import { db } from '{{srcAlias}}services/database'
|
|
854
|
+
import { processEnrichment } from '../processors/enrichment'
|
|
855
|
+
import { enrichData } from '{{srcAlias}}services/external-api'
|
|
856
|
+
|
|
857
|
+
describe('enrichment processor', () => {
|
|
858
|
+
const createMockJob = (data: any) => ({
|
|
859
|
+
id: 'job-123',
|
|
860
|
+
data,
|
|
861
|
+
attemptsMade: 0,
|
|
862
|
+
log: vi.fn(),
|
|
863
|
+
updateProgress: vi.fn(),
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
beforeEach(() => {
|
|
867
|
+
vi.clearAllMocks()
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
it('enriches data and updates record', async () => {
|
|
871
|
+
const mockRecord = { id: 'rec-1', name: 'Acme Corp' }
|
|
872
|
+
vi.mocked(db.user.findFirst).mockResolvedValue(mockRecord as any)
|
|
873
|
+
vi.mocked(db.user.update).mockResolvedValue({ ...mockRecord, enriched: true } as any)
|
|
874
|
+
|
|
875
|
+
const job = createMockJob({ recordId: 'rec-1' })
|
|
876
|
+
await processEnrichment(job as any)
|
|
877
|
+
|
|
878
|
+
expect(db.user.findFirst).toHaveBeenCalledWith({
|
|
879
|
+
where: { id: 'rec-1' },
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
expect(enrichData).toHaveBeenCalledWith(
|
|
883
|
+
expect.objectContaining({ name: 'Acme Corp' })
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
expect(db.user.update).toHaveBeenCalledWith({
|
|
887
|
+
where: { id: 'rec-1' },
|
|
888
|
+
data: expect.objectContaining({ enriched: true }),
|
|
889
|
+
})
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
it('throws if record not found', async () => {
|
|
893
|
+
vi.mocked(db.user.findFirst).mockResolvedValue(null)
|
|
894
|
+
|
|
895
|
+
const job = createMockJob({ recordId: 'nonexistent' })
|
|
896
|
+
|
|
897
|
+
await expect(processEnrichment(job as any)).rejects.toThrow()
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
it('logs progress during processing', async () => {
|
|
901
|
+
vi.mocked(db.user.findFirst).mockResolvedValue({ id: 'rec-1' } as any)
|
|
902
|
+
vi.mocked(db.user.update).mockResolvedValue({} as any)
|
|
903
|
+
|
|
904
|
+
const job = createMockJob({ recordId: 'rec-1' })
|
|
905
|
+
await processEnrichment(job as any)
|
|
906
|
+
|
|
907
|
+
expect(job.updateProgress).toHaveBeenCalled()
|
|
908
|
+
})
|
|
909
|
+
})
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
### Mock Job Factory (Reusable)
|
|
913
|
+
|
|
914
|
+
```typescript
|
|
915
|
+
// test/helpers.ts
|
|
916
|
+
import { vi } from '{{testRunner}}'
|
|
917
|
+
|
|
918
|
+
export function createMockJob<T>(data: T, overrides: Partial<any> = {}) {
|
|
919
|
+
return {
|
|
920
|
+
id: overrides.id ?? `job-${Date.now()}`,
|
|
921
|
+
name: overrides.name ?? 'test-job',
|
|
922
|
+
data,
|
|
923
|
+
attemptsMade: overrides.attemptsMade ?? 0,
|
|
924
|
+
opts: overrides.opts ?? {},
|
|
925
|
+
timestamp: Date.now(),
|
|
926
|
+
log: vi.fn(),
|
|
927
|
+
updateProgress: vi.fn(),
|
|
928
|
+
moveToFailed: vi.fn(),
|
|
929
|
+
moveToCompleted: vi.fn(),
|
|
930
|
+
...overrides,
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
---
|
|
936
|
+
|
|
937
|
+
## Test Coverage
|
|
938
|
+
|
|
939
|
+
Run coverage report:
|
|
940
|
+
|
|
941
|
+
```bash
|
|
942
|
+
{{testCoverageCommand}}
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
Coverage thresholds (if configured):
|
|
946
|
+
- Statements: 80%
|
|
947
|
+
- Branches: 70%
|
|
948
|
+
- Functions: 80%
|
|
949
|
+
- Lines: 80%
|
|
950
|
+
|
|
951
|
+
---
|
|
952
|
+
|
|
953
|
+
## Checklist
|
|
954
|
+
|
|
955
|
+
Before submitting tests:
|
|
956
|
+
|
|
957
|
+
- [ ] Test file follows naming convention
|
|
958
|
+
- [ ] Tests are organized with `describe` blocks
|
|
959
|
+
- [ ] Test names describe expected behavior (not implementation)
|
|
960
|
+
- [ ] Edge cases are covered (null, undefined, empty, boundary values)
|
|
961
|
+
- [ ] Error cases are tested (thrown errors, rejected promises)
|
|
962
|
+
- [ ] Mocks are cleared between tests (`vi.clearAllMocks()` in `beforeEach`)
|
|
963
|
+
- [ ] No hardcoded timeouts (use fake timers)
|
|
964
|
+
- [ ] Tests are deterministic (no random failures, no shared state)
|
|
965
|
+
- [ ] Tests run in isolation (no dependency on test execution order)
|
|
966
|
+
- [ ] Environment is restored after tests (`afterEach` cleanup)
|
|
967
|
+
|
|
968
|
+
---
|
|
969
|
+
|
|
970
|
+
## Running Tests
|
|
971
|
+
|
|
972
|
+
```bash
|
|
973
|
+
# Run all tests
|
|
974
|
+
{{testCommand}}
|
|
975
|
+
|
|
976
|
+
# Run specific test file
|
|
977
|
+
{{testCommand}} utils/currency.test.ts
|
|
978
|
+
|
|
979
|
+
# Run tests in watch mode
|
|
980
|
+
{{testWatchCommand}}
|
|
981
|
+
|
|
982
|
+
# Run with coverage
|
|
983
|
+
{{testCoverageCommand}}
|
|
984
|
+
|
|
985
|
+
# Run tests matching a pattern
|
|
986
|
+
{{testCommand}} --testNamePattern="formatCurrency"
|
|
987
|
+
|
|
988
|
+
# Run tests in a specific directory
|
|
989
|
+
{{testCommand}} __tests__/utils/
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
---
|
|
993
|
+
|
|
994
|
+
## See Also
|
|
995
|
+
|
|
996
|
+
- `component-test.md` - Component testing patterns
|
|
997
|
+
- `e2e-test.md` - End-to-end testing patterns
|
|
998
|
+
- `test-standards.md` - Coverage thresholds and test type requirements
|