@yabbadabbadev/pepito 0.1.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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +512 -0
- package/dist/failure-messages.d.ts +59 -0
- package/dist/failure-messages.js +145 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/matcher-types.d.ts +113 -0
- package/dist/matcher-types.js +1 -0
- package/dist/matchers.d.ts +17 -0
- package/dist/matchers.js +233 -0
- package/dist/mount.d.ts +45 -0
- package/dist/mount.js +61 -0
- package/dist/msw-events.d.ts +6 -0
- package/dist/msw-events.js +44 -0
- package/dist/network-singleton.d.ts +10 -0
- package/dist/network-singleton.js +16 -0
- package/dist/network.d.ts +63 -0
- package/dist/network.js +76 -0
- package/dist/request-descriptors.d.ts +79 -0
- package/dist/request-descriptors.js +49 -0
- package/dist/setup-network.d.ts +33 -0
- package/dist/setup-network.js +57 -0
- package/dist/spec-matching.d.ts +18 -0
- package/dist/spec-matching.js +66 -0
- package/dist/storage-cleanup.d.ts +34 -0
- package/dist/storage-cleanup.js +44 -0
- package/dist/traffic-registry.d.ts +91 -0
- package/dist/traffic-registry.js +131 -0
- package/package.json +92 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-08-14
|
|
4
|
+
|
|
5
|
+
First release: `setupNetwork`, `mount`, request descriptors (`get`, `post`,
|
|
6
|
+
`put`, `patch`, `del`, `query`, `request`) and the matchers
|
|
7
|
+
`toHaveBeenRequested`, `toHaveBeenRequestedTimes`, `toHaveBeenIntercepted`,
|
|
8
|
+
`toHaveRespondedWith` and `toHaveNoUnhandledRequests`, plus `network.log()`
|
|
9
|
+
and `network.idle()`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yabbadabbadev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# pepito
|
|
2
|
+
|
|
3
|
+
Network test utilities for Vitest browser mode: mount the application with
|
|
4
|
+
`mount` and assert on the traffic observed by MSW with `expect` matchers. It
|
|
5
|
+
doesn't abstract MSW — handlers are still declared with the usual
|
|
6
|
+
`http.get(...)` — it only provides the startup, the traffic registry and the
|
|
7
|
+
matchers to query it.
|
|
8
|
+
|
|
9
|
+
Published on npm as `@yabbadabbadev/pepito`; `pepito` remains the project's
|
|
10
|
+
code-name and this directory's name. See `CONTRIBUTING.md` to run the tests
|
|
11
|
+
and publish a version, and `ROADMAP.md` for what's out of scope for this
|
|
12
|
+
version.
|
|
13
|
+
|
|
14
|
+
## 1. Install and start
|
|
15
|
+
|
|
16
|
+
`react`, `react-dom`, `vitest`, `msw` and `vitest-browser-react` are
|
|
17
|
+
**peerDependencies**: install them if your project doesn't already have
|
|
18
|
+
them.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm i -D @yabbadabbadev/pepito msw vitest-browser-react
|
|
22
|
+
npx msw init public --save
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`npx msw init public --save` generates the service worker MSW needs in
|
|
26
|
+
browser mode (`public/mockServiceWorker.js`); it's a one-off, not a
|
|
27
|
+
day-to-day step — it's only repeated when upgrading MSW's version.
|
|
28
|
+
|
|
29
|
+
Call `setupNetwork` once, in a `setupFiles` file of `vitest.config` — never
|
|
30
|
+
inside a test:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// vitest.setup.ts
|
|
34
|
+
import { setupNetwork } from '@yabbadabbadev/pepito'
|
|
35
|
+
import { handlers } from './handlers'
|
|
36
|
+
|
|
37
|
+
setupNetwork(handlers)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// vitest.config.ts
|
|
42
|
+
export default defineConfig({
|
|
43
|
+
test: {
|
|
44
|
+
setupFiles: ['./vitest.setup.ts'],
|
|
45
|
+
browser: {/* … */},
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`setupNetwork`'s second argument passes straight through to
|
|
51
|
+
`worker.start()`, with no wrapper of its own — for example, to make a
|
|
52
|
+
request with no handler fail the test instead of just warning on the
|
|
53
|
+
console:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// vitest.setup.ts
|
|
57
|
+
import { setupNetwork } from '@yabbadabbadev/pepito'
|
|
58
|
+
import { handlers } from './handlers'
|
|
59
|
+
|
|
60
|
+
setupNetwork(handlers, { onUnhandledRequest: 'error' })
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Importing anything from `pepito` — here, `setupNetwork` — already brings the
|
|
64
|
+
network matchers along as `expect` types: there's no separate type
|
|
65
|
+
registration. If your test `tsconfig` doesn't include the setup file, `tsc`
|
|
66
|
+
won't see the augmentation and `expect(...).toHaveBeenRequested()` will
|
|
67
|
+
raise `TS2339` even though the test passes at runtime.
|
|
68
|
+
|
|
69
|
+
## 2. Mount the application
|
|
70
|
+
|
|
71
|
+
`mount` mounts with `vitest-browser-react` and returns its `screen`
|
|
72
|
+
unwrapped. It requires `setupNetwork` to have run first (section 1), **even
|
|
73
|
+
for a test with no network**: the coupling is deliberate — `mount` also
|
|
74
|
+
installs URL and storage cleanup between tests, not just the network — and
|
|
75
|
+
if it's missing, it fails immediately with a fix instruction.
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import { http, HttpResponse } from 'msw'
|
|
79
|
+
import { mount, get } from '@yabbadabbadev/pepito'
|
|
80
|
+
import { App } from '../src/App'
|
|
81
|
+
import { ProductListMother } from '../test/mothers/product-list-mother'
|
|
82
|
+
|
|
83
|
+
test('the catalog page renders the URL filter', async () => {
|
|
84
|
+
const screen = await mount(<App />, {
|
|
85
|
+
path: '/products?filter=bread',
|
|
86
|
+
network: [
|
|
87
|
+
http.get('/api/products', () =>
|
|
88
|
+
HttpResponse.json(ProductListMother.catalog()),
|
|
89
|
+
),
|
|
90
|
+
],
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
await expect.element(screen.getByText('filter: bread')).toBeVisible()
|
|
94
|
+
await expect(get('/api/products')).toHaveBeenRequested()
|
|
95
|
+
})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`path` is a **complete** same-origin URI starting with `/` — query and hash
|
|
99
|
+
included — because your application's router (`BrowserRouter` or whichever)
|
|
100
|
+
reads it from the document's real URL, not from a `MemoryRouter`. A
|
|
101
|
+
different origin isn't a valid `path`: it's mocked in the `handlers`, not in
|
|
102
|
+
the mount — see section 7.
|
|
103
|
+
|
|
104
|
+
`network` are this test's own MSW handlers: they're installed with
|
|
105
|
+
`worker.use()` before render, so they win over the suite's for the same
|
|
106
|
+
route, and `setupNetwork()` undoes them afterwards in its `afterEach`.
|
|
107
|
+
Neither option is required — `mount(<App />)` on its own just mounts:
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
import { mount } from '@yabbadabbadev/pepito'
|
|
111
|
+
import { App } from '../src/App'
|
|
112
|
+
|
|
113
|
+
test('mounts with no path or network of its own', async () => {
|
|
114
|
+
const screen = await mount(<App />)
|
|
115
|
+
|
|
116
|
+
await expect.element(screen.getByText('Product catalog')).toBeVisible()
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`path` can carry query and hash together, because both flow through the
|
|
121
|
+
router the same as the rest of the URI:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
const screen = await mount(<App />, { path: '/products?filter=bread#detail' })
|
|
125
|
+
|
|
126
|
+
await expect.element(screen.getByText('filter: bread')).toBeVisible()
|
|
127
|
+
await expect.element(screen.getByText('hash: #detail')).toBeVisible()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## 3. Assert a request
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { get, post } from '@yabbadabbadev/pepito'
|
|
134
|
+
|
|
135
|
+
await expect(get('/api/products')).toHaveBeenRequested()
|
|
136
|
+
await expect(get('/api/products')).toHaveBeenRequestedTimes(2)
|
|
137
|
+
await expect(post('/api/products')).not.toHaveBeenRequested()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`get`, `post`, `put`, `patch`, `del` and `query` describe the expected
|
|
141
|
+
request — all six are shortcuts for `request(method, path)` with the method
|
|
142
|
+
already fixed. For any other method use `request(method, path)` directly:
|
|
143
|
+
it's the escape hatch that covers even the ones MSW 2.15 still doesn't
|
|
144
|
+
expose as a handler helper:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { del, patch, put, query, request } from '@yabbadabbadev/pepito'
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
put('/api/products/1', { body: { product_name: 'Whole milk' } }),
|
|
151
|
+
).toHaveBeenRequested()
|
|
152
|
+
await expect(
|
|
153
|
+
patch('/api/products/1', { body: { stock: 3 } }),
|
|
154
|
+
).toHaveBeenRequested()
|
|
155
|
+
await expect(del('/api/products/1')).toHaveBeenRequested()
|
|
156
|
+
await expect(
|
|
157
|
+
query('/api/products', { searchParams: { filter: 'bread' } }),
|
|
158
|
+
).toHaveBeenRequested()
|
|
159
|
+
await expect(request('OPTIONS', '/api/products')).toHaveBeenRequested()
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Body and `searchParams` match by **subset**:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { get, post } from '@yabbadabbadev/pepito'
|
|
166
|
+
|
|
167
|
+
await expect(
|
|
168
|
+
get('/api/products', { searchParams: { filter: 'bread' } }),
|
|
169
|
+
).toHaveBeenRequested()
|
|
170
|
+
await expect(
|
|
171
|
+
post('/api/products', { body: { product_name: 'Whole milk' } }),
|
|
172
|
+
).toHaveBeenRequested()
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`post('/api/products', { body: { product_name: 'Whole milk' } })` matches
|
|
176
|
+
even if the real request also carries `id`. Pass `{ exact: true }` when you
|
|
177
|
+
need strict equality of the whole object, not just the keys you list in
|
|
178
|
+
`body`:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
await expect(
|
|
182
|
+
post('/api/products', {
|
|
183
|
+
body: { product_name: 'Whole milk' },
|
|
184
|
+
exact: true,
|
|
185
|
+
}),
|
|
186
|
+
).toHaveBeenRequested()
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
A `searchParams` key repeated in the real URL (`?tag=a&tag=b`) collapses to
|
|
190
|
+
its last value before comparing — the matcher can't tell that request apart
|
|
191
|
+
from one where the key appears only once.
|
|
192
|
+
|
|
193
|
+
The matchers retry because a request is an effect that follows the
|
|
194
|
+
interaction, just like `expect.element` — there's no need to wrap them in a
|
|
195
|
+
`waitFor`. `.not.toHaveBeenRequested()` and `toHaveBeenRequestedTimes` are
|
|
196
|
+
the exception: before deciding, they wait for the network to settle, so as
|
|
197
|
+
not to confuse a request that hasn't arrived yet with one that never
|
|
198
|
+
happened.
|
|
199
|
+
|
|
200
|
+
## 4. Intercepted or escaped
|
|
201
|
+
|
|
202
|
+
`toHaveBeenRequested` and `toHaveBeenIntercepted` assert different things:
|
|
203
|
+
|
|
204
|
+
| Matcher | What it asserts |
|
|
205
|
+
| ----------------------- | ------------------------------------------ |
|
|
206
|
+
| `toHaveBeenRequested` | The application made the request |
|
|
207
|
+
| `toHaveBeenIntercepted` | One of your handlers produced the response |
|
|
208
|
+
|
|
209
|
+
A handler with `passthrough()` satisfies the first and not the second: the
|
|
210
|
+
response came from the real network, not from your mock.
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { http, passthrough } from 'msw'
|
|
214
|
+
import { get } from '@yabbadabbadev/pepito'
|
|
215
|
+
|
|
216
|
+
// handler: http.get('/api/legacy', () => passthrough())
|
|
217
|
+
|
|
218
|
+
await fetch('/api/legacy')
|
|
219
|
+
|
|
220
|
+
await expect(get('/api/legacy')).toHaveBeenRequested() // passes
|
|
221
|
+
await expect(get('/api/legacy')).toHaveBeenIntercepted() // fails
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
To catch what doesn't even have a handler, the suite-wide guardrail:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
await expect.network().toHaveNoUnhandledRequests()
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`toHaveNoUnhandledRequests` hangs off `expect.network()`, not off a request
|
|
231
|
+
descriptor, because it doesn't describe one specific request but all the
|
|
232
|
+
observed traffic.
|
|
233
|
+
|
|
234
|
+
## 5. What the mock responded
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { get } from '@yabbadabbadev/pepito'
|
|
238
|
+
|
|
239
|
+
await expect(get('/api/products')).toHaveRespondedWith(500)
|
|
240
|
+
|
|
241
|
+
await expect(get('/api/products')).toHaveRespondedWith({
|
|
242
|
+
status: 200,
|
|
243
|
+
body: { total: 2 },
|
|
244
|
+
})
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
A bare number is the shorthand for `{ status }`. The `body`, if given,
|
|
248
|
+
matches by subset the same way as in request descriptors — `{ exact: true }`
|
|
249
|
+
for strict equality:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import { get } from '@yabbadabbadev/pepito'
|
|
253
|
+
import { ProductListMother } from '../test/mothers/product-list-mother'
|
|
254
|
+
|
|
255
|
+
await expect(get('/api/products')).toHaveRespondedWith({
|
|
256
|
+
status: 200,
|
|
257
|
+
body: ProductListMother.catalog(),
|
|
258
|
+
exact: true,
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`toHaveRespondedWith` also requires the request to have been intercepted: a
|
|
263
|
+
real response via `passthrough()` never counts, even if the status happens
|
|
264
|
+
to match.
|
|
265
|
+
|
|
266
|
+
## 6. Debug a failure
|
|
267
|
+
|
|
268
|
+
Failure messages carry the full observed traffic and a colored diff
|
|
269
|
+
(Vitest's `this.utils`, the same one native matchers use — no new
|
|
270
|
+
dependencies):
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
expect(received).toHaveBeenRequested(expected)
|
|
274
|
+
|
|
275
|
+
Expected: Object {
|
|
276
|
+
"body": undefined,
|
|
277
|
+
"method": "GET",
|
|
278
|
+
"path": "/api/products",
|
|
279
|
+
"searchParams": Object {
|
|
280
|
+
"filter": "chocolate",
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
- Expected
|
|
285
|
+
+ Received
|
|
286
|
+
|
|
287
|
+
{
|
|
288
|
+
"body": undefined,
|
|
289
|
+
"searchParams": {
|
|
290
|
+
- "filter": "chocolate",
|
|
291
|
+
+ "filter": "bread",
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
Observed traffic:
|
|
296
|
+
GET /api/products?filter=bread → 200 [matched/mocked]
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
(real output, captured without color; in your terminal `- Expected`/`+
|
|
300
|
+
Received` arrive in green and red)
|
|
301
|
+
|
|
302
|
+
To look at the traffic without anything failing, in the middle of a test
|
|
303
|
+
you're debugging:
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
import { network } from '@yabbadabbadev/pepito'
|
|
307
|
+
|
|
308
|
+
await fetch('/api/products')
|
|
309
|
+
await network.log() // dumps method, path, body and status to the console
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
If a failure message doesn't explain what you expected, that's a matcher
|
|
313
|
+
defect, not something to work around by hand: failure messages are part of
|
|
314
|
+
the product and are tested like any other output (see `CONTRIBUTING.md`).
|
|
315
|
+
|
|
316
|
+
## 7. Recipes
|
|
317
|
+
|
|
318
|
+
**Different responses across successive calls.** No `pepito` API is needed
|
|
319
|
+
for this: it's plain MSW, with chained handlers. The first one that matches
|
|
320
|
+
wins, and if it carries `{ once: true }` it deactivates after that first
|
|
321
|
+
time, so the request falls through to the next handler in the list:
|
|
322
|
+
|
|
323
|
+
```tsx
|
|
324
|
+
import { http, HttpResponse } from 'msw'
|
|
325
|
+
import { get, mount } from '@yabbadabbadev/pepito'
|
|
326
|
+
import { App } from '../src/App'
|
|
327
|
+
import { ProductListMother } from '../test/mothers/product-list-mother'
|
|
328
|
+
|
|
329
|
+
test('the second visit already sees the cached catalog', async () => {
|
|
330
|
+
await mount(<App />, {
|
|
331
|
+
network: [
|
|
332
|
+
http.get(
|
|
333
|
+
'/api/products',
|
|
334
|
+
() => HttpResponse.json(ProductListMother.empty()),
|
|
335
|
+
{ once: true },
|
|
336
|
+
),
|
|
337
|
+
http.get('/api/products', () =>
|
|
338
|
+
HttpResponse.json(ProductListMother.catalog()),
|
|
339
|
+
),
|
|
340
|
+
],
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
await fetch('/api/products') // 1st: the once handler responds and is spent
|
|
344
|
+
await fetch('/api/products') // 2nd: falls through to the handler below
|
|
345
|
+
await fetch('/api/products') // 3rd: the one below isn't once, keeps responding
|
|
346
|
+
|
|
347
|
+
await expect(get('/api/products')).toHaveBeenRequestedTimes(3)
|
|
348
|
+
await expect(get('/api/products')).toHaveRespondedWith({
|
|
349
|
+
status: 200,
|
|
350
|
+
body: ProductListMother.empty(),
|
|
351
|
+
})
|
|
352
|
+
await expect(get('/api/products')).toHaveRespondedWith({
|
|
353
|
+
status: 200,
|
|
354
|
+
body: ProductListMother.catalog(),
|
|
355
|
+
})
|
|
356
|
+
})
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Verified in this repo: first call → empty catalog, second and third → full
|
|
360
|
+
catalog (X→Y→Y). `setupNetwork()` undoes the test handlers in its
|
|
361
|
+
`afterEach` with `worker.resetHandlers()`, so every following test sees a
|
|
362
|
+
fresh `once` again, with no exhaustion carrying over between tests.
|
|
363
|
+
|
|
364
|
+
Each `toHaveRespondedWith` above finds its own entry in the registry — one
|
|
365
|
+
matches the empty response, the other the catalog — regardless of the order
|
|
366
|
+
the `expect`s are written in. What `pepito` doesn't have yet is a matcher
|
|
367
|
+
that asserts the ORDER between two identical requests (for example, "the
|
|
368
|
+
empty one before the catalog"): it's in `ROADMAP.md`, because the registry
|
|
369
|
+
is already chronological and only needs exposing.
|
|
370
|
+
|
|
371
|
+
For sequences odder than `once` can express well (three different
|
|
372
|
+
responses, or a condition that isn't "the first time"), a counter closure
|
|
373
|
+
declared inside the test itself is the alternative — with no changes to
|
|
374
|
+
`pepito`:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
test('three different responses for the same route', async () => {
|
|
378
|
+
let callCount = 0
|
|
379
|
+
|
|
380
|
+
await mount(<App />, {
|
|
381
|
+
network: [
|
|
382
|
+
http.get('/api/products', () => {
|
|
383
|
+
callCount += 1
|
|
384
|
+
if (callCount === 1) return HttpResponse.json([], { status: 202 })
|
|
385
|
+
if (callCount === 2) return HttpResponse.json([], { status: 500 })
|
|
386
|
+
return HttpResponse.json(ProductListMother.catalog())
|
|
387
|
+
}),
|
|
388
|
+
],
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
await fetch('/api/products')
|
|
392
|
+
await fetch('/api/products')
|
|
393
|
+
await fetch('/api/products')
|
|
394
|
+
|
|
395
|
+
await expect(get('/api/products')).toHaveRespondedWith(202)
|
|
396
|
+
await expect(get('/api/products')).toHaveRespondedWith(500)
|
|
397
|
+
})
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**Seeding `localStorage`/cookies before mounting.** The browser's storage is
|
|
401
|
+
real; no `mount` option is needed for this, a plain `setItem` before `await
|
|
402
|
+
mount(...)` is enough:
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
test('the catalog respects the saved filter', async () => {
|
|
406
|
+
localStorage.setItem('favoriteFilter', 'bread')
|
|
407
|
+
document.cookie = 'session=abc'
|
|
408
|
+
|
|
409
|
+
const screen = await mount(<App />)
|
|
410
|
+
|
|
411
|
+
await expect.element(screen.getByText('filter: bread')).toBeVisible()
|
|
412
|
+
})
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
**Fixtures with Mothers.** Mocked response payloads follow the house's
|
|
416
|
+
Mother/Builder pattern — each Mother models the shape of one endpoint's
|
|
417
|
+
response, with named factories for its variants; no loose literals and no
|
|
418
|
+
`structuredClone` with mutation, not even in examples copied from here:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
// test/mothers/product-list-mother.ts
|
|
422
|
+
interface ProductResponse {
|
|
423
|
+
id: number
|
|
424
|
+
product_name: string
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const milk = { id: 1, product_name: 'Whole milk' } satisfies ProductResponse
|
|
428
|
+
const bread = { id: 2, product_name: 'Country bread' } satisfies ProductResponse
|
|
429
|
+
|
|
430
|
+
export const ProductListMother = {
|
|
431
|
+
catalog: (): ProductResponse[] => [milk, bread],
|
|
432
|
+
empty: (): ProductResponse[] => [],
|
|
433
|
+
}
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
http.get('/api/products', () => HttpResponse.json(ProductListMother.catalog()))
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
An incidental payload that isn't an entity (`{ ok: true }`) doesn't need a
|
|
441
|
+
Mother. `pepito` doesn't export fixture infrastructure — Mothers belong to
|
|
442
|
+
each project's own domain.
|
|
443
|
+
|
|
444
|
+
**The API's host goes in the handler, not in the configuration.** There's no
|
|
445
|
+
`defaultHost`: the service worker intercepts cross-origin the same as
|
|
446
|
+
same-origin, so the host is a detail of the handler's URL.
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
http.get('https://api.example.com/products', () =>
|
|
450
|
+
HttpResponse.json(ProductListMother.catalog()),
|
|
451
|
+
)
|
|
452
|
+
// or with a wildcard:
|
|
453
|
+
http.get('*/products', () => HttpResponse.json(ProductListMother.catalog()))
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
`mount({ path })`, by contrast, does require same-origin — a different
|
|
457
|
+
origin there throws with an instruction, because `path` moves the test
|
|
458
|
+
document's URL, not the request you want to mock.
|
|
459
|
+
|
|
460
|
+
**Waiting for the network to settle before capturing.**
|
|
461
|
+
`toMatchScreenshot`'s native stabilizer waits for the frame to stop
|
|
462
|
+
changing, but a static `<p>Loading…</p>` is also a stable capture: without
|
|
463
|
+
waiting for the network, the baseline is the loading screen, not the real
|
|
464
|
+
content. Measured in the evaluation repo's
|
|
465
|
+
`docs/knowledge/regresion-visual-browser-mode.md`: 0 failures across 17
|
|
466
|
+
local runs + 3 in CI waiting for calm this way, with
|
|
467
|
+
baselines byte-identical to the ones anchored by `expect.element` to a
|
|
468
|
+
specific text. `network.idle()` waits with the same mechanism the network
|
|
469
|
+
matchers use, without asserting anything:
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
import { mount, network } from '@yabbadabbadev/pepito'
|
|
473
|
+
import { App } from '../src/App'
|
|
474
|
+
|
|
475
|
+
test('the catalog does not change visually', async () => {
|
|
476
|
+
const screen = await mount(<App />)
|
|
477
|
+
|
|
478
|
+
await network.idle()
|
|
479
|
+
|
|
480
|
+
await expect.element(screen.getByRole('main')).toMatchScreenshot('catalog')
|
|
481
|
+
})
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## 8. Cheat sheet
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
request('QUERY', '/api/products') // any method, including ones with no shortcut
|
|
488
|
+
get('/api/products', { searchParams: { filter: 'bread' } }) // subset of searchParams
|
|
489
|
+
post('/api/products', { body: { product_name: 'Whole milk' } }) // subset of body
|
|
490
|
+
put('/api/products/1', { body: { stock: 3 }, exact: true }) // strict equality
|
|
491
|
+
patch('/api/products/1', { body: { stock: 3 } })
|
|
492
|
+
del('/api/products/1')
|
|
493
|
+
query('/api/products', { searchParams: { filter: 'bread' } })
|
|
494
|
+
|
|
495
|
+
await expect(get('/api/products')).toHaveBeenRequested() // the app made the request
|
|
496
|
+
await expect(get('/api/products')).not.toHaveBeenRequested() // waits for calm first
|
|
497
|
+
await expect(get('/api/products')).toHaveBeenRequestedTimes(2) // exact count, network settled
|
|
498
|
+
await expect(get('/api/products')).toHaveBeenIntercepted() // one of your handlers responded
|
|
499
|
+
await expect(get('/api/products')).toHaveRespondedWith(500) // shorthand for { status: 500 }
|
|
500
|
+
await expect(get('/api/products')).toHaveRespondedWith({
|
|
501
|
+
status: 200,
|
|
502
|
+
body: { total: 2 }, // subset
|
|
503
|
+
})
|
|
504
|
+
await expect(get('/api/products')).toHaveRespondedWith({
|
|
505
|
+
status: 200,
|
|
506
|
+
body: { total: 2 },
|
|
507
|
+
exact: true, // strict equality
|
|
508
|
+
})
|
|
509
|
+
await expect.network().toHaveNoUnhandledRequests() // guardrail: nothing without a handler
|
|
510
|
+
await network.log() // dumps the observed traffic, without asserting
|
|
511
|
+
await network.idle() // waits for calm, without asserting — before capturing in visual regression
|
|
512
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { MatcherState } from '@vitest/expect';
|
|
2
|
+
import type { ExpectedResponse } from './matcher-types';
|
|
3
|
+
import type { RequestSpec } from './request-descriptors';
|
|
4
|
+
import type { ResolvedRequest } from './traffic-registry';
|
|
5
|
+
/** Dumps the observed traffic, one line per entry, to embed in a failure message. */
|
|
6
|
+
export declare function formatTraffic(traffic: ResolvedRequest[]): string;
|
|
7
|
+
/**
|
|
8
|
+
* Composes the network matchers' failure message: the matcher's hint, what
|
|
9
|
+
* was expected, a diff against the closest candidate (same method and path,
|
|
10
|
+
* even if it doesn't fully match) if one exists, and the full dump of the
|
|
11
|
+
* observed traffic.
|
|
12
|
+
*/
|
|
13
|
+
export declare function requestFailureMessage(messageContext: {
|
|
14
|
+
utils: MatcherState['utils'];
|
|
15
|
+
matcherName: string;
|
|
16
|
+
spec: RequestSpec;
|
|
17
|
+
traffic: ResolvedRequest[];
|
|
18
|
+
isNot: boolean;
|
|
19
|
+
}): string;
|
|
20
|
+
/**
|
|
21
|
+
* Composes the `toHaveBeenRequestedTimes` failure message: there's no
|
|
22
|
+
* "closest candidate" to show like in `requestFailureMessage`, but an
|
|
23
|
+
* expected count against the one actually observed, plus the full dump.
|
|
24
|
+
*/
|
|
25
|
+
export declare function requestCountFailureMessage(messageContext: {
|
|
26
|
+
utils: MatcherState['utils'];
|
|
27
|
+
spec: RequestSpec;
|
|
28
|
+
expectedCount: number;
|
|
29
|
+
foundCount: number;
|
|
30
|
+
traffic: ResolvedRequest[];
|
|
31
|
+
isNot: boolean;
|
|
32
|
+
}): string;
|
|
33
|
+
/**
|
|
34
|
+
* Composes the `toHaveNoUnhandledRequests` failure message: which requests
|
|
35
|
+
* arrived without a handler (method and path, the minimum needed to locate
|
|
36
|
+
* them in the code) followed by the full traffic dump, in case the missing
|
|
37
|
+
* handler becomes obvious when seen alongside the rest.
|
|
38
|
+
*/
|
|
39
|
+
export declare function noUnhandledRequestsFailureMessage(messageContext: {
|
|
40
|
+
utils: MatcherState['utils'];
|
|
41
|
+
unhandledEntries: ResolvedRequest[];
|
|
42
|
+
traffic: ResolvedRequest[];
|
|
43
|
+
isNot: boolean;
|
|
44
|
+
}): string;
|
|
45
|
+
/**
|
|
46
|
+
* Composes the `toHaveRespondedWith` failure message: the matcher's hint,
|
|
47
|
+
* the expected response, a diff against the closest intercepted-and-matched
|
|
48
|
+
* candidate (even if it doesn't match on status or body) if one exists, and
|
|
49
|
+
* the full dump of the observed traffic. The candidate requires `matched &&
|
|
50
|
+
* mocked` because a passthrough entry can match the request's spec without
|
|
51
|
+
* ever having been intercepted: showing it as "close" would be misleading.
|
|
52
|
+
*/
|
|
53
|
+
export declare function respondedWithFailureMessage(messageContext: {
|
|
54
|
+
utils: MatcherState['utils'];
|
|
55
|
+
spec: RequestSpec;
|
|
56
|
+
expected: ExpectedResponse;
|
|
57
|
+
traffic: ResolvedRequest[];
|
|
58
|
+
isNot: boolean;
|
|
59
|
+
}): string;
|