@martinffx/atelier 2.0.0 → 3.0.1
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/CHANGELOG.md +21 -0
- package/README.md +94 -253
- package/package.json +1 -1
- package/skills/{spec-orchestrator → atelier-orchestrator}/SKILL.md +3 -3
- package/skills/atelier-setup/SKILL.md +80 -0
- package/skills/code-review/references/reviewers.md +3 -3
- package/skills/code-review/references/rq.md +6 -6
- package/skills/spec-brainstorm/SKILL.md +5 -5
- package/skills/spec-implement/SKILL.md +2 -3
- package/skills/spec-plan/SKILL.md +21 -29
- package/skills/spec-plan/references/plan_template.md +77 -0
- package/skills/python-architecture/SKILL.md +0 -148
- package/skills/python-architecture/references/data-modeling.md +0 -558
- package/skills/python-architecture/references/ddd.md +0 -569
- package/skills/python-architecture/references/functional-core.md +0 -337
- package/skills/python-build-tools/SKILL.md +0 -153
- package/skills/python-build-tools/references/basedpyright.md +0 -321
- package/skills/python-build-tools/references/ruff.md +0 -225
- package/skills/python-build-tools/references/uv.md +0 -257
- package/skills/python-fastapi/SKILL.md +0 -184
- package/skills/python-fastapi/references/api-design.md +0 -563
- package/skills/python-fastapi/references/dependencies.md +0 -479
- package/skills/python-fastapi/references/middleware.md +0 -475
- package/skills/python-fastapi/references/validation.md +0 -497
- package/skills/python-modern-python/SKILL.md +0 -201
- package/skills/python-modern-python/references/async.md +0 -76
- package/skills/python-modern-python/references/pattern-matching.md +0 -89
- package/skills/python-modern-python/references/typing.md +0 -96
- package/skills/python-monorepo/SKILL.md +0 -146
- package/skills/python-monorepo/references/docker.md +0 -515
- package/skills/python-monorepo/references/namespace-packages.md +0 -426
- package/skills/python-monorepo/references/workspace-config.md +0 -341
- package/skills/python-sqlalchemy/SKILL.md +0 -119
- package/skills/python-sqlalchemy/references/async.md +0 -51
- package/skills/python-sqlalchemy/references/models.md +0 -67
- package/skills/python-sqlalchemy/references/queries.md +0 -57
- package/skills/python-temporal/SKILL.md +0 -128
- package/skills/python-temporal/references/patterns.md +0 -87
- package/skills/python-temporal/references/testing.md +0 -54
- package/skills/python-testing/SKILL.md +0 -161
- package/skills/python-testing/references/boundaries.md +0 -449
- package/skills/python-testing/references/mocking.md +0 -449
- package/skills/python-testing/references/pytest.md +0 -524
- package/skills/typescript-api-design/SKILL.md +0 -396
- package/skills/typescript-api-design/references/error-responses.md +0 -469
- package/skills/typescript-build-tools/SKILL.md +0 -447
- package/skills/typescript-build-tools/references/biome.md +0 -436
- package/skills/typescript-build-tools/references/bun.md +0 -432
- package/skills/typescript-build-tools/references/turborepo.md +0 -426
- package/skills/typescript-build-tools/references/vitest.md +0 -518
- package/skills/typescript-drizzle-orm/SKILL.md +0 -398
- package/skills/typescript-drizzle-orm/references/cloudflare.md +0 -392
- package/skills/typescript-drizzle-orm/references/entity-pattern.md +0 -412
- package/skills/typescript-drizzle-orm/references/postgresql.md +0 -191
- package/skills/typescript-drizzle-orm/references/repository-pattern.md +0 -479
- package/skills/typescript-drizzle-orm/references/sqlite.md +0 -291
- package/skills/typescript-dynamodb-toolbox/SKILL.md +0 -370
- package/skills/typescript-dynamodb-toolbox/references/entity-layer.md +0 -574
- package/skills/typescript-dynamodb-toolbox/references/error-handling.md +0 -495
- package/skills/typescript-dynamodb-toolbox/references/modeling.md +0 -332
- package/skills/typescript-dynamodb-toolbox/references/repository-layer.md +0 -631
- package/skills/typescript-dynamodb-toolbox/references/testing.md +0 -718
- package/skills/typescript-dynamodb-toolbox/references/transactions.md +0 -423
- package/skills/typescript-effect-ts/SKILL.md +0 -341
- package/skills/typescript-effect-ts/references/core.md +0 -412
- package/skills/typescript-effect-ts/references/error-handling.md +0 -468
- package/skills/typescript-effect-ts/references/resources.md +0 -476
- package/skills/typescript-effect-ts/references/schema.md +0 -536
- package/skills/typescript-effect-ts/references/services.md +0 -461
- package/skills/typescript-fastify/SKILL.md +0 -405
- package/skills/typescript-fastify/references/plugins.md +0 -329
- package/skills/typescript-fastify/references/typeid.md +0 -315
- package/skills/typescript-functional-patterns/SKILL.md +0 -402
- package/skills/typescript-functional-patterns/references/adts.md +0 -497
- package/skills/typescript-functional-patterns/references/branded-types.md +0 -542
- package/skills/typescript-functional-patterns/references/migration-guide.md +0 -695
- package/skills/typescript-functional-patterns/references/option-result.md +0 -585
- package/skills/typescript-testing/SKILL.md +0 -353
- package/skills/typescript-testing/references/mocking.md +0 -675
- package/skills/typescript-testing/references/msw.md +0 -852
- package/skills/typescript-testing/references/snapshot-testing.md +0 -774
|
@@ -1,852 +0,0 @@
|
|
|
1
|
-
# MSW (Mock Service Worker) Patterns
|
|
2
|
-
|
|
3
|
-
Comprehensive guide to mocking HTTP requests at the network level using MSW with Vitest.
|
|
4
|
-
|
|
5
|
-
## Installation and Setup
|
|
6
|
-
|
|
7
|
-
### Dependencies
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
bun add -d msw
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
### Basic Server Setup
|
|
14
|
-
|
|
15
|
-
```typescript
|
|
16
|
-
// tests/setup.ts
|
|
17
|
-
import { setupServer } from 'msw/node'
|
|
18
|
-
import { http, HttpResponse } from 'msw'
|
|
19
|
-
import { beforeAll, afterEach, afterAll } from 'vitest'
|
|
20
|
-
|
|
21
|
-
// Define default handlers
|
|
22
|
-
export const handlers = [
|
|
23
|
-
http.get('/api/users', () => {
|
|
24
|
-
return HttpResponse.json([
|
|
25
|
-
{ id: '1', name: 'Alice' },
|
|
26
|
-
{ id: '2', name: 'Bob' },
|
|
27
|
-
])
|
|
28
|
-
}),
|
|
29
|
-
]
|
|
30
|
-
|
|
31
|
-
// Create server instance
|
|
32
|
-
export const server = setupServer(...handlers)
|
|
33
|
-
|
|
34
|
-
// Setup lifecycle hooks
|
|
35
|
-
beforeAll(() => {
|
|
36
|
-
server.listen({ onUnhandledRequest: 'error' })
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
afterEach(() => {
|
|
40
|
-
server.resetHandlers()
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
afterAll(() => {
|
|
44
|
-
server.close()
|
|
45
|
-
})
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### Vitest Configuration
|
|
49
|
-
|
|
50
|
-
```typescript
|
|
51
|
-
// vitest.config.ts
|
|
52
|
-
import { defineConfig } from 'vitest/config'
|
|
53
|
-
|
|
54
|
-
export default defineConfig({
|
|
55
|
-
test: {
|
|
56
|
-
setupFiles: ['./tests/setup.ts'],
|
|
57
|
-
},
|
|
58
|
-
})
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
## REST API Handlers
|
|
62
|
-
|
|
63
|
-
### HTTP Methods
|
|
64
|
-
|
|
65
|
-
```typescript
|
|
66
|
-
import { http, HttpResponse } from 'msw'
|
|
67
|
-
|
|
68
|
-
const handlers = [
|
|
69
|
-
// GET
|
|
70
|
-
http.get('/api/users', () => {
|
|
71
|
-
return HttpResponse.json([{ id: '1', name: 'Alice' }])
|
|
72
|
-
}),
|
|
73
|
-
|
|
74
|
-
// POST
|
|
75
|
-
http.post('/api/users', async ({ request }) => {
|
|
76
|
-
const body = await request.json()
|
|
77
|
-
return HttpResponse.json(
|
|
78
|
-
{ id: '123', ...body },
|
|
79
|
-
{ status: 201 }
|
|
80
|
-
)
|
|
81
|
-
}),
|
|
82
|
-
|
|
83
|
-
// PUT
|
|
84
|
-
http.put('/api/users/:id', async ({ params, request }) => {
|
|
85
|
-
const body = await request.json()
|
|
86
|
-
return HttpResponse.json({ id: params.id, ...body })
|
|
87
|
-
}),
|
|
88
|
-
|
|
89
|
-
// PATCH
|
|
90
|
-
http.patch('/api/users/:id', async ({ params, request }) => {
|
|
91
|
-
const updates = await request.json()
|
|
92
|
-
return HttpResponse.json({ id: params.id, ...updates })
|
|
93
|
-
}),
|
|
94
|
-
|
|
95
|
-
// DELETE
|
|
96
|
-
http.delete('/api/users/:id', ({ params }) => {
|
|
97
|
-
return new HttpResponse(null, { status: 204 })
|
|
98
|
-
}),
|
|
99
|
-
]
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
### Path Parameters
|
|
103
|
-
|
|
104
|
-
```typescript
|
|
105
|
-
// Single parameter
|
|
106
|
-
http.get('/api/users/:id', ({ params }) => {
|
|
107
|
-
const { id } = params
|
|
108
|
-
return HttpResponse.json({ id, name: 'Alice' })
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
// Multiple parameters
|
|
112
|
-
http.get('/api/orgs/:orgId/repos/:repoId', ({ params }) => {
|
|
113
|
-
const { orgId, repoId } = params
|
|
114
|
-
return HttpResponse.json({
|
|
115
|
-
org: orgId,
|
|
116
|
-
repo: repoId,
|
|
117
|
-
name: 'my-repo',
|
|
118
|
-
})
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
// Wildcard
|
|
122
|
-
http.get('/api/files/*', ({ params }) => {
|
|
123
|
-
const path = params[0] // matches everything after /files/
|
|
124
|
-
return HttpResponse.json({ path })
|
|
125
|
-
})
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
### Query Parameters
|
|
129
|
-
|
|
130
|
-
```typescript
|
|
131
|
-
http.get('/api/users', ({ request }) => {
|
|
132
|
-
const url = new URL(request.url)
|
|
133
|
-
const page = url.searchParams.get('page') || '1'
|
|
134
|
-
const limit = url.searchParams.get('limit') || '10'
|
|
135
|
-
|
|
136
|
-
return HttpResponse.json({
|
|
137
|
-
data: users.slice(
|
|
138
|
-
(parseInt(page) - 1) * parseInt(limit),
|
|
139
|
-
parseInt(page) * parseInt(limit)
|
|
140
|
-
),
|
|
141
|
-
page: parseInt(page),
|
|
142
|
-
total: users.length,
|
|
143
|
-
})
|
|
144
|
-
})
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
### Request Headers
|
|
148
|
-
|
|
149
|
-
```typescript
|
|
150
|
-
http.get('/api/protected', ({ request }) => {
|
|
151
|
-
const auth = request.headers.get('Authorization')
|
|
152
|
-
|
|
153
|
-
if (!auth || !auth.startsWith('Bearer ')) {
|
|
154
|
-
return new HttpResponse(null, {
|
|
155
|
-
status: 401,
|
|
156
|
-
statusText: 'Unauthorized',
|
|
157
|
-
})
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const token = auth.substring(7)
|
|
161
|
-
if (token !== 'valid-token') {
|
|
162
|
-
return new HttpResponse(
|
|
163
|
-
JSON.stringify({ error: 'Invalid token' }),
|
|
164
|
-
{ status: 403 }
|
|
165
|
-
)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return HttpResponse.json({ message: 'Access granted' })
|
|
169
|
-
})
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
### Request Body
|
|
173
|
-
|
|
174
|
-
```typescript
|
|
175
|
-
// JSON body
|
|
176
|
-
http.post('/api/users', async ({ request }) => {
|
|
177
|
-
const body = await request.json()
|
|
178
|
-
|
|
179
|
-
if (!body.email) {
|
|
180
|
-
return HttpResponse.json(
|
|
181
|
-
{ error: 'Email required' },
|
|
182
|
-
{ status: 400 }
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return HttpResponse.json({ id: '123', ...body }, { status: 201 })
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
// Form data
|
|
190
|
-
http.post('/api/upload', async ({ request }) => {
|
|
191
|
-
const formData = await request.formData()
|
|
192
|
-
const file = formData.get('file')
|
|
193
|
-
|
|
194
|
-
return HttpResponse.json({
|
|
195
|
-
filename: file?.name,
|
|
196
|
-
size: file?.size,
|
|
197
|
-
})
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
// Text body
|
|
201
|
-
http.post('/api/webhook', async ({ request }) => {
|
|
202
|
-
const body = await request.text()
|
|
203
|
-
return new HttpResponse(null, { status: 200 })
|
|
204
|
-
})
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
## Response Types
|
|
208
|
-
|
|
209
|
-
### JSON Response
|
|
210
|
-
|
|
211
|
-
```typescript
|
|
212
|
-
http.get('/api/users', () => {
|
|
213
|
-
return HttpResponse.json(
|
|
214
|
-
{ id: '1', name: 'Alice' },
|
|
215
|
-
{
|
|
216
|
-
status: 200,
|
|
217
|
-
headers: {
|
|
218
|
-
'Content-Type': 'application/json',
|
|
219
|
-
'X-Custom-Header': 'value',
|
|
220
|
-
},
|
|
221
|
-
}
|
|
222
|
-
)
|
|
223
|
-
})
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
### Text Response
|
|
227
|
-
|
|
228
|
-
```typescript
|
|
229
|
-
http.get('/api/health', () => {
|
|
230
|
-
return new HttpResponse('OK', {
|
|
231
|
-
status: 200,
|
|
232
|
-
headers: { 'Content-Type': 'text/plain' },
|
|
233
|
-
})
|
|
234
|
-
})
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
### Binary Response
|
|
238
|
-
|
|
239
|
-
```typescript
|
|
240
|
-
http.get('/api/download', () => {
|
|
241
|
-
const buffer = new ArrayBuffer(8)
|
|
242
|
-
return new HttpResponse(buffer, {
|
|
243
|
-
headers: {
|
|
244
|
-
'Content-Type': 'application/octet-stream',
|
|
245
|
-
'Content-Disposition': 'attachment; filename="file.bin"',
|
|
246
|
-
},
|
|
247
|
-
})
|
|
248
|
-
})
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
### Empty Response
|
|
252
|
-
|
|
253
|
-
```typescript
|
|
254
|
-
http.delete('/api/users/:id', () => {
|
|
255
|
-
return new HttpResponse(null, { status: 204 })
|
|
256
|
-
})
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
## Error Responses
|
|
260
|
-
|
|
261
|
-
### HTTP Errors
|
|
262
|
-
|
|
263
|
-
```typescript
|
|
264
|
-
// 404 Not Found
|
|
265
|
-
http.get('/api/users/:id', ({ params }) => {
|
|
266
|
-
return HttpResponse.json(
|
|
267
|
-
{ error: 'User not found' },
|
|
268
|
-
{ status: 404 }
|
|
269
|
-
)
|
|
270
|
-
})
|
|
271
|
-
|
|
272
|
-
// 400 Bad Request
|
|
273
|
-
http.post('/api/users', async ({ request }) => {
|
|
274
|
-
const body = await request.json()
|
|
275
|
-
|
|
276
|
-
if (!body.email) {
|
|
277
|
-
return HttpResponse.json(
|
|
278
|
-
{ error: 'Email is required', field: 'email' },
|
|
279
|
-
{ status: 400 }
|
|
280
|
-
)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return HttpResponse.json(body)
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
// 500 Internal Server Error
|
|
287
|
-
http.get('/api/data', () => {
|
|
288
|
-
return HttpResponse.json(
|
|
289
|
-
{ error: 'Internal server error' },
|
|
290
|
-
{ status: 500 }
|
|
291
|
-
)
|
|
292
|
-
})
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
### Network Errors
|
|
296
|
-
|
|
297
|
-
```typescript
|
|
298
|
-
import { HttpResponse } from 'msw'
|
|
299
|
-
|
|
300
|
-
// Network error (connection failed)
|
|
301
|
-
http.get('/api/users', () => {
|
|
302
|
-
return HttpResponse.error()
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
// Timeout (no response)
|
|
306
|
-
http.get('/api/slow', async () => {
|
|
307
|
-
await new Promise(() => {}) // Never resolves
|
|
308
|
-
})
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
### Conditional Errors
|
|
312
|
-
|
|
313
|
-
```typescript
|
|
314
|
-
let failureCount = 0
|
|
315
|
-
|
|
316
|
-
http.get('/api/flaky', () => {
|
|
317
|
-
failureCount++
|
|
318
|
-
|
|
319
|
-
// Fail first 2 times, then succeed
|
|
320
|
-
if (failureCount <= 2) {
|
|
321
|
-
return HttpResponse.json(
|
|
322
|
-
{ error: 'Service unavailable' },
|
|
323
|
-
{ status: 503 }
|
|
324
|
-
)
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return HttpResponse.json({ data: 'success' })
|
|
328
|
-
})
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
## Response Delays
|
|
332
|
-
|
|
333
|
-
### Fixed Delay
|
|
334
|
-
|
|
335
|
-
```typescript
|
|
336
|
-
import { delay, http, HttpResponse } from 'msw'
|
|
337
|
-
|
|
338
|
-
http.get('/api/slow', async () => {
|
|
339
|
-
await delay(2000) // 2 second delay
|
|
340
|
-
return HttpResponse.json({ data: 'delayed response' })
|
|
341
|
-
})
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
### Random Delay
|
|
345
|
-
|
|
346
|
-
```typescript
|
|
347
|
-
http.get('/api/variable', async () => {
|
|
348
|
-
const delayMs = Math.random() * 1000 // 0-1 second
|
|
349
|
-
await delay(delayMs)
|
|
350
|
-
return HttpResponse.json({ data: 'response' })
|
|
351
|
-
})
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
### Realistic Network Delays
|
|
355
|
-
|
|
356
|
-
```typescript
|
|
357
|
-
const handlers = [
|
|
358
|
-
http.get('/api/users', async () => {
|
|
359
|
-
await delay('real') // Use realistic network latency
|
|
360
|
-
return HttpResponse.json(users)
|
|
361
|
-
}),
|
|
362
|
-
]
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
### Test Loading States
|
|
366
|
-
|
|
367
|
-
```typescript
|
|
368
|
-
it('shows loading spinner', async () => {
|
|
369
|
-
server.use(
|
|
370
|
-
http.get('/api/users', async () => {
|
|
371
|
-
await delay(1000)
|
|
372
|
-
return HttpResponse.json(users)
|
|
373
|
-
})
|
|
374
|
-
)
|
|
375
|
-
|
|
376
|
-
render(<UserList />)
|
|
377
|
-
|
|
378
|
-
// Verify loading state
|
|
379
|
-
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
380
|
-
|
|
381
|
-
// Wait for data
|
|
382
|
-
await waitFor(() => {
|
|
383
|
-
expect(screen.getByText('Alice')).toBeInTheDocument()
|
|
384
|
-
})
|
|
385
|
-
})
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
## Per-Test Overrides
|
|
389
|
-
|
|
390
|
-
### Runtime Handler Override
|
|
391
|
-
|
|
392
|
-
```typescript
|
|
393
|
-
import { server } from './tests/setup'
|
|
394
|
-
import { http, HttpResponse } from 'msw'
|
|
395
|
-
|
|
396
|
-
it('handles user not found', async () => {
|
|
397
|
-
// Override for this test only
|
|
398
|
-
server.use(
|
|
399
|
-
http.get('/api/users/:id', ({ params }) => {
|
|
400
|
-
return HttpResponse.json(
|
|
401
|
-
{ error: 'User not found' },
|
|
402
|
-
{ status: 404 }
|
|
403
|
-
)
|
|
404
|
-
})
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
const result = await fetchUser('999')
|
|
408
|
-
expect(result.error).toBe('User not found')
|
|
409
|
-
})
|
|
410
|
-
|
|
411
|
-
// Handler automatically resets after test (via afterEach hook)
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
### Multiple Overrides
|
|
415
|
-
|
|
416
|
-
```typescript
|
|
417
|
-
it('handles multiple error scenarios', async () => {
|
|
418
|
-
server.use(
|
|
419
|
-
// Override user endpoint
|
|
420
|
-
http.get('/api/users/:id', () => {
|
|
421
|
-
return HttpResponse.json({ error: 'Not found' }, { status: 404 })
|
|
422
|
-
}),
|
|
423
|
-
|
|
424
|
-
// Override posts endpoint
|
|
425
|
-
http.get('/api/posts', () => {
|
|
426
|
-
return HttpResponse.json({ error: 'Forbidden' }, { status: 403 })
|
|
427
|
-
})
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
// Test both scenarios
|
|
431
|
-
await expect(fetchUser('1')).rejects.toThrow()
|
|
432
|
-
await expect(fetchPosts()).rejects.toThrow()
|
|
433
|
-
})
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
### One-Time Override
|
|
437
|
-
|
|
438
|
-
```typescript
|
|
439
|
-
it('simulates temporary outage', async () => {
|
|
440
|
-
server.use(
|
|
441
|
-
http.get('/api/users', () => HttpResponse.error(), { once: true })
|
|
442
|
-
)
|
|
443
|
-
|
|
444
|
-
// First call fails
|
|
445
|
-
await expect(fetchUsers()).rejects.toThrow()
|
|
446
|
-
|
|
447
|
-
// Second call uses default handler (succeeds)
|
|
448
|
-
const users = await fetchUsers()
|
|
449
|
-
expect(users).toHaveLength(2)
|
|
450
|
-
})
|
|
451
|
-
```
|
|
452
|
-
|
|
453
|
-
## TypeScript Integration
|
|
454
|
-
|
|
455
|
-
### Typed Request Bodies
|
|
456
|
-
|
|
457
|
-
```typescript
|
|
458
|
-
interface CreateUserRequest {
|
|
459
|
-
name: string
|
|
460
|
-
email: string
|
|
461
|
-
age?: number
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
interface User extends CreateUserRequest {
|
|
465
|
-
id: string
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
http.post<never, CreateUserRequest, User>('/api/users', async ({ request }) => {
|
|
469
|
-
const body = await request.json()
|
|
470
|
-
|
|
471
|
-
// body is typed as CreateUserRequest
|
|
472
|
-
const user: User = {
|
|
473
|
-
id: crypto.randomUUID(),
|
|
474
|
-
...body,
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
return HttpResponse.json(user, { status: 201 })
|
|
478
|
-
})
|
|
479
|
-
```
|
|
480
|
-
|
|
481
|
-
### Typed Path Parameters
|
|
482
|
-
|
|
483
|
-
```typescript
|
|
484
|
-
interface UserParams {
|
|
485
|
-
id: string
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
http.get<UserParams>('/api/users/:id', ({ params }) => {
|
|
489
|
-
// params.id is typed as string
|
|
490
|
-
return HttpResponse.json({
|
|
491
|
-
id: params.id,
|
|
492
|
-
name: 'Alice',
|
|
493
|
-
})
|
|
494
|
-
})
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
### Type-Safe Responses
|
|
498
|
-
|
|
499
|
-
```typescript
|
|
500
|
-
interface ApiResponse<T> {
|
|
501
|
-
data: T
|
|
502
|
-
meta: {
|
|
503
|
-
page: number
|
|
504
|
-
total: number
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
http.get('/api/users', (): HttpResponse<ApiResponse<User[]>> => {
|
|
509
|
-
return HttpResponse.json({
|
|
510
|
-
data: users,
|
|
511
|
-
meta: { page: 1, total: users.length },
|
|
512
|
-
})
|
|
513
|
-
})
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
## GraphQL Handlers
|
|
517
|
-
|
|
518
|
-
### Basic GraphQL Setup
|
|
519
|
-
|
|
520
|
-
```typescript
|
|
521
|
-
import { graphql, HttpResponse } from 'msw'
|
|
522
|
-
|
|
523
|
-
const handlers = [
|
|
524
|
-
graphql.query('GetUser', ({ variables }) => {
|
|
525
|
-
return HttpResponse.json({
|
|
526
|
-
data: {
|
|
527
|
-
user: {
|
|
528
|
-
id: variables.id,
|
|
529
|
-
name: 'Alice',
|
|
530
|
-
email: 'alice@example.com',
|
|
531
|
-
},
|
|
532
|
-
},
|
|
533
|
-
})
|
|
534
|
-
}),
|
|
535
|
-
|
|
536
|
-
graphql.mutation('CreateUser', async ({ variables }) => {
|
|
537
|
-
return HttpResponse.json({
|
|
538
|
-
data: {
|
|
539
|
-
createUser: {
|
|
540
|
-
id: '123',
|
|
541
|
-
name: variables.name,
|
|
542
|
-
email: variables.email,
|
|
543
|
-
},
|
|
544
|
-
},
|
|
545
|
-
})
|
|
546
|
-
}),
|
|
547
|
-
]
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
### GraphQL Errors
|
|
551
|
-
|
|
552
|
-
```typescript
|
|
553
|
-
graphql.query('GetUser', ({ variables }) => {
|
|
554
|
-
return HttpResponse.json({
|
|
555
|
-
errors: [
|
|
556
|
-
{
|
|
557
|
-
message: 'User not found',
|
|
558
|
-
extensions: { code: 'NOT_FOUND' },
|
|
559
|
-
},
|
|
560
|
-
],
|
|
561
|
-
})
|
|
562
|
-
})
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
### Multiple GraphQL Endpoints
|
|
566
|
-
|
|
567
|
-
```typescript
|
|
568
|
-
const handlers = [
|
|
569
|
-
// Main API
|
|
570
|
-
graphql.query('GetUser', () => { /* ... */ }),
|
|
571
|
-
|
|
572
|
-
// Admin API
|
|
573
|
-
graphql.link('https://admin.example.com/graphql').query('GetStats', () => {
|
|
574
|
-
return HttpResponse.json({ data: { stats: {} } })
|
|
575
|
-
}),
|
|
576
|
-
]
|
|
577
|
-
```
|
|
578
|
-
|
|
579
|
-
## Advanced Patterns
|
|
580
|
-
|
|
581
|
-
### Stateful Handlers
|
|
582
|
-
|
|
583
|
-
```typescript
|
|
584
|
-
let users: User[] = []
|
|
585
|
-
|
|
586
|
-
const handlers = [
|
|
587
|
-
http.get('/api/users', () => {
|
|
588
|
-
return HttpResponse.json(users)
|
|
589
|
-
}),
|
|
590
|
-
|
|
591
|
-
http.post('/api/users', async ({ request }) => {
|
|
592
|
-
const body = await request.json()
|
|
593
|
-
const user = { id: crypto.randomUUID(), ...body }
|
|
594
|
-
users.push(user)
|
|
595
|
-
return HttpResponse.json(user, { status: 201 })
|
|
596
|
-
}),
|
|
597
|
-
|
|
598
|
-
http.delete('/api/users/:id', ({ params }) => {
|
|
599
|
-
users = users.filter(u => u.id !== params.id)
|
|
600
|
-
return new HttpResponse(null, { status: 204 })
|
|
601
|
-
}),
|
|
602
|
-
]
|
|
603
|
-
|
|
604
|
-
// Reset state between tests
|
|
605
|
-
beforeEach(() => {
|
|
606
|
-
users = []
|
|
607
|
-
})
|
|
608
|
-
```
|
|
609
|
-
|
|
610
|
-
### Paginated Responses
|
|
611
|
-
|
|
612
|
-
```typescript
|
|
613
|
-
http.get('/api/users', ({ request }) => {
|
|
614
|
-
const url = new URL(request.url)
|
|
615
|
-
const page = parseInt(url.searchParams.get('page') || '1')
|
|
616
|
-
const limit = parseInt(url.searchParams.get('limit') || '10')
|
|
617
|
-
|
|
618
|
-
const start = (page - 1) * limit
|
|
619
|
-
const end = start + limit
|
|
620
|
-
|
|
621
|
-
return HttpResponse.json({
|
|
622
|
-
data: allUsers.slice(start, end),
|
|
623
|
-
pagination: {
|
|
624
|
-
page,
|
|
625
|
-
limit,
|
|
626
|
-
total: allUsers.length,
|
|
627
|
-
pages: Math.ceil(allUsers.length / limit),
|
|
628
|
-
},
|
|
629
|
-
})
|
|
630
|
-
})
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
### File Upload Handling
|
|
634
|
-
|
|
635
|
-
```typescript
|
|
636
|
-
http.post('/api/upload', async ({ request }) => {
|
|
637
|
-
const formData = await request.formData()
|
|
638
|
-
const file = formData.get('file') as File
|
|
639
|
-
|
|
640
|
-
if (!file) {
|
|
641
|
-
return HttpResponse.json(
|
|
642
|
-
{ error: 'No file provided' },
|
|
643
|
-
{ status: 400 }
|
|
644
|
-
)
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
if (file.size > 1024 * 1024 * 5) { // 5MB
|
|
648
|
-
return HttpResponse.json(
|
|
649
|
-
{ error: 'File too large' },
|
|
650
|
-
{ status: 413 }
|
|
651
|
-
)
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
return HttpResponse.json({
|
|
655
|
-
id: crypto.randomUUID(),
|
|
656
|
-
filename: file.name,
|
|
657
|
-
size: file.size,
|
|
658
|
-
type: file.type,
|
|
659
|
-
})
|
|
660
|
-
})
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
### Conditional Responses
|
|
664
|
-
|
|
665
|
-
```typescript
|
|
666
|
-
http.get('/api/users/:id', ({ params, request }) => {
|
|
667
|
-
const userId = params.id
|
|
668
|
-
const auth = request.headers.get('Authorization')
|
|
669
|
-
|
|
670
|
-
// Not authenticated
|
|
671
|
-
if (!auth) {
|
|
672
|
-
return new HttpResponse(null, { status: 401 })
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// User not found
|
|
676
|
-
const user = users.find(u => u.id === userId)
|
|
677
|
-
if (!user) {
|
|
678
|
-
return HttpResponse.json(
|
|
679
|
-
{ error: 'User not found' },
|
|
680
|
-
{ status: 404 }
|
|
681
|
-
)
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// Success
|
|
685
|
-
return HttpResponse.json(user)
|
|
686
|
-
})
|
|
687
|
-
```
|
|
688
|
-
|
|
689
|
-
## Testing Patterns
|
|
690
|
-
|
|
691
|
-
### Testing Error Handling
|
|
692
|
-
|
|
693
|
-
```typescript
|
|
694
|
-
it('handles network errors', async () => {
|
|
695
|
-
server.use(
|
|
696
|
-
http.get('/api/users', () => HttpResponse.error())
|
|
697
|
-
)
|
|
698
|
-
|
|
699
|
-
await expect(fetchUsers()).rejects.toThrow('Network error')
|
|
700
|
-
})
|
|
701
|
-
|
|
702
|
-
it('handles 404 responses', async () => {
|
|
703
|
-
server.use(
|
|
704
|
-
http.get('/api/users/:id', () => {
|
|
705
|
-
return HttpResponse.json(
|
|
706
|
-
{ error: 'Not found' },
|
|
707
|
-
{ status: 404 }
|
|
708
|
-
)
|
|
709
|
-
})
|
|
710
|
-
)
|
|
711
|
-
|
|
712
|
-
await expect(fetchUser('999')).rejects.toThrow('User not found')
|
|
713
|
-
})
|
|
714
|
-
```
|
|
715
|
-
|
|
716
|
-
### Testing Retry Logic
|
|
717
|
-
|
|
718
|
-
```typescript
|
|
719
|
-
it('retries failed requests', async () => {
|
|
720
|
-
let attempts = 0
|
|
721
|
-
|
|
722
|
-
server.use(
|
|
723
|
-
http.get('/api/users', () => {
|
|
724
|
-
attempts++
|
|
725
|
-
if (attempts < 3) {
|
|
726
|
-
return HttpResponse.json(
|
|
727
|
-
{ error: 'Service unavailable' },
|
|
728
|
-
{ status: 503 }
|
|
729
|
-
)
|
|
730
|
-
}
|
|
731
|
-
return HttpResponse.json(users)
|
|
732
|
-
})
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
const result = await fetchUsersWithRetry()
|
|
736
|
-
|
|
737
|
-
expect(attempts).toBe(3)
|
|
738
|
-
expect(result).toEqual(users)
|
|
739
|
-
})
|
|
740
|
-
```
|
|
741
|
-
|
|
742
|
-
### Testing Optimistic Updates
|
|
743
|
-
|
|
744
|
-
```typescript
|
|
745
|
-
it('handles optimistic update failure', async () => {
|
|
746
|
-
server.use(
|
|
747
|
-
http.put('/api/users/:id', () => {
|
|
748
|
-
return HttpResponse.json(
|
|
749
|
-
{ error: 'Update failed' },
|
|
750
|
-
{ status: 500 }
|
|
751
|
-
)
|
|
752
|
-
})
|
|
753
|
-
)
|
|
754
|
-
|
|
755
|
-
render(<UserProfile userId="123" />)
|
|
756
|
-
|
|
757
|
-
// Make optimistic update
|
|
758
|
-
fireEvent.click(screen.getByText('Update Name'))
|
|
759
|
-
|
|
760
|
-
// Should show optimistic state
|
|
761
|
-
expect(screen.getByText('New Name')).toBeInTheDocument()
|
|
762
|
-
|
|
763
|
-
// Wait for error and rollback
|
|
764
|
-
await waitFor(() => {
|
|
765
|
-
expect(screen.getByText('Old Name')).toBeInTheDocument()
|
|
766
|
-
expect(screen.getByText('Update failed')).toBeInTheDocument()
|
|
767
|
-
})
|
|
768
|
-
})
|
|
769
|
-
```
|
|
770
|
-
|
|
771
|
-
## Guidelines
|
|
772
|
-
|
|
773
|
-
1. **Setup once, override per-test** - Define default handlers in setup file, override in individual tests
|
|
774
|
-
2. **Reset handlers between tests** - Use `server.resetHandlers()` in `afterEach` hook
|
|
775
|
-
3. **Use realistic delays** - Test loading states with `delay()` function
|
|
776
|
-
4. **Type your handlers** - Use TypeScript generics for request/response types
|
|
777
|
-
5. **Mock at network level** - Better than mocking fetch directly, works with any HTTP client
|
|
778
|
-
6. **Test error scenarios** - Use MSW to simulate network errors, timeouts, HTTP errors
|
|
779
|
-
7. **Keep handlers simple** - Complex logic should be tested separately, not in mock handlers
|
|
780
|
-
8. **Use stateful handlers sparingly** - Can make tests interdependent, prefer isolated state
|
|
781
|
-
9. **Fail on unhandled requests** - Use `onUnhandledRequest: 'error'` to catch missing mocks
|
|
782
|
-
10. **Group related handlers** - Organize handlers by feature or API domain
|
|
783
|
-
11. **Document conditional behavior** - Add comments when handlers have complex logic
|
|
784
|
-
12. **Test pagination separately** - Create dedicated handlers for pagination edge cases
|
|
785
|
-
|
|
786
|
-
## Common Pitfalls
|
|
787
|
-
|
|
788
|
-
### Missing Handler Resets
|
|
789
|
-
|
|
790
|
-
```typescript
|
|
791
|
-
// BAD - handlers persist across tests
|
|
792
|
-
afterEach(() => {
|
|
793
|
-
// Forgot to reset handlers
|
|
794
|
-
})
|
|
795
|
-
|
|
796
|
-
// GOOD - reset after each test
|
|
797
|
-
afterEach(() => {
|
|
798
|
-
server.resetHandlers()
|
|
799
|
-
})
|
|
800
|
-
```
|
|
801
|
-
|
|
802
|
-
### Over-Mocking
|
|
803
|
-
|
|
804
|
-
```typescript
|
|
805
|
-
// BAD - mocking too much detail
|
|
806
|
-
http.get('/api/users', () => {
|
|
807
|
-
return HttpResponse.json({
|
|
808
|
-
data: users,
|
|
809
|
-
meta: { timestamp: Date.now(), version: '1.0', ... },
|
|
810
|
-
debug: { queryTime: 123, ... },
|
|
811
|
-
})
|
|
812
|
-
})
|
|
813
|
-
|
|
814
|
-
// GOOD - mock only what tests need
|
|
815
|
-
http.get('/api/users', () => {
|
|
816
|
-
return HttpResponse.json(users)
|
|
817
|
-
})
|
|
818
|
-
```
|
|
819
|
-
|
|
820
|
-
### Forgetting async/await
|
|
821
|
-
|
|
822
|
-
```typescript
|
|
823
|
-
// BAD - not awaiting request.json()
|
|
824
|
-
http.post('/api/users', ({ request }) => {
|
|
825
|
-
const body = request.json() // Missing await!
|
|
826
|
-
return HttpResponse.json(body)
|
|
827
|
-
})
|
|
828
|
-
|
|
829
|
-
// GOOD - await async operations
|
|
830
|
-
http.post('/api/users', async ({ request }) => {
|
|
831
|
-
const body = await request.json()
|
|
832
|
-
return HttpResponse.json(body)
|
|
833
|
-
})
|
|
834
|
-
```
|
|
835
|
-
|
|
836
|
-
### Not Handling Headers
|
|
837
|
-
|
|
838
|
-
```typescript
|
|
839
|
-
// BAD - ignoring auth headers in tests
|
|
840
|
-
http.get('/api/protected', () => {
|
|
841
|
-
return HttpResponse.json({ data: 'secret' })
|
|
842
|
-
})
|
|
843
|
-
|
|
844
|
-
// GOOD - verify auth in mock
|
|
845
|
-
http.get('/api/protected', ({ request }) => {
|
|
846
|
-
const auth = request.headers.get('Authorization')
|
|
847
|
-
if (!auth) {
|
|
848
|
-
return new HttpResponse(null, { status: 401 })
|
|
849
|
-
}
|
|
850
|
-
return HttpResponse.json({ data: 'secret' })
|
|
851
|
-
})
|
|
852
|
-
```
|