@pageai/ralph-loop 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/.agent/PROMPT.md +58 -0
- package/.agent/STEERING.md +3 -0
- package/.agent/logs/LOG.md +13 -0
- package/.agent/prd/.gitkeep +0 -0
- package/.agent/screenshots/.gitkeep +0 -0
- package/.agent/skills/component-refactoring/SKILL.md +247 -0
- package/.agent/skills/component-refactoring/references/complexity-patterns.md +485 -0
- package/.agent/skills/component-refactoring/references/component-splitting.md +419 -0
- package/.agent/skills/component-refactoring/references/hook-extraction.md +317 -0
- package/.agent/skills/e2e-tester/SKILL.md +595 -0
- package/.agent/skills/frontend-code-review/SKILL.md +73 -0
- package/.agent/skills/frontend-code-review/references/code-quality.md +28 -0
- package/.agent/skills/frontend-code-review/references/performance.md +36 -0
- package/.agent/skills/frontend-testing/SKILL.md +316 -0
- package/.agent/skills/frontend-testing/assets/component-test.template.tsx +293 -0
- package/.agent/skills/frontend-testing/assets/hook-test.template.ts +207 -0
- package/.agent/skills/frontend-testing/assets/utility-test.template.ts +154 -0
- package/.agent/skills/frontend-testing/references/async-testing.md +345 -0
- package/.agent/skills/frontend-testing/references/checklist.md +188 -0
- package/.agent/skills/frontend-testing/references/common-patterns.md +449 -0
- package/.agent/skills/frontend-testing/references/mocking.md +289 -0
- package/.agent/skills/frontend-testing/references/workflow.md +265 -0
- package/.agent/skills/prd-creator/JSON.md +613 -0
- package/.agent/skills/prd-creator/PRD.md +196 -0
- package/.agent/skills/prd-creator/SKILL.md +143 -0
- package/.agent/skills/skill-creator/SKILL.md +355 -0
- package/.agent/skills/skill-creator/references/output-patterns.md +86 -0
- package/.agent/skills/skill-creator/references/workflows.md +28 -0
- package/.agent/skills/skill-creator/scripts/init_skill.py +300 -0
- package/.agent/skills/skill-creator/scripts/package_skill.py +110 -0
- package/.agent/skills/vercel-react-best-practices/AGENTS.md +2249 -0
- package/.agent/skills/vercel-react-best-practices/SKILL.md +125 -0
- package/.agent/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
- package/.agent/skills/vercel-react-best-practices/rules/advanced-use-latest.md +49 -0
- package/.agent/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
- package/.agent/skills/vercel-react-best-practices/rules/async-defer-await.md +80 -0
- package/.agent/skills/vercel-react-best-practices/rules/async-dependencies.md +36 -0
- package/.agent/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
- package/.agent/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
- package/.agent/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +59 -0
- package/.agent/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
- package/.agent/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
- package/.agent/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
- package/.agent/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
- package/.agent/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
- package/.agent/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +82 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
- package/.agent/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
- package/.agent/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
- package/.agent/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
- package/.agent/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
- package/.agent/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
- package/.agent/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
- package/.agent/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
- package/.agent/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
- package/.agent/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
- package/.agent/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
- package/.agent/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
- package/.agent/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
- package/.agent/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
- package/.agent/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
- package/.agent/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
- package/.agent/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
- package/.agent/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
- package/.agent/skills/vercel-react-best-practices/rules/server-cache-react.md +26 -0
- package/.agent/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +79 -0
- package/.agent/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
- package/.agent/skills/vitest-best-practices/AGENTS.md +84 -0
- package/.agent/skills/vitest-best-practices/SKILL.md +130 -0
- package/.agent/skills/vitest-best-practices/references/aaa-pattern.md +260 -0
- package/.agent/skills/vitest-best-practices/references/assertions.md +393 -0
- package/.agent/skills/vitest-best-practices/references/async-testing.md +454 -0
- package/.agent/skills/vitest-best-practices/references/error-handling.md +382 -0
- package/.agent/skills/vitest-best-practices/references/organization.md +212 -0
- package/.agent/skills/vitest-best-practices/references/parameterized-tests.md +297 -0
- package/.agent/skills/vitest-best-practices/references/performance.md +528 -0
- package/.agent/skills/vitest-best-practices/references/snapshot-testing.md +483 -0
- package/.agent/skills/vitest-best-practices/references/test-doubles.md +499 -0
- package/.agent/skills/vitest-best-practices/references/vitest-features.md +529 -0
- package/.agent/skills/web-design-guidelines/SKILL.md +39 -0
- package/.agent/tasks/.gitkeep +0 -0
- package/.agent/tasks.json +1 -0
- package/.claude/agents/code-reviewer.md +172 -0
- package/.claude/commands/aw.md +50 -0
- package/.claude/hooks/play-sound.js +87 -0
- package/.claude/hooks/pre-tool-use.js +40 -0
- package/.claude/settings.json +54 -0
- package/.claude/settings.local.json +13 -0
- package/.mcp.json +31 -0
- package/AGENTS.md +44 -0
- package/CLAUDE.md +1 -0
- package/README.md +236 -0
- package/bin/cli.js +156 -0
- package/bin/lib/copy.js +149 -0
- package/bin/lib/display.js +137 -0
- package/package.json +65 -0
- package/ralph.sh +333 -0
- package/scripts/lib/args.sh +44 -0
- package/scripts/lib/cleanup.sh +53 -0
- package/scripts/lib/constants.sh +25 -0
- package/scripts/lib/display.sh +196 -0
- package/scripts/lib/logging.sh +30 -0
- package/scripts/lib/notify.sh +41 -0
- package/scripts/lib/output.sh +147 -0
- package/scripts/lib/preflight.sh +57 -0
- package/scripts/lib/preview.sh +77 -0
- package/scripts/lib/promise.sh +76 -0
- package/scripts/lib/spinner.sh +85 -0
- package/scripts/lib/terminal.sh +57 -0
- package/scripts/lib/timing.sh +223 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Test Generation Checklist
|
|
2
|
+
|
|
3
|
+
Use this checklist when generating or reviewing tests for frontend components.
|
|
4
|
+
|
|
5
|
+
## Pre-Generation
|
|
6
|
+
|
|
7
|
+
- [ ] Read the component source code completely
|
|
8
|
+
- [ ] Identify component type (component, hook, utility, page)
|
|
9
|
+
- [ ] Analyze component complexity and features
|
|
10
|
+
- [ ] Note complexity score and features detected
|
|
11
|
+
- [ ] Check for existing tests in the same directory
|
|
12
|
+
- [ ] **Identify ALL files in the directory** that need testing (not just index)
|
|
13
|
+
|
|
14
|
+
## Testing Strategy
|
|
15
|
+
|
|
16
|
+
### ⚠️ Incremental Workflow (CRITICAL for Multi-File)
|
|
17
|
+
|
|
18
|
+
- [ ] **NEVER generate all tests at once** - process one file at a time
|
|
19
|
+
- [ ] Order files by complexity: utilities → hooks → simple → complex → integration
|
|
20
|
+
- [ ] Create a todo list to track progress before starting
|
|
21
|
+
- [ ] For EACH file: write → run test → verify pass → then next
|
|
22
|
+
- [ ] **DO NOT proceed** to next file until current one passes
|
|
23
|
+
|
|
24
|
+
### Path-Level Coverage
|
|
25
|
+
|
|
26
|
+
- [ ] **Test ALL files** in the assigned directory/path
|
|
27
|
+
- [ ] List all components, hooks, utilities that need coverage
|
|
28
|
+
- [ ] Decide: single spec file (integration) or multiple spec files (unit)
|
|
29
|
+
|
|
30
|
+
### Complexity Assessment
|
|
31
|
+
|
|
32
|
+
- [ ] **Medium Complexity and higher**: Consider refactoring before testing
|
|
33
|
+
- [ ] **500+ lines**: Consider splitting before testing
|
|
34
|
+
- [ ] **Low complexity**: Use multiple describe blocks, organized structure
|
|
35
|
+
|
|
36
|
+
### Integration vs Mocking
|
|
37
|
+
|
|
38
|
+
- [ ] Only mock: API calls, complex context providers, third-party libs with side effects
|
|
39
|
+
- [ ] Prefer integration testing when using single spec file
|
|
40
|
+
|
|
41
|
+
## Required Test Sections
|
|
42
|
+
|
|
43
|
+
### All Components MUST Have
|
|
44
|
+
|
|
45
|
+
- [ ] **Rendering tests** - Component renders without crashing
|
|
46
|
+
- [ ] **Props tests** - Required props, optional props, default values
|
|
47
|
+
- [ ] **Edge cases** - null, undefined, empty values, boundaries
|
|
48
|
+
|
|
49
|
+
### Conditional Sections (Add When Feature Present)
|
|
50
|
+
|
|
51
|
+
| Feature | Add Tests For |
|
|
52
|
+
| ----------------------- | ------------------------------------- |
|
|
53
|
+
| `useState` | Initial state, transitions, cleanup |
|
|
54
|
+
| `useEffect` | Execution, dependencies, cleanup |
|
|
55
|
+
| Event handlers | onClick, onChange, onSubmit, keyboard |
|
|
56
|
+
| API calls | Loading, success, error states |
|
|
57
|
+
| Routing | Navigation, params, query strings |
|
|
58
|
+
| `useCallback`/`useMemo` | Referential equality |
|
|
59
|
+
| Context | Provider values, consumer behavior |
|
|
60
|
+
| Forms | Validation, submission, error display |
|
|
61
|
+
|
|
62
|
+
## Code Quality Checklist
|
|
63
|
+
|
|
64
|
+
### Structure
|
|
65
|
+
|
|
66
|
+
- [ ] Uses `describe` blocks to group related tests
|
|
67
|
+
- [ ] Test names follow `should <behavior> when <condition>` pattern
|
|
68
|
+
- [ ] AAA pattern (Arrange-Act-Assert) is clear
|
|
69
|
+
- [ ] Comments explain complex test scenarios
|
|
70
|
+
|
|
71
|
+
### Mocks
|
|
72
|
+
|
|
73
|
+
- [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`)
|
|
74
|
+
- [ ] Shared mock state reset in `beforeEach`
|
|
75
|
+
- [ ] Router mocks match actual Next.js API
|
|
76
|
+
- [ ] Mocks reflect actual component conditional behavior
|
|
77
|
+
- [ ] Only mock: API services, complex context providers, third-party libs
|
|
78
|
+
|
|
79
|
+
### Queries
|
|
80
|
+
|
|
81
|
+
- [ ] Prefer semantic queries (`getByRole`, `getByLabelText`)
|
|
82
|
+
- [ ] Use `queryBy*` for absence assertions
|
|
83
|
+
- [ ] Use `findBy*` for async elements
|
|
84
|
+
- [ ] `getByTestId` only as last resort
|
|
85
|
+
|
|
86
|
+
### Async
|
|
87
|
+
|
|
88
|
+
- [ ] All async tests use `async/await`
|
|
89
|
+
- [ ] `waitFor` wraps async assertions
|
|
90
|
+
- [ ] Fake timers properly setup/teardown
|
|
91
|
+
- [ ] No floating promises
|
|
92
|
+
|
|
93
|
+
### TypeScript
|
|
94
|
+
|
|
95
|
+
- [ ] No `any` types without justification
|
|
96
|
+
- [ ] Mock data uses actual types from source
|
|
97
|
+
- [ ] Factory functions have proper return types
|
|
98
|
+
|
|
99
|
+
## Coverage Goals (Per File)
|
|
100
|
+
|
|
101
|
+
For the current file being tested:
|
|
102
|
+
|
|
103
|
+
- [ ] 100% function coverage
|
|
104
|
+
- [ ] 100% statement coverage
|
|
105
|
+
- [ ] >95% branch coverage
|
|
106
|
+
- [ ] >95% line coverage
|
|
107
|
+
|
|
108
|
+
## Post-Generation (Per File)
|
|
109
|
+
|
|
110
|
+
**Run these checks after EACH test file, not just at the end:**
|
|
111
|
+
|
|
112
|
+
- [ ] Run `npm run test-unit path/to/file.spec.tsx` - **MUST PASS before next file**
|
|
113
|
+
- [ ] Fix any failures immediately
|
|
114
|
+
- [ ] Mark file as complete in todo list
|
|
115
|
+
- [ ] Only then proceed to next file
|
|
116
|
+
|
|
117
|
+
### After All Files Complete
|
|
118
|
+
|
|
119
|
+
- [ ] Run full directory test: `npm run test-unit path/to/directory/`
|
|
120
|
+
- [ ] Check coverage report: `npm run test-unit:coverage path/to/directory/`
|
|
121
|
+
- [ ] Run `npm run lint --fix` on all test files
|
|
122
|
+
- [ ] Run `npm run typecheck`
|
|
123
|
+
|
|
124
|
+
## Common Issues to Watch
|
|
125
|
+
|
|
126
|
+
### False Positives
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// ❌ Mock doesn't match actual behavior
|
|
130
|
+
vi.mock('./Component', () => () => <div>Mocked</div>)
|
|
131
|
+
|
|
132
|
+
// ✅ Mock matches actual conditional logic
|
|
133
|
+
vi.mock('./Component', () => ({ isOpen }: any) =>
|
|
134
|
+
isOpen ? <div>Content</div> : null
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### State Leakage
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// ❌ Shared state not reset
|
|
142
|
+
let mockState = false
|
|
143
|
+
vi.mock('./useHook', () => () => mockState)
|
|
144
|
+
|
|
145
|
+
// ✅ Reset in beforeEach
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
mockState = false
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Async Race Conditions
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
// ❌ Not awaited
|
|
155
|
+
it('loads data', () => {
|
|
156
|
+
render(<Component />)
|
|
157
|
+
expect(screen.getByText('Data')).toBeInTheDocument()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ✅ Properly awaited
|
|
161
|
+
it('loads data', async () => {
|
|
162
|
+
render(<Component />)
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(screen.getByText('Data')).toBeInTheDocument()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Missing Edge Cases
|
|
170
|
+
|
|
171
|
+
Always test these scenarios:
|
|
172
|
+
|
|
173
|
+
- `null` / `undefined` inputs
|
|
174
|
+
- Empty strings / arrays / objects
|
|
175
|
+
- Boundary values (0, -1, MAX_INT)
|
|
176
|
+
- Error states
|
|
177
|
+
- Loading states
|
|
178
|
+
- Disabled states
|
|
179
|
+
|
|
180
|
+
## Quick Commands
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# Run specific test
|
|
184
|
+
npm run test-unit path/to/file.spec.tsx
|
|
185
|
+
|
|
186
|
+
# Run with coverage
|
|
187
|
+
npm run test-unit:coverage path/to/file.spec.tsx
|
|
188
|
+
```
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
# Common Testing Patterns
|
|
2
|
+
|
|
3
|
+
## Query Priority
|
|
4
|
+
|
|
5
|
+
Use queries in this order (most to least preferred):
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// 1. getByRole - Most recommended (accessibility)
|
|
9
|
+
screen.getByRole('button', { name: /submit/i })
|
|
10
|
+
screen.getByRole('textbox', { name: /email/i })
|
|
11
|
+
screen.getByRole('heading', { level: 1 })
|
|
12
|
+
|
|
13
|
+
// 2. getByLabelText - Form fields
|
|
14
|
+
screen.getByLabelText('Email address')
|
|
15
|
+
screen.getByLabelText(/password/i)
|
|
16
|
+
|
|
17
|
+
// 3. getByPlaceholderText - When no label
|
|
18
|
+
screen.getByPlaceholderText('Search...')
|
|
19
|
+
|
|
20
|
+
// 4. getByText - Non-interactive elements
|
|
21
|
+
screen.getByText('Welcome')
|
|
22
|
+
screen.getByText(/loading/i)
|
|
23
|
+
|
|
24
|
+
// 5. getByDisplayValue - Current input value
|
|
25
|
+
screen.getByDisplayValue('current value')
|
|
26
|
+
|
|
27
|
+
// 6. getByAltText - Images
|
|
28
|
+
screen.getByAltText('Company logo')
|
|
29
|
+
|
|
30
|
+
// 7. getByTitle - Tooltip elements
|
|
31
|
+
screen.getByTitle('Close')
|
|
32
|
+
|
|
33
|
+
// 8. getByTestId - Last resort only!
|
|
34
|
+
screen.getByTestId('custom-element')
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Event Handling Patterns
|
|
38
|
+
|
|
39
|
+
### Click Events
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// Basic click
|
|
43
|
+
fireEvent.click(screen.getByRole('button'))
|
|
44
|
+
|
|
45
|
+
// With userEvent (preferred for realistic interaction)
|
|
46
|
+
const user = userEvent.setup()
|
|
47
|
+
await user.click(screen.getByRole('button'))
|
|
48
|
+
|
|
49
|
+
// Double click
|
|
50
|
+
await user.dblClick(screen.getByRole('button'))
|
|
51
|
+
|
|
52
|
+
// Right click
|
|
53
|
+
await user.pointer({ keys: '[MouseRight]', target: screen.getByRole('button') })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Form Input
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
const user = userEvent.setup()
|
|
60
|
+
|
|
61
|
+
// Type in input
|
|
62
|
+
await user.type(screen.getByRole('textbox'), 'Hello World')
|
|
63
|
+
|
|
64
|
+
// Clear and type
|
|
65
|
+
await user.clear(screen.getByRole('textbox'))
|
|
66
|
+
await user.type(screen.getByRole('textbox'), 'New value')
|
|
67
|
+
|
|
68
|
+
// Select option
|
|
69
|
+
await user.selectOptions(screen.getByRole('combobox'), 'option-value')
|
|
70
|
+
|
|
71
|
+
// Check checkbox
|
|
72
|
+
await user.click(screen.getByRole('checkbox'))
|
|
73
|
+
|
|
74
|
+
// Upload file
|
|
75
|
+
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
|
|
76
|
+
await user.upload(screen.getByLabelText(/upload/i), file)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Keyboard Events
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
const user = userEvent.setup()
|
|
83
|
+
|
|
84
|
+
// Press Enter
|
|
85
|
+
await user.keyboard('{Enter}')
|
|
86
|
+
|
|
87
|
+
// Press Escape
|
|
88
|
+
await user.keyboard('{Escape}')
|
|
89
|
+
|
|
90
|
+
// Keyboard shortcut
|
|
91
|
+
await user.keyboard('{Control>}a{/Control}') // Ctrl+A
|
|
92
|
+
|
|
93
|
+
// Tab navigation
|
|
94
|
+
await user.tab()
|
|
95
|
+
|
|
96
|
+
// Arrow keys
|
|
97
|
+
await user.keyboard('{ArrowDown}')
|
|
98
|
+
await user.keyboard('{ArrowUp}')
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Component State Testing
|
|
102
|
+
|
|
103
|
+
### Testing State Transitions
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
describe('Counter', () => {
|
|
107
|
+
it('should increment count', async () => {
|
|
108
|
+
const user = userEvent.setup()
|
|
109
|
+
render(<Counter initialCount={0} />)
|
|
110
|
+
|
|
111
|
+
// Initial state
|
|
112
|
+
expect(screen.getByText('Count: 0')).toBeInTheDocument()
|
|
113
|
+
|
|
114
|
+
// Trigger transition
|
|
115
|
+
await user.click(screen.getByRole('button', { name: /increment/i }))
|
|
116
|
+
|
|
117
|
+
// New state
|
|
118
|
+
expect(screen.getByText('Count: 1')).toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Testing Controlled Components
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
describe('ControlledInput', () => {
|
|
127
|
+
it('should call onChange with new value', async () => {
|
|
128
|
+
const user = userEvent.setup()
|
|
129
|
+
const handleChange = vi.fn()
|
|
130
|
+
|
|
131
|
+
render(<ControlledInput value="" onChange={handleChange} />)
|
|
132
|
+
|
|
133
|
+
await user.type(screen.getByRole('textbox'), 'a')
|
|
134
|
+
|
|
135
|
+
expect(handleChange).toHaveBeenCalledWith('a')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('should display controlled value', () => {
|
|
139
|
+
render(<ControlledInput value="controlled" onChange={vi.fn()} />)
|
|
140
|
+
|
|
141
|
+
expect(screen.getByRole('textbox')).toHaveValue('controlled')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Conditional Rendering Testing
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
describe('ConditionalComponent', () => {
|
|
150
|
+
it('should show loading state', () => {
|
|
151
|
+
render(<DataDisplay isLoading={true} data={null} />)
|
|
152
|
+
|
|
153
|
+
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
|
154
|
+
expect(screen.queryByTestId('data-content')).not.toBeInTheDocument()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should show error state', () => {
|
|
158
|
+
render(<DataDisplay isLoading={false} data={null} error="Failed to load" />)
|
|
159
|
+
|
|
160
|
+
expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should show data when loaded', () => {
|
|
164
|
+
render(<DataDisplay isLoading={false} data={{ name: 'Test' }} />)
|
|
165
|
+
|
|
166
|
+
expect(screen.getByText('Test')).toBeInTheDocument()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should show empty state when no data', () => {
|
|
170
|
+
render(<DataDisplay isLoading={false} data={[]} />)
|
|
171
|
+
|
|
172
|
+
expect(screen.getByText(/no data/i)).toBeInTheDocument()
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## List Rendering Testing
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
describe('ItemList', () => {
|
|
181
|
+
const items = [
|
|
182
|
+
{ id: '1', name: 'Item 1' },
|
|
183
|
+
{ id: '2', name: 'Item 2' },
|
|
184
|
+
{ id: '3', name: 'Item 3' },
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
it('should render all items', () => {
|
|
188
|
+
render(<ItemList items={items} />)
|
|
189
|
+
|
|
190
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
|
191
|
+
items.forEach(item => {
|
|
192
|
+
expect(screen.getByText(item.name)).toBeInTheDocument()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('should handle item selection', async () => {
|
|
197
|
+
const user = userEvent.setup()
|
|
198
|
+
const onSelect = vi.fn()
|
|
199
|
+
|
|
200
|
+
render(<ItemList items={items} onSelect={onSelect} />)
|
|
201
|
+
|
|
202
|
+
await user.click(screen.getByText('Item 2'))
|
|
203
|
+
|
|
204
|
+
expect(onSelect).toHaveBeenCalledWith(items[1])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('should handle empty list', () => {
|
|
208
|
+
render(<ItemList items={[]} />)
|
|
209
|
+
|
|
210
|
+
expect(screen.getByText(/no items/i)).toBeInTheDocument()
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Modal/Dialog Testing
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
describe('Modal', () => {
|
|
219
|
+
it('should not render when closed', () => {
|
|
220
|
+
render(<Modal isOpen={false} onClose={vi.fn()} />)
|
|
221
|
+
|
|
222
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('should render when open', () => {
|
|
226
|
+
render(<Modal isOpen={true} onClose={vi.fn()} />)
|
|
227
|
+
|
|
228
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('should call onClose when clicking overlay', async () => {
|
|
232
|
+
const user = userEvent.setup()
|
|
233
|
+
const handleClose = vi.fn()
|
|
234
|
+
|
|
235
|
+
render(<Modal isOpen={true} onClose={handleClose} />)
|
|
236
|
+
|
|
237
|
+
await user.click(screen.getByTestId('modal-overlay'))
|
|
238
|
+
|
|
239
|
+
expect(handleClose).toHaveBeenCalled()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('should call onClose when pressing Escape', async () => {
|
|
243
|
+
const user = userEvent.setup()
|
|
244
|
+
const handleClose = vi.fn()
|
|
245
|
+
|
|
246
|
+
render(<Modal isOpen={true} onClose={handleClose} />)
|
|
247
|
+
|
|
248
|
+
await user.keyboard('{Escape}')
|
|
249
|
+
|
|
250
|
+
expect(handleClose).toHaveBeenCalled()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('should trap focus inside modal', async () => {
|
|
254
|
+
const user = userEvent.setup()
|
|
255
|
+
|
|
256
|
+
render(
|
|
257
|
+
<Modal isOpen={true} onClose={vi.fn()}>
|
|
258
|
+
<button>First</button>
|
|
259
|
+
<button>Second</button>
|
|
260
|
+
</Modal>
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
// Focus should cycle within modal
|
|
264
|
+
await user.tab()
|
|
265
|
+
expect(screen.getByText('First')).toHaveFocus()
|
|
266
|
+
|
|
267
|
+
await user.tab()
|
|
268
|
+
expect(screen.getByText('Second')).toHaveFocus()
|
|
269
|
+
|
|
270
|
+
await user.tab()
|
|
271
|
+
expect(screen.getByText('First')).toHaveFocus() // Cycles back
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Form Testing
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
describe('LoginForm', () => {
|
|
280
|
+
it('should submit valid form', async () => {
|
|
281
|
+
const user = userEvent.setup()
|
|
282
|
+
const onSubmit = vi.fn()
|
|
283
|
+
|
|
284
|
+
render(<LoginForm onSubmit={onSubmit} />)
|
|
285
|
+
|
|
286
|
+
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
|
287
|
+
await user.type(screen.getByLabelText(/password/i), 'password123')
|
|
288
|
+
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
|
289
|
+
|
|
290
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
291
|
+
email: 'test@example.com',
|
|
292
|
+
password: 'password123',
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('should show validation errors', async () => {
|
|
297
|
+
const user = userEvent.setup()
|
|
298
|
+
|
|
299
|
+
render(<LoginForm onSubmit={vi.fn()} />)
|
|
300
|
+
|
|
301
|
+
// Submit empty form
|
|
302
|
+
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
|
303
|
+
|
|
304
|
+
expect(screen.getByText(/email is required/i)).toBeInTheDocument()
|
|
305
|
+
expect(screen.getByText(/password is required/i)).toBeInTheDocument()
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('should validate email format', async () => {
|
|
309
|
+
const user = userEvent.setup()
|
|
310
|
+
|
|
311
|
+
render(<LoginForm onSubmit={vi.fn()} />)
|
|
312
|
+
|
|
313
|
+
await user.type(screen.getByLabelText(/email/i), 'invalid-email')
|
|
314
|
+
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
|
315
|
+
|
|
316
|
+
expect(screen.getByText(/invalid email/i)).toBeInTheDocument()
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('should disable submit button while submitting', async () => {
|
|
320
|
+
const user = userEvent.setup()
|
|
321
|
+
const onSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)))
|
|
322
|
+
|
|
323
|
+
render(<LoginForm onSubmit={onSubmit} />)
|
|
324
|
+
|
|
325
|
+
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
|
|
326
|
+
await user.type(screen.getByLabelText(/password/i), 'password123')
|
|
327
|
+
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
|
328
|
+
|
|
329
|
+
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled()
|
|
330
|
+
|
|
331
|
+
await waitFor(() => {
|
|
332
|
+
expect(screen.getByRole('button', { name: /sign in/i })).toBeEnabled()
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Data-Driven Tests with test.each
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
describe('StatusBadge', () => {
|
|
342
|
+
test.each([
|
|
343
|
+
['success', 'bg-green-500'],
|
|
344
|
+
['warning', 'bg-yellow-500'],
|
|
345
|
+
['error', 'bg-red-500'],
|
|
346
|
+
['info', 'bg-blue-500'],
|
|
347
|
+
])('should apply correct class for %s status', (status, expectedClass) => {
|
|
348
|
+
render(<StatusBadge status={status} />)
|
|
349
|
+
|
|
350
|
+
expect(screen.getByTestId('status-badge')).toHaveClass(expectedClass)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test.each([
|
|
354
|
+
{ input: null, expected: 'Unknown' },
|
|
355
|
+
{ input: undefined, expected: 'Unknown' },
|
|
356
|
+
{ input: '', expected: 'Unknown' },
|
|
357
|
+
{ input: 'invalid', expected: 'Unknown' },
|
|
358
|
+
])('should show "Unknown" for invalid input: $input', ({ input, expected }) => {
|
|
359
|
+
render(<StatusBadge status={input} />)
|
|
360
|
+
|
|
361
|
+
expect(screen.getByText(expected)).toBeInTheDocument()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## Debugging Tips
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
// Print entire DOM
|
|
370
|
+
screen.debug()
|
|
371
|
+
|
|
372
|
+
// Print specific element
|
|
373
|
+
screen.debug(screen.getByRole('button'))
|
|
374
|
+
|
|
375
|
+
// Log testing playground URL
|
|
376
|
+
screen.logTestingPlaygroundURL()
|
|
377
|
+
|
|
378
|
+
// Pretty print DOM
|
|
379
|
+
import { prettyDOM } from '@testing-library/react'
|
|
380
|
+
console.log(prettyDOM(screen.getByRole('dialog')))
|
|
381
|
+
|
|
382
|
+
// Check available roles
|
|
383
|
+
import { getRoles } from '@testing-library/react'
|
|
384
|
+
console.log(getRoles(container))
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Common Mistakes to Avoid
|
|
388
|
+
|
|
389
|
+
### ❌ Don't Use Implementation Details
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
// Bad - testing implementation
|
|
393
|
+
expect(component.state.isOpen).toBe(true)
|
|
394
|
+
expect(wrapper.find('.internal-class').length).toBe(1)
|
|
395
|
+
|
|
396
|
+
// Good - testing behavior
|
|
397
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### ❌ Don't Forget Cleanup
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
// Bad - may leak state between tests
|
|
404
|
+
it('test 1', () => {
|
|
405
|
+
render(<Component />)
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// Good - cleanup is automatic with RTL, but reset mocks
|
|
409
|
+
beforeEach(() => {
|
|
410
|
+
vi.clearAllMocks()
|
|
411
|
+
})
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### ❌ Don't Use Exact String Matching (Prefer Black-Box Assertions)
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
// ❌ Bad - hardcoded strings are brittle
|
|
418
|
+
expect(screen.getByText('Submit Form')).toBeInTheDocument()
|
|
419
|
+
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
420
|
+
|
|
421
|
+
// ✅ Good - role-based queries (most semantic)
|
|
422
|
+
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument()
|
|
423
|
+
expect(screen.getByRole('status')).toBeInTheDocument()
|
|
424
|
+
|
|
425
|
+
// ✅ Good - pattern matching (flexible)
|
|
426
|
+
expect(screen.getByText(/submit/i)).toBeInTheDocument()
|
|
427
|
+
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
|
428
|
+
|
|
429
|
+
// ✅ Good - test behavior, not exact UI text
|
|
430
|
+
expect(screen.getByRole('button')).toBeDisabled()
|
|
431
|
+
expect(screen.getByRole('alert')).toBeInTheDocument()
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**Why prefer black-box assertions?**
|
|
435
|
+
|
|
436
|
+
- Text content may change (i18n, copy updates)
|
|
437
|
+
- Role-based queries test accessibility
|
|
438
|
+
- Pattern matching is resilient to minor changes
|
|
439
|
+
- Tests focus on behavior, not implementation details
|
|
440
|
+
|
|
441
|
+
### ❌ Don't Assert on Absence Without Query
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
// Bad - throws if not found
|
|
445
|
+
expect(screen.getByText('Error')).not.toBeInTheDocument() // Error!
|
|
446
|
+
|
|
447
|
+
// Good - use queryBy for absence assertions
|
|
448
|
+
expect(screen.queryByText('Error')).not.toBeInTheDocument()
|
|
449
|
+
```
|