red64-cli 0.1.0 → 0.3.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 +1 -2
- package/dist/cli/parseArgs.d.ts.map +1 -1
- package/dist/cli/parseArgs.js +5 -0
- package/dist/cli/parseArgs.js.map +1 -1
- package/dist/components/init/CompleteStep.d.ts.map +1 -1
- package/dist/components/init/CompleteStep.js +2 -2
- package/dist/components/init/CompleteStep.js.map +1 -1
- package/dist/components/init/TestCheckStep.d.ts +16 -0
- package/dist/components/init/TestCheckStep.d.ts.map +1 -0
- package/dist/components/init/TestCheckStep.js +120 -0
- package/dist/components/init/TestCheckStep.js.map +1 -0
- package/dist/components/init/index.d.ts +1 -0
- package/dist/components/init/index.d.ts.map +1 -1
- package/dist/components/init/index.js +1 -0
- package/dist/components/init/index.js.map +1 -1
- package/dist/components/init/types.d.ts +9 -0
- package/dist/components/init/types.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.js +69 -6
- package/dist/components/screens/InitScreen.js.map +1 -1
- package/dist/components/screens/ListScreen.d.ts.map +1 -1
- package/dist/components/screens/ListScreen.js +28 -3
- package/dist/components/screens/ListScreen.js.map +1 -1
- package/dist/components/screens/StartScreen.d.ts.map +1 -1
- package/dist/components/screens/StartScreen.js +212 -13
- package/dist/components/screens/StartScreen.js.map +1 -1
- package/dist/components/ui/ArtifactsSidebar.d.ts +19 -0
- package/dist/components/ui/ArtifactsSidebar.d.ts.map +1 -0
- package/dist/components/ui/ArtifactsSidebar.js +51 -0
- package/dist/components/ui/ArtifactsSidebar.js.map +1 -0
- package/dist/components/ui/FeatureSidebar.d.ts.map +1 -1
- package/dist/components/ui/FeatureSidebar.js +1 -1
- package/dist/components/ui/FeatureSidebar.js.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/services/ClaudeErrorDetector.js +3 -3
- package/dist/services/ClaudeErrorDetector.js.map +1 -1
- package/dist/services/ConfigService.d.ts +1 -0
- package/dist/services/ConfigService.d.ts.map +1 -1
- package/dist/services/ConfigService.js.map +1 -1
- package/dist/services/ProjectDetector.d.ts +28 -0
- package/dist/services/ProjectDetector.d.ts.map +1 -0
- package/dist/services/ProjectDetector.js +236 -0
- package/dist/services/ProjectDetector.js.map +1 -0
- package/dist/services/TestRunner.d.ts +46 -0
- package/dist/services/TestRunner.d.ts.map +1 -0
- package/dist/services/TestRunner.js +85 -0
- package/dist/services/TestRunner.js.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -0
- package/dist/services/index.js.map +1 -1
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/framework/.red64/settings/templates/specs/gap-analysis.md +163 -0
- package/framework/agents/claude/.claude/agents/red64/spec-impl.md +131 -2
- package/framework/agents/claude/.claude/agents/red64/validate-gap.md +13 -7
- package/framework/agents/claude/.claude/commands/red64/spec-impl.md +24 -0
- package/framework/agents/claude/.claude/commands/red64/validate-gap.md +4 -0
- package/framework/agents/codex/.codex/agents/red64/spec-impl.md +131 -2
- package/framework/agents/codex/.codex/agents/red64/validate-gap.md +13 -7
- package/framework/agents/codex/.codex/commands/red64/spec-impl.md +24 -0
- package/framework/agents/codex/.codex/commands/red64/validate-gap.md +4 -0
- package/framework/stacks/generic/feedback.md +80 -0
- package/framework/stacks/nextjs/accessibility.md +437 -0
- package/framework/stacks/nextjs/api.md +431 -0
- package/framework/stacks/nextjs/coding-style.md +282 -0
- package/framework/stacks/nextjs/commenting.md +226 -0
- package/framework/stacks/nextjs/components.md +411 -0
- package/framework/stacks/nextjs/conventions.md +333 -0
- package/framework/stacks/nextjs/css.md +310 -0
- package/framework/stacks/nextjs/error-handling.md +442 -0
- package/framework/stacks/nextjs/feedback.md +124 -0
- package/framework/stacks/nextjs/migrations.md +332 -0
- package/framework/stacks/nextjs/models.md +362 -0
- package/framework/stacks/nextjs/queries.md +410 -0
- package/framework/stacks/nextjs/responsive.md +338 -0
- package/framework/stacks/nextjs/tech-stack.md +177 -0
- package/framework/stacks/nextjs/test-writing.md +475 -0
- package/framework/stacks/nextjs/validation.md +467 -0
- package/framework/stacks/python/api.md +468 -0
- package/framework/stacks/python/authentication.md +342 -0
- package/framework/stacks/python/code-quality.md +283 -0
- package/framework/stacks/python/code-refactoring.md +315 -0
- package/framework/stacks/python/coding-style.md +462 -0
- package/framework/stacks/python/conventions.md +399 -0
- package/framework/stacks/python/error-handling.md +512 -0
- package/framework/stacks/python/feedback.md +92 -0
- package/framework/stacks/python/implement-ai-llm.md +468 -0
- package/framework/stacks/python/migrations.md +388 -0
- package/framework/stacks/python/models.md +399 -0
- package/framework/stacks/python/python.md +232 -0
- package/framework/stacks/python/queries.md +451 -0
- package/framework/stacks/python/structure.md +245 -58
- package/framework/stacks/python/tech.md +92 -35
- package/framework/stacks/python/testing.md +380 -0
- package/framework/stacks/python/validation.md +471 -0
- package/framework/stacks/rails/authentication.md +176 -0
- package/framework/stacks/rails/code-quality.md +287 -0
- package/framework/stacks/rails/code-refactoring.md +299 -0
- package/framework/stacks/rails/feedback.md +130 -0
- package/framework/stacks/rails/implement-ai-llm-with-rubyllm.md +342 -0
- package/framework/stacks/rails/rails.md +301 -0
- package/framework/stacks/rails/rails8-best-practices.md +498 -0
- package/framework/stacks/rails/rails8-css.md +573 -0
- package/framework/stacks/rails/structure.md +140 -0
- package/framework/stacks/rails/tech.md +108 -0
- package/framework/stacks/react/code-quality.md +521 -0
- package/framework/stacks/react/components.md +625 -0
- package/framework/stacks/react/data-fetching.md +586 -0
- package/framework/stacks/react/feedback.md +110 -0
- package/framework/stacks/react/forms.md +694 -0
- package/framework/stacks/react/performance.md +640 -0
- package/framework/stacks/react/product.md +22 -9
- package/framework/stacks/react/state-management.md +472 -0
- package/framework/stacks/react/structure.md +351 -44
- package/framework/stacks/react/tech.md +219 -30
- package/framework/stacks/react/testing.md +690 -0
- package/package.json +1 -1
- package/framework/stacks/node/product.md +0 -27
- package/framework/stacks/node/structure.md +0 -82
- package/framework/stacks/node/tech.md +0 -63
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
# Testing Patterns
|
|
2
|
+
|
|
3
|
+
Comprehensive testing patterns for React applications using Vitest, React Testing Library, MSW, and Playwright.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **Test user behavior, not implementation**: Test what users see and do, not internal state
|
|
10
|
+
- **Fast feedback**: Unit tests run in milliseconds; use Vitest's native ESM support
|
|
11
|
+
- **Realistic integration**: MSW intercepts at the network level for true integration tests
|
|
12
|
+
- **Confidence over coverage**: Focus on critical paths; 100% coverage is not the goal
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Test Organization
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
src/
|
|
20
|
+
├── features/
|
|
21
|
+
│ └── users/
|
|
22
|
+
│ ├── components/
|
|
23
|
+
│ │ ├── UserList.tsx
|
|
24
|
+
│ │ └── UserList.test.tsx # Co-located component tests
|
|
25
|
+
│ └── hooks/
|
|
26
|
+
│ ├── useUsers.ts
|
|
27
|
+
│ └── useUsers.test.ts # Co-located hook tests
|
|
28
|
+
├── utils/
|
|
29
|
+
│ ├── formatters.ts
|
|
30
|
+
│ └── formatters.test.ts # Co-located utility tests
|
|
31
|
+
└── test/
|
|
32
|
+
├── setup.ts # Global test setup
|
|
33
|
+
├── test-utils.tsx # Custom render, providers
|
|
34
|
+
└── mocks/
|
|
35
|
+
├── handlers.ts # MSW request handlers
|
|
36
|
+
└── server.ts # MSW server setup
|
|
37
|
+
|
|
38
|
+
tests/ # E2E tests at project root
|
|
39
|
+
├── e2e/
|
|
40
|
+
│ ├── auth.spec.ts
|
|
41
|
+
│ └── users.spec.ts
|
|
42
|
+
└── playwright.config.ts
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Vitest Configuration
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// vitest.config.ts
|
|
51
|
+
import { defineConfig } from 'vitest/config';
|
|
52
|
+
import react from '@vitejs/plugin-react';
|
|
53
|
+
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
54
|
+
|
|
55
|
+
export default defineConfig({
|
|
56
|
+
plugins: [react(), tsconfigPaths()],
|
|
57
|
+
test: {
|
|
58
|
+
globals: true,
|
|
59
|
+
environment: 'jsdom',
|
|
60
|
+
setupFiles: ['./src/test/setup.ts'],
|
|
61
|
+
include: ['src/**/*.test.{ts,tsx}'],
|
|
62
|
+
coverage: {
|
|
63
|
+
provider: 'v8',
|
|
64
|
+
reporter: ['text', 'html', 'lcov'],
|
|
65
|
+
include: ['src/**/*.{ts,tsx}'],
|
|
66
|
+
exclude: [
|
|
67
|
+
'src/**/*.test.{ts,tsx}',
|
|
68
|
+
'src/test/**',
|
|
69
|
+
'src/**/*.d.ts',
|
|
70
|
+
'src/main.tsx',
|
|
71
|
+
],
|
|
72
|
+
thresholds: {
|
|
73
|
+
lines: 80,
|
|
74
|
+
branches: 80,
|
|
75
|
+
functions: 80,
|
|
76
|
+
statements: 80,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
// Faster test runs
|
|
80
|
+
pool: 'forks',
|
|
81
|
+
poolOptions: {
|
|
82
|
+
forks: {
|
|
83
|
+
singleFork: true,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Test Setup
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
// src/test/setup.ts
|
|
96
|
+
import '@testing-library/jest-dom/vitest';
|
|
97
|
+
import { cleanup } from '@testing-library/react';
|
|
98
|
+
import { afterEach, beforeAll, afterAll } from 'vitest';
|
|
99
|
+
import { server } from './mocks/server';
|
|
100
|
+
|
|
101
|
+
// Start MSW server before all tests
|
|
102
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
103
|
+
|
|
104
|
+
// Reset handlers and cleanup after each test
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
cleanup();
|
|
107
|
+
server.resetHandlers();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Close server after all tests
|
|
111
|
+
afterAll(() => server.close());
|
|
112
|
+
|
|
113
|
+
// Mock window.matchMedia
|
|
114
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
115
|
+
writable: true,
|
|
116
|
+
value: vi.fn().mockImplementation((query: string) => ({
|
|
117
|
+
matches: false,
|
|
118
|
+
media: query,
|
|
119
|
+
onchange: null,
|
|
120
|
+
addListener: vi.fn(),
|
|
121
|
+
removeListener: vi.fn(),
|
|
122
|
+
addEventListener: vi.fn(),
|
|
123
|
+
removeEventListener: vi.fn(),
|
|
124
|
+
dispatchEvent: vi.fn(),
|
|
125
|
+
})),
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Custom Render with Providers
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
// src/test/test-utils.tsx
|
|
135
|
+
import { render, type RenderOptions } from '@testing-library/react';
|
|
136
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
137
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
138
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
139
|
+
|
|
140
|
+
// Create a fresh QueryClient for each test
|
|
141
|
+
function createTestQueryClient() {
|
|
142
|
+
return new QueryClient({
|
|
143
|
+
defaultOptions: {
|
|
144
|
+
queries: {
|
|
145
|
+
retry: false,
|
|
146
|
+
gcTime: 0,
|
|
147
|
+
staleTime: 0,
|
|
148
|
+
},
|
|
149
|
+
mutations: {
|
|
150
|
+
retry: false,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
logger: {
|
|
154
|
+
log: console.log,
|
|
155
|
+
warn: console.warn,
|
|
156
|
+
error: () => {}, // Suppress error logs in tests
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface WrapperProps {
|
|
162
|
+
children: ReactNode;
|
|
163
|
+
initialEntries?: string[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function AllProviders({ children, initialEntries = ['/'] }: WrapperProps) {
|
|
167
|
+
const queryClient = createTestQueryClient();
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<QueryClientProvider client={queryClient}>
|
|
171
|
+
<MemoryRouter initialEntries={initialEntries}>
|
|
172
|
+
{children}
|
|
173
|
+
</MemoryRouter>
|
|
174
|
+
</QueryClientProvider>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
|
179
|
+
initialEntries?: string[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function customRender(ui: ReactElement, options: CustomRenderOptions = {}) {
|
|
183
|
+
const { initialEntries, ...renderOptions } = options;
|
|
184
|
+
|
|
185
|
+
return render(ui, {
|
|
186
|
+
wrapper: ({ children }) => (
|
|
187
|
+
<AllProviders initialEntries={initialEntries}>{children}</AllProviders>
|
|
188
|
+
),
|
|
189
|
+
...renderOptions,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Re-export everything from RTL
|
|
194
|
+
export * from '@testing-library/react';
|
|
195
|
+
export { customRender as render };
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## MSW Setup (Mock Service Worker)
|
|
201
|
+
|
|
202
|
+
### Server Configuration
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// src/test/mocks/server.ts
|
|
206
|
+
import { setupServer } from 'msw/node';
|
|
207
|
+
import { handlers } from './handlers';
|
|
208
|
+
|
|
209
|
+
export const server = setupServer(...handlers);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Request Handlers
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
// src/test/mocks/handlers.ts
|
|
216
|
+
import { http, HttpResponse } from 'msw';
|
|
217
|
+
|
|
218
|
+
const API_URL = 'http://localhost:3000/api';
|
|
219
|
+
|
|
220
|
+
export const handlers = [
|
|
221
|
+
// GET /api/users
|
|
222
|
+
http.get(`${API_URL}/users`, () => {
|
|
223
|
+
return HttpResponse.json({
|
|
224
|
+
items: [
|
|
225
|
+
{ id: 1, name: 'John Doe', email: 'john@example.com' },
|
|
226
|
+
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
|
|
227
|
+
],
|
|
228
|
+
total: 2,
|
|
229
|
+
page: 1,
|
|
230
|
+
perPage: 20,
|
|
231
|
+
});
|
|
232
|
+
}),
|
|
233
|
+
|
|
234
|
+
// GET /api/users/:id
|
|
235
|
+
http.get(`${API_URL}/users/:id`, ({ params }) => {
|
|
236
|
+
const { id } = params;
|
|
237
|
+
return HttpResponse.json({
|
|
238
|
+
id: Number(id),
|
|
239
|
+
name: 'John Doe',
|
|
240
|
+
email: 'john@example.com',
|
|
241
|
+
});
|
|
242
|
+
}),
|
|
243
|
+
|
|
244
|
+
// POST /api/users
|
|
245
|
+
http.post(`${API_URL}/users`, async ({ request }) => {
|
|
246
|
+
const body = await request.json();
|
|
247
|
+
return HttpResponse.json(
|
|
248
|
+
{ id: 3, ...body },
|
|
249
|
+
{ status: 201 }
|
|
250
|
+
);
|
|
251
|
+
}),
|
|
252
|
+
|
|
253
|
+
// DELETE /api/users/:id
|
|
254
|
+
http.delete(`${API_URL}/users/:id`, () => {
|
|
255
|
+
return new HttpResponse(null, { status: 204 });
|
|
256
|
+
}),
|
|
257
|
+
];
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Override Handlers in Tests
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
import { http, HttpResponse } from 'msw';
|
|
264
|
+
import { server } from '@/test/mocks/server';
|
|
265
|
+
|
|
266
|
+
test('shows error when API fails', async () => {
|
|
267
|
+
// Override handler for this test only
|
|
268
|
+
server.use(
|
|
269
|
+
http.get('http://localhost:3000/api/users', () => {
|
|
270
|
+
return HttpResponse.json(
|
|
271
|
+
{ error: { code: 'SERVER_ERROR', message: 'Something went wrong' } },
|
|
272
|
+
{ status: 500 }
|
|
273
|
+
);
|
|
274
|
+
})
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
render(<UserList />);
|
|
278
|
+
|
|
279
|
+
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Component Testing Patterns
|
|
286
|
+
|
|
287
|
+
### Basic Component Test
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
// features/users/components/UserCard.test.tsx
|
|
291
|
+
import { render, screen } from '@/test/test-utils';
|
|
292
|
+
import { UserCard } from './UserCard';
|
|
293
|
+
|
|
294
|
+
const mockUser = {
|
|
295
|
+
id: 1,
|
|
296
|
+
name: 'John Doe',
|
|
297
|
+
email: 'john@example.com',
|
|
298
|
+
role: 'admin',
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
describe('UserCard', () => {
|
|
302
|
+
it('renders user information', () => {
|
|
303
|
+
render(<UserCard user={mockUser} />);
|
|
304
|
+
|
|
305
|
+
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
306
|
+
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
|
307
|
+
expect(screen.getByText('admin')).toBeInTheDocument();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('calls onEdit when edit button is clicked', async () => {
|
|
311
|
+
const onEdit = vi.fn();
|
|
312
|
+
const user = userEvent.setup();
|
|
313
|
+
|
|
314
|
+
render(<UserCard user={mockUser} onEdit={onEdit} />);
|
|
315
|
+
|
|
316
|
+
await user.click(screen.getByRole('button', { name: /edit/i }));
|
|
317
|
+
|
|
318
|
+
expect(onEdit).toHaveBeenCalledWith(mockUser.id);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Testing Async Components
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
// features/users/components/UserList.test.tsx
|
|
327
|
+
import { render, screen, waitFor } from '@/test/test-utils';
|
|
328
|
+
import { UserList } from './UserList';
|
|
329
|
+
|
|
330
|
+
describe('UserList', () => {
|
|
331
|
+
it('shows loading state initially', () => {
|
|
332
|
+
render(<UserList />);
|
|
333
|
+
|
|
334
|
+
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('renders users after loading', async () => {
|
|
338
|
+
render(<UserList />);
|
|
339
|
+
|
|
340
|
+
// Wait for loading to finish and users to appear
|
|
341
|
+
expect(await screen.findByText('John Doe')).toBeInTheDocument();
|
|
342
|
+
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('shows empty state when no users', async () => {
|
|
346
|
+
server.use(
|
|
347
|
+
http.get('http://localhost:3000/api/users', () => {
|
|
348
|
+
return HttpResponse.json({ items: [], total: 0 });
|
|
349
|
+
})
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
render(<UserList />);
|
|
353
|
+
|
|
354
|
+
expect(await screen.findByText(/no users found/i)).toBeInTheDocument();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Testing Forms
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
// features/users/components/UserForm.test.tsx
|
|
363
|
+
import { render, screen, waitFor } from '@/test/test-utils';
|
|
364
|
+
import userEvent from '@testing-library/user-event';
|
|
365
|
+
import { UserForm } from './UserForm';
|
|
366
|
+
|
|
367
|
+
describe('UserForm', () => {
|
|
368
|
+
it('submits form with valid data', async () => {
|
|
369
|
+
const onSuccess = vi.fn();
|
|
370
|
+
const user = userEvent.setup();
|
|
371
|
+
|
|
372
|
+
render(<UserForm onSuccess={onSuccess} />);
|
|
373
|
+
|
|
374
|
+
await user.type(screen.getByLabelText(/name/i), 'New User');
|
|
375
|
+
await user.type(screen.getByLabelText(/email/i), 'new@example.com');
|
|
376
|
+
await user.click(screen.getByRole('button', { name: /submit/i }));
|
|
377
|
+
|
|
378
|
+
await waitFor(() => {
|
|
379
|
+
expect(onSuccess).toHaveBeenCalled();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('shows validation errors for invalid input', async () => {
|
|
384
|
+
const user = userEvent.setup();
|
|
385
|
+
|
|
386
|
+
render(<UserForm />);
|
|
387
|
+
|
|
388
|
+
await user.click(screen.getByRole('button', { name: /submit/i }));
|
|
389
|
+
|
|
390
|
+
expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
|
|
391
|
+
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('shows server error on API failure', async () => {
|
|
395
|
+
server.use(
|
|
396
|
+
http.post('http://localhost:3000/api/users', () => {
|
|
397
|
+
return HttpResponse.json(
|
|
398
|
+
{ error: { message: 'Email already exists' } },
|
|
399
|
+
{ status: 409 }
|
|
400
|
+
);
|
|
401
|
+
})
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const user = userEvent.setup();
|
|
405
|
+
render(<UserForm />);
|
|
406
|
+
|
|
407
|
+
await user.type(screen.getByLabelText(/name/i), 'Test');
|
|
408
|
+
await user.type(screen.getByLabelText(/email/i), 'existing@example.com');
|
|
409
|
+
await user.click(screen.getByRole('button', { name: /submit/i }));
|
|
410
|
+
|
|
411
|
+
expect(await screen.findByText(/email already exists/i)).toBeInTheDocument();
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## Hook Testing Patterns
|
|
419
|
+
|
|
420
|
+
### Testing with renderHook
|
|
421
|
+
|
|
422
|
+
```typescript
|
|
423
|
+
// features/users/hooks/useUsers.test.ts
|
|
424
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
425
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
426
|
+
import { useUsers } from './useUsers';
|
|
427
|
+
|
|
428
|
+
function createWrapper() {
|
|
429
|
+
const queryClient = new QueryClient({
|
|
430
|
+
defaultOptions: { queries: { retry: false } },
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
434
|
+
return (
|
|
435
|
+
<QueryClientProvider client={queryClient}>
|
|
436
|
+
{children}
|
|
437
|
+
</QueryClientProvider>
|
|
438
|
+
);
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
describe('useUsers', () => {
|
|
443
|
+
it('fetches users successfully', async () => {
|
|
444
|
+
const { result } = renderHook(() => useUsers(), {
|
|
445
|
+
wrapper: createWrapper(),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Initially loading
|
|
449
|
+
expect(result.current.isLoading).toBe(true);
|
|
450
|
+
|
|
451
|
+
// Wait for data
|
|
452
|
+
await waitFor(() => {
|
|
453
|
+
expect(result.current.isSuccess).toBe(true);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(result.current.data?.items).toHaveLength(2);
|
|
457
|
+
expect(result.current.data?.items[0].name).toBe('John Doe');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('handles errors', async () => {
|
|
461
|
+
server.use(
|
|
462
|
+
http.get('http://localhost:3000/api/users', () => {
|
|
463
|
+
return HttpResponse.json({ error: 'Server error' }, { status: 500 });
|
|
464
|
+
})
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const { result } = renderHook(() => useUsers(), {
|
|
468
|
+
wrapper: createWrapper(),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await waitFor(() => {
|
|
472
|
+
expect(result.current.isError).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Testing Custom Hooks with State
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
// hooks/useDebounce.test.ts
|
|
482
|
+
import { renderHook, act } from '@testing-library/react';
|
|
483
|
+
import { useDebounce } from './useDebounce';
|
|
484
|
+
|
|
485
|
+
describe('useDebounce', () => {
|
|
486
|
+
beforeEach(() => {
|
|
487
|
+
vi.useFakeTimers();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
afterEach(() => {
|
|
491
|
+
vi.useRealTimers();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('returns initial value immediately', () => {
|
|
495
|
+
const { result } = renderHook(() => useDebounce('initial', 500));
|
|
496
|
+
|
|
497
|
+
expect(result.current).toBe('initial');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('debounces value changes', () => {
|
|
501
|
+
const { result, rerender } = renderHook(
|
|
502
|
+
({ value, delay }) => useDebounce(value, delay),
|
|
503
|
+
{ initialProps: { value: 'initial', delay: 500 } }
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
rerender({ value: 'updated', delay: 500 });
|
|
507
|
+
|
|
508
|
+
// Value hasn't changed yet
|
|
509
|
+
expect(result.current).toBe('initial');
|
|
510
|
+
|
|
511
|
+
// Fast-forward time
|
|
512
|
+
act(() => {
|
|
513
|
+
vi.advanceTimersByTime(500);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
expect(result.current).toBe('updated');
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
## Utility Testing Patterns
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
// utils/formatters.test.ts
|
|
527
|
+
import { describe, it, expect } from 'vitest';
|
|
528
|
+
import { formatCurrency, formatDate, truncate } from './formatters';
|
|
529
|
+
|
|
530
|
+
describe('formatCurrency', () => {
|
|
531
|
+
it('formats USD currency', () => {
|
|
532
|
+
expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('handles zero', () => {
|
|
536
|
+
expect(formatCurrency(0, 'USD')).toBe('$0.00');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('handles negative numbers', () => {
|
|
540
|
+
expect(formatCurrency(-100, 'USD')).toBe('-$100.00');
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe('formatDate', () => {
|
|
545
|
+
it('formats date in default format', () => {
|
|
546
|
+
const date = new Date('2024-01-15T10:30:00Z');
|
|
547
|
+
expect(formatDate(date)).toBe('Jan 15, 2024');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('formats date with custom format', () => {
|
|
551
|
+
const date = new Date('2024-01-15T10:30:00Z');
|
|
552
|
+
expect(formatDate(date, 'YYYY-MM-DD')).toBe('2024-01-15');
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
describe('truncate', () => {
|
|
557
|
+
it.each([
|
|
558
|
+
['Hello World', 5, 'Hello...'],
|
|
559
|
+
['Hi', 5, 'Hi'],
|
|
560
|
+
['', 5, ''],
|
|
561
|
+
])('truncates "%s" with limit %i to "%s"', (input, limit, expected) => {
|
|
562
|
+
expect(truncate(input, limit)).toBe(expected);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
## E2E Testing with Playwright
|
|
570
|
+
|
|
571
|
+
### Configuration
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
// playwright.config.ts
|
|
575
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
576
|
+
|
|
577
|
+
export default defineConfig({
|
|
578
|
+
testDir: './tests/e2e',
|
|
579
|
+
fullyParallel: true,
|
|
580
|
+
forbidOnly: !!process.env.CI,
|
|
581
|
+
retries: process.env.CI ? 2 : 0,
|
|
582
|
+
workers: process.env.CI ? 1 : undefined,
|
|
583
|
+
reporter: 'html',
|
|
584
|
+
use: {
|
|
585
|
+
baseURL: 'http://localhost:5173',
|
|
586
|
+
trace: 'on-first-retry',
|
|
587
|
+
screenshot: 'only-on-failure',
|
|
588
|
+
},
|
|
589
|
+
projects: [
|
|
590
|
+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
591
|
+
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
|
592
|
+
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
|
593
|
+
],
|
|
594
|
+
webServer: {
|
|
595
|
+
command: 'pnpm dev',
|
|
596
|
+
url: 'http://localhost:5173',
|
|
597
|
+
reuseExistingServer: !process.env.CI,
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### E2E Test Example
|
|
603
|
+
|
|
604
|
+
```typescript
|
|
605
|
+
// tests/e2e/users.spec.ts
|
|
606
|
+
import { test, expect } from '@playwright/test';
|
|
607
|
+
|
|
608
|
+
test.describe('Users', () => {
|
|
609
|
+
test.beforeEach(async ({ page }) => {
|
|
610
|
+
await page.goto('/users');
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test('displays list of users', async ({ page }) => {
|
|
614
|
+
await expect(page.getByRole('heading', { name: /users/i })).toBeVisible();
|
|
615
|
+
await expect(page.getByTestId('user-card')).toHaveCount(2);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test('creates a new user', async ({ page }) => {
|
|
619
|
+
await page.getByRole('button', { name: /add user/i }).click();
|
|
620
|
+
|
|
621
|
+
await page.getByLabel(/name/i).fill('New User');
|
|
622
|
+
await page.getByLabel(/email/i).fill('new@example.com');
|
|
623
|
+
await page.getByRole('button', { name: /submit/i }).click();
|
|
624
|
+
|
|
625
|
+
await expect(page.getByText('User created successfully')).toBeVisible();
|
|
626
|
+
await expect(page.getByText('New User')).toBeVisible();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test('shows validation errors', async ({ page }) => {
|
|
630
|
+
await page.getByRole('button', { name: /add user/i }).click();
|
|
631
|
+
await page.getByRole('button', { name: /submit/i }).click();
|
|
632
|
+
|
|
633
|
+
await expect(page.getByText(/name is required/i)).toBeVisible();
|
|
634
|
+
await expect(page.getByText(/email is required/i)).toBeVisible();
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
## Test Commands
|
|
642
|
+
|
|
643
|
+
```bash
|
|
644
|
+
# Unit/Integration Tests (Vitest)
|
|
645
|
+
pnpm test # Watch mode
|
|
646
|
+
pnpm test:run # Single run
|
|
647
|
+
pnpm test:coverage # With coverage report
|
|
648
|
+
pnpm test:ui # Vitest UI
|
|
649
|
+
|
|
650
|
+
# Run specific tests
|
|
651
|
+
pnpm test UserList # Match by filename
|
|
652
|
+
pnpm test --grep "submits" # Match by test name
|
|
653
|
+
|
|
654
|
+
# E2E Tests (Playwright)
|
|
655
|
+
pnpm test:e2e # Run all E2E tests
|
|
656
|
+
pnpm test:e2e --ui # Interactive UI mode
|
|
657
|
+
pnpm test:e2e --project=chromium # Single browser
|
|
658
|
+
|
|
659
|
+
# Debug
|
|
660
|
+
pnpm test:e2e --debug # Step through tests
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
---
|
|
664
|
+
|
|
665
|
+
## Anti-Patterns
|
|
666
|
+
|
|
667
|
+
| Anti-Pattern | Problem | Correct Approach |
|
|
668
|
+
|--------------|---------|------------------|
|
|
669
|
+
| Testing implementation details | Brittle tests | Test user-visible behavior |
|
|
670
|
+
| Snapshot testing everything | Meaningless snapshots | Snapshot only stable output |
|
|
671
|
+
| Mocking everything | Tests don't reflect reality | Use MSW for real network behavior |
|
|
672
|
+
| No test isolation | Flaky tests | Fresh QueryClient per test |
|
|
673
|
+
| `waitFor` with no assertion | Silent failures | Always assert inside waitFor |
|
|
674
|
+
| `getByTestId` everywhere | Not accessible | Prefer accessible queries |
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
## Query Priority
|
|
679
|
+
|
|
680
|
+
Use queries in this order (most to least preferred):
|
|
681
|
+
|
|
682
|
+
1. `getByRole` - Accessible, how users interact
|
|
683
|
+
2. `getByLabelText` - For form inputs
|
|
684
|
+
3. `getByPlaceholderText` - When label is not available
|
|
685
|
+
4. `getByText` - For non-interactive elements
|
|
686
|
+
5. `getByTestId` - Last resort for elements without accessible names
|
|
687
|
+
|
|
688
|
+
---
|
|
689
|
+
|
|
690
|
+
_Tests document behavior. Each test should read as a specification of what the code does for real users._
|