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,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