msw-fetch-mock 0.3.3 → 0.3.4
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 +8 -6
- package/README.zh-TW.md +245 -0
- package/docs/api.md +4 -4
- package/docs/api.zh-TW.md +682 -0
- package/docs/cloudflare-migration.zh-TW.md +80 -0
- package/docs/msw-v1-legacy.zh-TW.md +94 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/msw-fetch-mock)
|
|
5
5
|
[](https://github.com/recca0120/msw-fetch-mock/blob/main/LICENSE.md)
|
|
6
6
|
|
|
7
|
+
[English](README.md) | [繁體中文](README.zh-TW.md)
|
|
8
|
+
|
|
7
9
|
Undici-style fetch mock API built on [MSW](https://mswjs.io/) (Mock Service Worker).
|
|
8
10
|
|
|
9
11
|
If you're familiar with Cloudflare Workers' `fetchMock` (from `cloudflare:test`) or Node.js undici's `MockAgent`, you already know this API.
|
|
@@ -28,7 +30,7 @@ npm install -D msw-fetch-mock msw
|
|
|
28
30
|
### Node.js (Vitest, Jest)
|
|
29
31
|
|
|
30
32
|
```typescript
|
|
31
|
-
// Works with
|
|
33
|
+
// Works with root import or explicit /node subpath
|
|
32
34
|
import { fetchMock } from 'msw-fetch-mock';
|
|
33
35
|
// import { fetchMock } from 'msw-fetch-mock/node';
|
|
34
36
|
|
|
@@ -78,11 +80,10 @@ If you already use MSW, pass your server to share a single interceptor:
|
|
|
78
80
|
```typescript
|
|
79
81
|
import { setupServer } from 'msw/node';
|
|
80
82
|
import { http, HttpResponse } from 'msw';
|
|
81
|
-
import {
|
|
82
|
-
import { NodeMswAdapter } from 'msw-fetch-mock/node';
|
|
83
|
+
import { createFetchMock } from 'msw-fetch-mock';
|
|
83
84
|
|
|
84
85
|
const server = setupServer(http.get('/api/users', () => HttpResponse.json([{ id: 1 }])));
|
|
85
|
-
const fetchMock =
|
|
86
|
+
const fetchMock = createFetchMock(server);
|
|
86
87
|
|
|
87
88
|
beforeAll(() => server.listen());
|
|
88
89
|
afterAll(() => server.close());
|
|
@@ -92,7 +93,7 @@ afterEach(() => {
|
|
|
92
93
|
});
|
|
93
94
|
```
|
|
94
95
|
|
|
95
|
-
> **Note:** Only one MSW server can be active at a time. If
|
|
96
|
+
> **Note:** Only one MSW server can be active at a time. If a server is already listening, standalone `activate()` will throw an error. Use `createFetchMock(server)` to share an existing server.
|
|
96
97
|
|
|
97
98
|
### Legacy (MSW v1)
|
|
98
99
|
|
|
@@ -204,6 +205,7 @@ E2E tests run on every CI push across these environments:
|
|
|
204
205
|
| Jest CJS | CJS (require) | Jest |
|
|
205
206
|
| Node.js Test | ESM (import) | Node test runner |
|
|
206
207
|
| Node.js CJS | CJS (require) | Node test runner |
|
|
208
|
+
| Legacy CJS | CJS (require) | Jest (MSW v1) |
|
|
207
209
|
| Vitest Browser | ESM (import) | Vitest + Playwright |
|
|
208
210
|
|
|
209
211
|
## Documentation
|
|
@@ -236,7 +238,7 @@ pnpm test:e2e -- node-cjs
|
|
|
236
238
|
pnpm test:e2e -- --all
|
|
237
239
|
```
|
|
238
240
|
|
|
239
|
-
Available suites: `jest-esm`, `jest-cjs`, `node-test`, `node-cjs`, `vitest-browser`
|
|
241
|
+
Available suites: `jest-esm`, `jest-cjs`, `node-test`, `node-cjs`, `legacy-cjs`, `vitest-browser`
|
|
240
242
|
|
|
241
243
|
## License
|
|
242
244
|
|
package/README.zh-TW.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# msw-fetch-mock
|
|
2
|
+
|
|
3
|
+
[](https://github.com/recca0120/msw-fetch-mock/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/msw-fetch-mock)
|
|
5
|
+
[](https://github.com/recca0120/msw-fetch-mock/blob/main/LICENSE.md)
|
|
6
|
+
|
|
7
|
+
[English](README.md) | [繁體中文](README.zh-TW.md)
|
|
8
|
+
|
|
9
|
+
基於 [MSW](https://mswjs.io/)(Mock Service Worker)的 Undici 風格 fetch mock API。
|
|
10
|
+
|
|
11
|
+
如果你熟悉 Cloudflare Workers 的 `fetchMock`(來自 `cloudflare:test`)或 Node.js undici 的 `MockAgent`,你已經會使用這個 API 了。
|
|
12
|
+
|
|
13
|
+
透過 subpath exports 同時支援 **Node.js**(`msw/node`)和**瀏覽器**(`msw/browser`)環境。
|
|
14
|
+
|
|
15
|
+
## 系統需求
|
|
16
|
+
|
|
17
|
+
- **Node.js** >= 18
|
|
18
|
+
- **MSW** ^1.0.0(透過 `/legacy`)或 ^2.12.7
|
|
19
|
+
|
|
20
|
+
## 安裝
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -D msw-fetch-mock msw
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`msw` 是 peer dependency — 需要自行安裝你的版本。
|
|
27
|
+
|
|
28
|
+
## 快速開始
|
|
29
|
+
|
|
30
|
+
### Node.js(Vitest、Jest)
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
// 可使用根路徑匯入或明確的 /node 子路徑
|
|
34
|
+
import { fetchMock } from 'msw-fetch-mock';
|
|
35
|
+
// import { fetchMock } from 'msw-fetch-mock/node';
|
|
36
|
+
|
|
37
|
+
beforeAll(() => fetchMock.activate({ onUnhandledRequest: 'error' }));
|
|
38
|
+
afterAll(() => fetchMock.deactivate());
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
fetchMock.assertNoPendingInterceptors();
|
|
41
|
+
fetchMock.reset();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('模擬 GET 請求', async () => {
|
|
45
|
+
fetchMock
|
|
46
|
+
.get('https://api.example.com')
|
|
47
|
+
.intercept({ path: '/users', method: 'GET' })
|
|
48
|
+
.reply(200, { users: [{ id: '1', name: 'Alice' }] });
|
|
49
|
+
|
|
50
|
+
const res = await fetch('https://api.example.com/users');
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
|
|
53
|
+
expect(data.users).toHaveLength(1);
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 瀏覽器(Storybook、Vitest Browser Mode)
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { setupWorker } from 'msw/browser';
|
|
61
|
+
import { createFetchMock } from 'msw-fetch-mock/browser';
|
|
62
|
+
|
|
63
|
+
const worker = setupWorker();
|
|
64
|
+
const fetchMock = createFetchMock(worker);
|
|
65
|
+
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
await fetchMock.activate({ onUnhandledRequest: 'error' });
|
|
68
|
+
});
|
|
69
|
+
afterAll(() => fetchMock.deactivate());
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
fetchMock.assertNoPendingInterceptors();
|
|
72
|
+
fetchMock.reset();
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 搭配現有的 MSW server
|
|
77
|
+
|
|
78
|
+
如果你已經在使用 MSW,可以傳入你的 server 來共用同一個攔截器:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import { setupServer } from 'msw/node';
|
|
82
|
+
import { http, HttpResponse } from 'msw';
|
|
83
|
+
import { createFetchMock } from 'msw-fetch-mock';
|
|
84
|
+
|
|
85
|
+
const server = setupServer(http.get('/api/users', () => HttpResponse.json([{ id: 1 }])));
|
|
86
|
+
const fetchMock = createFetchMock(server);
|
|
87
|
+
|
|
88
|
+
beforeAll(() => server.listen());
|
|
89
|
+
afterAll(() => server.close());
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
server.resetHandlers();
|
|
92
|
+
fetchMock.assertNoPendingInterceptors();
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
> **注意:** 同一時間只能有一個 MSW server 處於啟動狀態。如果已有 server 在監聽中,獨立模式的 `activate()` 會拋出錯誤。請使用 `createFetchMock(server)` 來共用現有的 server。
|
|
97
|
+
|
|
98
|
+
### Legacy(MSW v1)
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
import { rest } from 'msw';
|
|
102
|
+
import { setupServer } from 'msw/node';
|
|
103
|
+
import { createFetchMock } from 'msw-fetch-mock/legacy';
|
|
104
|
+
|
|
105
|
+
const server = setupServer();
|
|
106
|
+
const fetchMock = createFetchMock(rest, server);
|
|
107
|
+
|
|
108
|
+
beforeAll(() => fetchMock.activate());
|
|
109
|
+
afterAll(() => fetchMock.deactivate());
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
fetchMock.assertNoPendingInterceptors();
|
|
112
|
+
fetchMock.reset();
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
詳見 [MSW v1 Legacy 指南](docs/msw-v1-legacy.zh-TW.md)。
|
|
117
|
+
|
|
118
|
+
## 未處理的請求
|
|
119
|
+
|
|
120
|
+
預設情況下 `activate()` 使用 `'error'` 模式 — 未匹配的請求會導致 `fetch()` 拒絕。這包含對**已消耗**攔截器的請求(一次性攔截器使用後,其 handler 會從 MSW 中移除)。
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// 拒絕未匹配的請求(預設行為)
|
|
124
|
+
fetchMock.activate();
|
|
125
|
+
fetchMock.activate({ onUnhandledRequest: 'error' });
|
|
126
|
+
|
|
127
|
+
// 記錄警告但允許通過
|
|
128
|
+
fetchMock.activate({ onUnhandledRequest: 'warn' });
|
|
129
|
+
|
|
130
|
+
// 靜默允許通過
|
|
131
|
+
fetchMock.activate({ onUnhandledRequest: 'bypass' });
|
|
132
|
+
|
|
133
|
+
// 自訂回呼
|
|
134
|
+
fetchMock.activate({
|
|
135
|
+
onUnhandledRequest: (request, print) => {
|
|
136
|
+
if (request.url.includes('/health')) return; // 忽略
|
|
137
|
+
print.error(); // 阻擋其他所有請求
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
> `enableNetConnect()` 的優先順序高於 `onUnhandledRequest` — 允許的主機一律直接通過。
|
|
143
|
+
|
|
144
|
+
## API 概覽
|
|
145
|
+
|
|
146
|
+
### 匯入路徑
|
|
147
|
+
|
|
148
|
+
| 路徑 | 環境 | MSW 版本 |
|
|
149
|
+
| ------------------------ | ----------------------------- | -------- |
|
|
150
|
+
| `msw-fetch-mock` | Node.js(re-exports `/node`) | v2 |
|
|
151
|
+
| `msw-fetch-mock/node` | Node.js | v2 |
|
|
152
|
+
| `msw-fetch-mock/browser` | 瀏覽器 | v2 |
|
|
153
|
+
| `msw-fetch-mock/legacy` | Node.js(MSW v1) | v1 |
|
|
154
|
+
|
|
155
|
+
### `fetchMock`(單例)
|
|
156
|
+
|
|
157
|
+
預先建立的 `FetchMock` 實例,適用於獨立使用。匯入後直接呼叫 `activate()` 即可 — 無需額外設定。
|
|
158
|
+
可從 `msw-fetch-mock` 和 `msw-fetch-mock/node` 匯入。
|
|
159
|
+
|
|
160
|
+
### `createFetchMock(server?)` / `createFetchMock(worker)`
|
|
161
|
+
|
|
162
|
+
建立 `FetchMock` 的工廠函式,會自動搭配對應的 adapter。
|
|
163
|
+
|
|
164
|
+
- Node:`createFetchMock(server?)` — 可選擇性傳入現有的 MSW `SetupServer`
|
|
165
|
+
- 瀏覽器:`createFetchMock(worker)` — 傳入 MSW `SetupWorker`(必要)
|
|
166
|
+
- Legacy:`createFetchMock(rest, server?)` — 傳入 MSW v1 的 `rest` 物件
|
|
167
|
+
|
|
168
|
+
### `new FetchMock(adapter?)`
|
|
169
|
+
|
|
170
|
+
使用明確的 `MswAdapter` 建立 `FetchMock` 實例。可使用 `NodeMswAdapter` 或 `BrowserMswAdapter`。
|
|
171
|
+
|
|
172
|
+
### 攔截與回應
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
fetchMock
|
|
176
|
+
.get(origin) // string、RegExp 或 function
|
|
177
|
+
.intercept({ path, method, headers, body, query }) // 匹配條件
|
|
178
|
+
.reply(status, body, options) // 定義回應
|
|
179
|
+
.times(n) / .persist(); // 重複控制
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### 呼叫歷史
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
fetchMock.calls.lastCall(); // 最近一次
|
|
186
|
+
fetchMock.calls.firstCall(); // 最早一次
|
|
187
|
+
fetchMock.calls.nthCall(2); // 第 2 次呼叫(1-indexed)
|
|
188
|
+
fetchMock.calls.filterCalls({ method: 'POST', path: '/users' }, { operator: 'AND' });
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### 斷言與清理
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
fetchMock.assertNoPendingInterceptors(); // 若有未消耗的攔截器則拋出錯誤
|
|
195
|
+
fetchMock.reset(); // 清除攔截器 + 呼叫歷史 + handlers
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## 測試環境
|
|
199
|
+
|
|
200
|
+
每次 CI 推送都會在以下環境執行 E2E 測試:
|
|
201
|
+
|
|
202
|
+
| 環境 | 模組系統 | 測試框架 |
|
|
203
|
+
| -------------- | ------------- | ------------------- |
|
|
204
|
+
| Jest ESM | ESM (import) | Jest |
|
|
205
|
+
| Jest CJS | CJS (require) | Jest |
|
|
206
|
+
| Node.js Test | ESM (import) | Node test runner |
|
|
207
|
+
| Node.js CJS | CJS (require) | Node test runner |
|
|
208
|
+
| Legacy CJS | CJS (require) | Jest (MSW v1) |
|
|
209
|
+
| Vitest Browser | ESM (import) | Vitest + Playwright |
|
|
210
|
+
|
|
211
|
+
## 文件
|
|
212
|
+
|
|
213
|
+
- [API 參考](docs/api.zh-TW.md) — 完整的 API 細節、匹配選項、回應回呼
|
|
214
|
+
- [Cloudflare Workers 遷移指南](docs/cloudflare-migration.zh-TW.md) — 從 `cloudflare:test` fetchMock 遷移
|
|
215
|
+
- [MSW v1 Legacy 指南](docs/msw-v1-legacy.zh-TW.md) — 搭配 MSW v1 使用 msw-fetch-mock
|
|
216
|
+
|
|
217
|
+
## 開發
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pnpm install
|
|
221
|
+
pnpm build # 使用 tsup 建置
|
|
222
|
+
pnpm test # 單元測試(vitest)
|
|
223
|
+
pnpm test:e2e # E2E 測試(jest-esm、jest-cjs、node-test、node-cjs、legacy-cjs)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### E2E 測試
|
|
227
|
+
|
|
228
|
+
E2E 測試驗證已發佈的套件能在不同 runtime 和模組系統下正常運作。腳本會建置、透過 `npm pack` 打包 tarball,然後安裝到每個 `e2e/` 專案中 — 完全模擬 CI 流程。
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
# 執行預設套組(跳過 vitest-browser)
|
|
232
|
+
pnpm test:e2e
|
|
233
|
+
|
|
234
|
+
# 執行單一套組
|
|
235
|
+
pnpm test:e2e -- node-cjs
|
|
236
|
+
|
|
237
|
+
# 執行所有套組,包含 vitest-browser(會自動安裝 Playwright)
|
|
238
|
+
pnpm test:e2e -- --all
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
可用套組:`jest-esm`、`jest-cjs`、`node-test`、`node-cjs`、`legacy-cjs`、`vitest-browser`
|
|
242
|
+
|
|
243
|
+
## 授權
|
|
244
|
+
|
|
245
|
+
[MIT](LICENSE.md)
|
package/docs/api.md
CHANGED
|
@@ -425,7 +425,7 @@ Returns: `MockReplyChain`
|
|
|
425
425
|
| `callback` | `SingleReplyCallback` | Full-control callback |
|
|
426
426
|
| `options` | `{ headers?: Record<string, string> }` | Response headers |
|
|
427
427
|
|
|
428
|
-
### `interceptor.replyWithError(error)`
|
|
428
|
+
### `interceptor.replyWithError(error?)`
|
|
429
429
|
|
|
430
430
|
Replies with a network error (simulates a connection failure).
|
|
431
431
|
|
|
@@ -435,9 +435,9 @@ Replies with a network error (simulates a connection failure).
|
|
|
435
435
|
|
|
436
436
|
Returns: `MockReplyChain`
|
|
437
437
|
|
|
438
|
-
| Parameter | Type | Description
|
|
439
|
-
| --------- | ------- |
|
|
440
|
-
| `error` | `Error` |
|
|
438
|
+
| Parameter | Type | Required | Description |
|
|
439
|
+
| --------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
440
|
+
| `error` | `Error` | No | Optional error instance. Accepted for API compatibility but not used internally — the response is always a generic network error. |
|
|
441
441
|
|
|
442
442
|
---
|
|
443
443
|
|
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
# API 參考
|
|
2
|
+
|
|
3
|
+
## 匯入路徑
|
|
4
|
+
|
|
5
|
+
| 路徑 | 環境 | MSW 版本 |
|
|
6
|
+
| ------------------------ | ----------------------------- | -------- |
|
|
7
|
+
| `msw-fetch-mock` | Node.js(re-exports `/node`) | v2 |
|
|
8
|
+
| `msw-fetch-mock/node` | Node.js | v2 |
|
|
9
|
+
| `msw-fetch-mock/browser` | 瀏覽器 | v2 |
|
|
10
|
+
| `msw-fetch-mock/legacy` | Node.js(MSW v1) | v1 |
|
|
11
|
+
|
|
12
|
+
## `fetchMock`(單例)
|
|
13
|
+
|
|
14
|
+
預先建立的 `FetchMock` 實例,適用於獨立的 Node.js 使用。無需額外設定 — 匯入後呼叫 `activate()` 即可。
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { fetchMock } from 'msw-fetch-mock';
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
await fetchMock.activate({ onUnhandledRequest: 'error' });
|
|
21
|
+
});
|
|
22
|
+
afterAll(() => fetchMock.deactivate());
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
fetchMock.assertNoPendingInterceptors();
|
|
25
|
+
fetchMock.reset();
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## `createFetchMock(server?)`(Node)
|
|
30
|
+
|
|
31
|
+
建立搭配 `NodeMswAdapter` 的 `FetchMock`。可選擇性傳入現有的 MSW server。
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { createFetchMock } from 'msw-fetch-mock/node';
|
|
35
|
+
import { setupServer } from 'msw/node';
|
|
36
|
+
|
|
37
|
+
// 獨立模式
|
|
38
|
+
const fetchMock = createFetchMock();
|
|
39
|
+
|
|
40
|
+
// 搭配外部 MSW server
|
|
41
|
+
const server = setupServer();
|
|
42
|
+
const fetchMock = createFetchMock(server);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## `createFetchMock(worker)`(瀏覽器)
|
|
46
|
+
|
|
47
|
+
建立搭配 `BrowserMswAdapter` 的 `FetchMock`。需要傳入 MSW worker。
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { setupWorker } from 'msw/browser';
|
|
51
|
+
import { createFetchMock } from 'msw-fetch-mock/browser';
|
|
52
|
+
|
|
53
|
+
const worker = setupWorker();
|
|
54
|
+
const fetchMock = createFetchMock(worker);
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
await fetchMock.activate({ onUnhandledRequest: 'error' });
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## `createFetchMock(rest, server?)`(Legacy)
|
|
62
|
+
|
|
63
|
+
建立適用於 MSW v1 環境的 `FetchMock`。詳見 [MSW v1 Legacy 指南](msw-v1-legacy.zh-TW.md)。
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { rest } from 'msw';
|
|
67
|
+
import { setupServer } from 'msw/node';
|
|
68
|
+
import { createFetchMock } from 'msw-fetch-mock/legacy';
|
|
69
|
+
|
|
70
|
+
const server = setupServer();
|
|
71
|
+
const fetchMock = createFetchMock(rest, server);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## `new FetchMock(adapter?)`
|
|
75
|
+
|
|
76
|
+
使用明確的 `MswAdapter` 建立 `FetchMock` 實例。
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { FetchMock } from 'msw-fetch-mock';
|
|
80
|
+
import { NodeMswAdapter } from 'msw-fetch-mock/node';
|
|
81
|
+
import { BrowserMswAdapter } from 'msw-fetch-mock/browser';
|
|
82
|
+
|
|
83
|
+
// Node 搭配外部 server
|
|
84
|
+
const fetchMock = new FetchMock(new NodeMswAdapter(server));
|
|
85
|
+
|
|
86
|
+
// 瀏覽器搭配 worker
|
|
87
|
+
const fetchMock = new FetchMock(new BrowserMswAdapter(worker));
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| 參數 | 型別 | 必要 | 說明 |
|
|
91
|
+
| --------- | ------------ | ---- | -------------------------------------------- |
|
|
92
|
+
| `adapter` | `MswAdapter` | 否 | 環境 adapter。建議改用 `createFetchMock()`。 |
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## `FetchMock`
|
|
97
|
+
|
|
98
|
+
### 生命週期
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
await fetchMock.activate(options?); // 開始攔截(非同步 — 瀏覽器需要 worker.start())
|
|
102
|
+
fetchMock.deactivate(); // 停止攔截
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
> `activate()` 回傳 `Promise<void>`。在 Node.js 中,promise 會同步解析。在瀏覽器中,會等待 Service Worker 啟動。Vitest 和 Jest 原生支援非同步的 `beforeAll`。
|
|
106
|
+
>
|
|
107
|
+
> **衝突偵測(僅 Node):** 在獨立模式下,`activate()` 會檢查 `globalThis.fetch` 是否已被 MSW 修補。若是,會拋出錯誤,引導你使用 `createFetchMock(server)` 來共用現有 server。
|
|
108
|
+
|
|
109
|
+
#### `ActivateOptions`
|
|
110
|
+
|
|
111
|
+
| 屬性 | 型別 | 預設值 | 說明 |
|
|
112
|
+
| -------------------- | -------------------- | --------- | -------------------- |
|
|
113
|
+
| `onUnhandledRequest` | `OnUnhandledRequest` | `'error'` | 如何處理未匹配的請求 |
|
|
114
|
+
|
|
115
|
+
#### `OnUnhandledRequest`
|
|
116
|
+
|
|
117
|
+
| 值 | 行為 |
|
|
118
|
+
| -------------------------- | ---------------------------------------------------------------------------------------- |
|
|
119
|
+
| `'error'` | MSW 印出錯誤且 `fetch()` 以 `InternalError` 拒絕 |
|
|
120
|
+
| `'warn'` | MSW 印出警告;請求直接通過到實際網路 |
|
|
121
|
+
| `'bypass'` | 靜默通過到實際網路 |
|
|
122
|
+
| `(request, print) => void` | 自訂回呼。呼叫 `print.error()` 阻擋或 `print.warning()` 警告。不呼叫任何一個則靜默放行。 |
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
// 預設 — 拒絕未匹配的請求
|
|
126
|
+
await fetchMock.activate();
|
|
127
|
+
await fetchMock.activate({ onUnhandledRequest: 'error' });
|
|
128
|
+
|
|
129
|
+
// 自訂回呼
|
|
130
|
+
await fetchMock.activate({
|
|
131
|
+
onUnhandledRequest: (request, print) => {
|
|
132
|
+
if (new URL(request.url).pathname === '/health') return;
|
|
133
|
+
print.error();
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
> **已消耗的攔截器:** 一次性攔截器完全消耗後,其 MSW handler 會被移除。後續對該 URL 的請求會被視為未處理,並經由 `onUnhandledRequest` 處理。這可防止已消耗的攔截器靜默通過。
|
|
139
|
+
>
|
|
140
|
+
> **優先順序:** `enableNetConnect()` 的優先順序高於 `onUnhandledRequest` — 允許的主機一律直接通過,不受未處理請求模式影響。
|
|
141
|
+
|
|
142
|
+
### `fetchMock.calls`
|
|
143
|
+
|
|
144
|
+
回傳 `MockCallHistory` 實例,用於檢視和管理已記錄的請求。
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// 檢查呼叫次數
|
|
148
|
+
expect(fetchMock.calls.length).toBe(3);
|
|
149
|
+
|
|
150
|
+
// 檢視最後一次呼叫
|
|
151
|
+
const last = fetchMock.calls.lastCall();
|
|
152
|
+
|
|
153
|
+
// 清除歷史
|
|
154
|
+
fetchMock.calls.clear();
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### `fetchMock.get(origin)`
|
|
158
|
+
|
|
159
|
+
回傳範圍限定在指定 origin 的 `MockPool`。`origin` 參數接受三種形式:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// 字串 — 精確的 origin 匹配
|
|
163
|
+
const pool = fetchMock.get('https://api.example.com');
|
|
164
|
+
|
|
165
|
+
// RegExp — 對 URL origin 進行匹配
|
|
166
|
+
const pool = fetchMock.get(/\.example\.com$/);
|
|
167
|
+
|
|
168
|
+
// 函式 — 自訂 origin 判斷
|
|
169
|
+
const pool = fetchMock.get((origin) => origin.startsWith('https://'));
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| 參數 | 型別 | 必要 | 說明 |
|
|
173
|
+
| -------- | ------------------------------------------------- | ---- | ------------- |
|
|
174
|
+
| `origin` | `string \| RegExp \| (origin: string) => boolean` | 是 | Origin 匹配器 |
|
|
175
|
+
|
|
176
|
+
### `fetchMock.disableNetConnect()`
|
|
177
|
+
|
|
178
|
+
阻止所有實際的網路請求。未匹配的請求會拋出錯誤。
|
|
179
|
+
|
|
180
|
+
### `fetchMock.enableNetConnect(matcher?)`
|
|
181
|
+
|
|
182
|
+
允許實際的網路請求通過。不帶參數時允許所有請求。帶 matcher 時僅匹配的主機可通過。
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
// 允許所有網路請求
|
|
186
|
+
fetchMock.enableNetConnect();
|
|
187
|
+
|
|
188
|
+
// 允許特定主機(精確匹配)
|
|
189
|
+
fetchMock.enableNetConnect('api.example.com');
|
|
190
|
+
|
|
191
|
+
// 允許匹配 RegExp 的主機
|
|
192
|
+
fetchMock.enableNetConnect(/\.example\.com$/);
|
|
193
|
+
|
|
194
|
+
// 允許匹配函式的主機
|
|
195
|
+
fetchMock.enableNetConnect((host) => host.endsWith('.test'));
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
| 參數 | 型別 | 必要 | 說明 |
|
|
199
|
+
| --------- | ----------------------------------------------- | ---- | ---------------------------- |
|
|
200
|
+
| `matcher` | `string \| RegExp \| (host: string) => boolean` | 否 | 主機匹配器。省略時允許全部。 |
|
|
201
|
+
|
|
202
|
+
### `fetchMock.defaultReplyHeaders(headers)`
|
|
203
|
+
|
|
204
|
+
設定每個回應都會包含的預設 headers。每個回應的 headers 會與預設值合併並覆蓋。
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
fetchMock.defaultReplyHeaders({
|
|
208
|
+
'x-request-id': 'test-123',
|
|
209
|
+
'cache-control': 'no-store',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// 此回應會同時包含 x-request-id 和 content-type
|
|
213
|
+
fetchMock
|
|
214
|
+
.get('https://api.example.com')
|
|
215
|
+
.intercept({ path: '/data' })
|
|
216
|
+
.reply(200, { ok: true }, { headers: { 'content-type': 'application/json' } });
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
> `reset()` 會清除預設回應 headers。
|
|
220
|
+
|
|
221
|
+
| 參數 | 型別 | 必要 | 說明 |
|
|
222
|
+
| --------- | ------------------------ | ---- | -------------------------- |
|
|
223
|
+
| `headers` | `Record<string, string>` | 是 | 每個回應都要包含的 headers |
|
|
224
|
+
|
|
225
|
+
### `fetchMock.enableCallHistory()`
|
|
226
|
+
|
|
227
|
+
啟用呼叫歷史記錄。這是預設狀態。
|
|
228
|
+
|
|
229
|
+
### `fetchMock.disableCallHistory()`
|
|
230
|
+
|
|
231
|
+
停用呼叫歷史記錄。請求仍會被攔截和回應,但不會被記錄。適用於效能敏感的測試以減少開銷。
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
fetchMock.disableCallHistory();
|
|
235
|
+
// ... 請求被攔截但不被記錄
|
|
236
|
+
fetchMock.enableCallHistory();
|
|
237
|
+
// ... 請求現在會被記錄
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### `fetchMock.getCallHistory()`
|
|
241
|
+
|
|
242
|
+
回傳 `MockCallHistory` 實例。Cloudflare 相容的 `fetchMock.calls` 別名。
|
|
243
|
+
|
|
244
|
+
### `fetchMock.clearCallHistory()`
|
|
245
|
+
|
|
246
|
+
清除所有已記錄的呼叫。Cloudflare 相容的 `fetchMock.calls.clear()` 別名。
|
|
247
|
+
|
|
248
|
+
### `fetchMock.clearAllCallHistory()`
|
|
249
|
+
|
|
250
|
+
`clearCallHistory()` 的別名。
|
|
251
|
+
|
|
252
|
+
### `fetchMock.assertNoPendingInterceptors()`
|
|
253
|
+
|
|
254
|
+
若有任何已註冊的攔截器尚未被消耗,會拋出錯誤。這是一個**純斷言** — 不會清除呼叫歷史、攔截器或 handlers。使用 `reset()` 來清理狀態。
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
afterEach(() => {
|
|
258
|
+
fetchMock.assertNoPendingInterceptors();
|
|
259
|
+
fetchMock.reset();
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### `fetchMock.reset()`
|
|
264
|
+
|
|
265
|
+
清除所有攔截器、呼叫歷史、預設回應 headers 和 MSW handlers。將實例重設為乾淨狀態,但不會停止 server。建議在 `afterEach` 中於斷言無待處理攔截器之後呼叫。
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
afterEach(() => {
|
|
269
|
+
fetchMock.assertNoPendingInterceptors();
|
|
270
|
+
fetchMock.reset();
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### `fetchMock.pendingInterceptors()`
|
|
275
|
+
|
|
276
|
+
回傳未消耗的攔截器陣列,包含中繼資料:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
interface PendingInterceptor {
|
|
280
|
+
origin: string;
|
|
281
|
+
path: string;
|
|
282
|
+
method: string;
|
|
283
|
+
consumed: boolean;
|
|
284
|
+
times: number;
|
|
285
|
+
timesInvoked: number;
|
|
286
|
+
persist: boolean;
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## `MockPool`
|
|
293
|
+
|
|
294
|
+
### `pool.intercept(options)`
|
|
295
|
+
|
|
296
|
+
註冊一個用於匹配請求的攔截器。
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
pool.intercept({
|
|
300
|
+
path: '/users',
|
|
301
|
+
method: 'GET',
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
回傳:`MockInterceptor`
|
|
306
|
+
|
|
307
|
+
#### `InterceptOptions`
|
|
308
|
+
|
|
309
|
+
| 屬性 | 型別 | 必要 | 說明 |
|
|
310
|
+
| --------- | ---------------------------------------------------------------- | ---- | ---------------------------- |
|
|
311
|
+
| `path` | `string \| RegExp \| (path: string) => boolean` | 是 | 要匹配的 URL 路徑名 |
|
|
312
|
+
| `method` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH'` | 否 | HTTP 方法(預設:`'GET'`) |
|
|
313
|
+
| `headers` | `Record<string, string \| RegExp \| (value: string) => boolean>` | 否 | Header 匹配器 |
|
|
314
|
+
| `body` | `string \| RegExp \| (body: string) => boolean` | 否 | 請求 body 匹配器 |
|
|
315
|
+
| `query` | `Record<string, string>` | 否 | Query 參數匹配器(精確匹配) |
|
|
316
|
+
|
|
317
|
+
#### 路徑匹配
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
// 精確字串
|
|
321
|
+
.intercept({ path: '/users' })
|
|
322
|
+
|
|
323
|
+
// RegExp
|
|
324
|
+
.intercept({ path: /^\/users\/\d+$/ })
|
|
325
|
+
|
|
326
|
+
// 函式
|
|
327
|
+
.intercept({ path: (p) => p.startsWith('/users') })
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
#### Header 匹配
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
.intercept({
|
|
334
|
+
path: '/api',
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: {
|
|
337
|
+
'content-type': 'application/json', // 精確匹配
|
|
338
|
+
authorization: /^Bearer /, // 正則
|
|
339
|
+
'x-custom': (v) => v.includes('special'), // 函式
|
|
340
|
+
},
|
|
341
|
+
})
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
#### Body 匹配
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
.intercept({
|
|
348
|
+
path: '/api',
|
|
349
|
+
method: 'POST',
|
|
350
|
+
body: '{"key":"value"}', // 精確匹配
|
|
351
|
+
// body: /keyword/, // 正則
|
|
352
|
+
// body: (b) => b.includes('key'), // 函式
|
|
353
|
+
})
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
#### Query 參數
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
.intercept({
|
|
360
|
+
path: '/search',
|
|
361
|
+
method: 'GET',
|
|
362
|
+
query: { q: 'test', page: '1' },
|
|
363
|
+
})
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## `MockInterceptor`
|
|
369
|
+
|
|
370
|
+
### `interceptor.reply(status, body?, options?)`
|
|
371
|
+
|
|
372
|
+
使用靜態 body 定義模擬回應。
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
// 靜態 body
|
|
376
|
+
.reply(200, { users: [] })
|
|
377
|
+
|
|
378
|
+
// 附帶回應 headers
|
|
379
|
+
.reply(200, { users: [] }, { headers: { 'x-request-id': '123' } })
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### `interceptor.reply(status, callback)`
|
|
383
|
+
|
|
384
|
+
使用動態 body 回呼定義模擬回應。
|
|
385
|
+
|
|
386
|
+
```typescript
|
|
387
|
+
// 回呼(接收請求資訊)
|
|
388
|
+
.reply(200, (req) => {
|
|
389
|
+
const input = JSON.parse(req.body!);
|
|
390
|
+
return { echo: input };
|
|
391
|
+
})
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### `interceptor.reply(callback)`
|
|
395
|
+
|
|
396
|
+
單一回呼形式 — 完全控制狀態碼、body 和回應選項。
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
.reply((req) => {
|
|
400
|
+
const data = JSON.parse(req.body!);
|
|
401
|
+
return {
|
|
402
|
+
statusCode: 201,
|
|
403
|
+
data: { id: '1', ...data },
|
|
404
|
+
responseOptions: { headers: { 'x-created': 'true' } },
|
|
405
|
+
};
|
|
406
|
+
})
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
回呼接收 `{ body: string | null }`,必須回傳(或解析為):
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
interface SingleReplyResult {
|
|
413
|
+
statusCode: number;
|
|
414
|
+
data: unknown;
|
|
415
|
+
responseOptions?: { headers?: Record<string, string> };
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
回傳:`MockReplyChain`
|
|
420
|
+
|
|
421
|
+
| 參數 | 型別 | 說明 |
|
|
422
|
+
| ---------- | -------------------------------------- | ---------------- |
|
|
423
|
+
| `status` | `number` | HTTP 狀態碼 |
|
|
424
|
+
| `body` | `unknown \| (req) => unknown` | 回應 body 或回呼 |
|
|
425
|
+
| `callback` | `SingleReplyCallback` | 完全控制回呼 |
|
|
426
|
+
| `options` | `{ headers?: Record<string, string> }` | 回應 headers |
|
|
427
|
+
|
|
428
|
+
### `interceptor.replyWithError(error?)`
|
|
429
|
+
|
|
430
|
+
回應一個網路錯誤(模擬連線失敗)。
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
.replyWithError(new Error('connection refused'))
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
回傳:`MockReplyChain`
|
|
437
|
+
|
|
438
|
+
| 參數 | 型別 | 必要 | 說明 |
|
|
439
|
+
| ------- | ------- | ---- | ---------------------------------------------------------------------------- |
|
|
440
|
+
| `error` | `Error` | 否 | 可選的錯誤實例。為 API 相容性而接受,但內部不使用 — 回應一律為通用網路錯誤。 |
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## `MockReplyChain`
|
|
445
|
+
|
|
446
|
+
### `chain.times(n)`
|
|
447
|
+
|
|
448
|
+
攔截器精確匹配 `n` 次後即被消耗。
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
.reply(200, { ok: true }).times(3)
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### `chain.persist()`
|
|
455
|
+
|
|
456
|
+
攔截器無限期匹配(永不消耗)。
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
.reply(200, { ok: true }).persist()
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### `chain.delay(ms)`
|
|
463
|
+
|
|
464
|
+
在回應送出前加入延遲。
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
.reply(200, { ok: true }).delay(500)
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### `chain.replyContentLength()`
|
|
471
|
+
|
|
472
|
+
根據 JSON 序列化後的 body 大小,自動加入 `Content-Length` header。
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
.reply(200, { ok: true }).replyContentLength()
|
|
476
|
+
// 回應會包含 Content-Length: 13
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## `MockCallHistory`
|
|
482
|
+
|
|
483
|
+
追蹤所有通過模擬 server 的請求。
|
|
484
|
+
|
|
485
|
+
### `history.length`
|
|
486
|
+
|
|
487
|
+
回傳已記錄的呼叫數量。
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
expect(fetchMock.calls.length).toBe(3);
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### `history.called(criteria?)`
|
|
494
|
+
|
|
495
|
+
若有任何呼叫匹配給定條件則回傳 `true`,未提供條件時則檢查是否有任何呼叫。
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
// 有任何呼叫記錄嗎?
|
|
499
|
+
expect(fetchMock.calls.called()).toBe(true);
|
|
500
|
+
|
|
501
|
+
// 有匹配篩選條件的呼叫嗎?
|
|
502
|
+
expect(fetchMock.calls.called({ method: 'POST' })).toBe(true);
|
|
503
|
+
expect(fetchMock.calls.called(/\/users/)).toBe(true);
|
|
504
|
+
expect(fetchMock.calls.called((log) => log.path === '/users')).toBe(true);
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### `history.calls()`
|
|
508
|
+
|
|
509
|
+
回傳所有已記錄的 `MockCallHistoryLog` 條目的副本。
|
|
510
|
+
|
|
511
|
+
### `history.firstCall(criteria?)`
|
|
512
|
+
|
|
513
|
+
回傳第一個記錄的呼叫,若無則回傳 `undefined`。可選擇性以條件篩選。
|
|
514
|
+
|
|
515
|
+
```typescript
|
|
516
|
+
const first = fetchMock.calls.firstCall();
|
|
517
|
+
const firstPost = fetchMock.calls.firstCall({ method: 'POST' });
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### `history.lastCall(criteria?)`
|
|
521
|
+
|
|
522
|
+
回傳最近一次記錄的呼叫,若無則回傳 `undefined`。可選擇性以條件篩選。
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
const last = fetchMock.calls.lastCall();
|
|
526
|
+
const lastPost = fetchMock.calls.lastCall({ method: 'POST' });
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### `history.nthCall(n, criteria?)`
|
|
530
|
+
|
|
531
|
+
回傳第 n 次呼叫(1-indexed),若無則回傳 `undefined`。可選擇性以條件篩選。
|
|
532
|
+
|
|
533
|
+
```typescript
|
|
534
|
+
const second = fetchMock.calls.nthCall(2);
|
|
535
|
+
const secondPost = fetchMock.calls.nthCall(2, { method: 'POST' });
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### `history.clear()`
|
|
539
|
+
|
|
540
|
+
移除所有已記錄的呼叫。
|
|
541
|
+
|
|
542
|
+
### `history.filterCalls(criteria, options?)`
|
|
543
|
+
|
|
544
|
+
彈性篩選,有三種多載:
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
// 函式謂詞
|
|
548
|
+
history.filterCalls((log) => log.body?.includes('test'));
|
|
549
|
+
|
|
550
|
+
// RegExp(對 log.toString() 測試)
|
|
551
|
+
history.filterCalls(/POST.*\/users/);
|
|
552
|
+
|
|
553
|
+
// 結構化條件
|
|
554
|
+
history.filterCalls(
|
|
555
|
+
{ method: 'POST', path: '/users' },
|
|
556
|
+
{ operator: 'AND' } // 預設:'OR'
|
|
557
|
+
);
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
#### `CallHistoryFilterCriteria`
|
|
561
|
+
|
|
562
|
+
| 屬性 | 型別 | 說明 |
|
|
563
|
+
| ---------- | -------- | ---------- |
|
|
564
|
+
| `method` | `string` | HTTP 方法 |
|
|
565
|
+
| `path` | `string` | URL 路徑名 |
|
|
566
|
+
| `origin` | `string` | URL origin |
|
|
567
|
+
| `protocol` | `string` | URL 協定 |
|
|
568
|
+
| `host` | `string` | URL 主機 |
|
|
569
|
+
| `port` | `string` | URL 連接埠 |
|
|
570
|
+
| `hash` | `string` | URL hash |
|
|
571
|
+
| `fullUrl` | `string` | 完整 URL |
|
|
572
|
+
|
|
573
|
+
### `history.filterCallsByMethod(filter)`
|
|
574
|
+
|
|
575
|
+
```typescript
|
|
576
|
+
history.filterCallsByMethod('POST');
|
|
577
|
+
history.filterCallsByMethod(/^P/); // POST、PUT、PATCH
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### `history.filterCallsByPath(filter)`
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
history.filterCallsByPath('/users');
|
|
584
|
+
history.filterCallsByPath(/\/users\/\d+/);
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### `history.filterCallsByOrigin(filter)`
|
|
588
|
+
|
|
589
|
+
```typescript
|
|
590
|
+
history.filterCallsByOrigin('https://api.example.com');
|
|
591
|
+
history.filterCallsByOrigin(/example\.com/);
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### `history.filterCallsByProtocol(filter)`
|
|
595
|
+
|
|
596
|
+
```typescript
|
|
597
|
+
history.filterCallsByProtocol('https:');
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
### `history.filterCallsByHost(filter)`
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
history.filterCallsByHost('api.example.com');
|
|
604
|
+
history.filterCallsByHost(/example\.com/);
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### `history.filterCallsByPort(filter)`
|
|
608
|
+
|
|
609
|
+
```typescript
|
|
610
|
+
history.filterCallsByPort('8787');
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
### `history.filterCallsByHash(filter)`
|
|
614
|
+
|
|
615
|
+
```typescript
|
|
616
|
+
history.filterCallsByHash('#section');
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### `history.filterCallsByFullUrl(filter)`
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
history.filterCallsByFullUrl('https://api.example.com/users');
|
|
623
|
+
history.filterCallsByFullUrl(/\/users\?page=1/);
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### 迭代
|
|
627
|
+
|
|
628
|
+
`MockCallHistory` 實作了 `Symbol.iterator`:
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
for (const call of history) {
|
|
632
|
+
console.log(call.method, call.path);
|
|
633
|
+
}
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
---
|
|
637
|
+
|
|
638
|
+
## `MockCallHistoryLog`
|
|
639
|
+
|
|
640
|
+
每筆記錄的呼叫都是 `MockCallHistoryLog` 實例,包含以下屬性:
|
|
641
|
+
|
|
642
|
+
| 屬性 | 型別 | 說明 |
|
|
643
|
+
| -------------- | ------------------------ | ----------------------------------- |
|
|
644
|
+
| `method` | `string` | HTTP 方法 |
|
|
645
|
+
| `fullUrl` | `string` | 完整 URL |
|
|
646
|
+
| `origin` | `string` | URL origin(`https://example.com`) |
|
|
647
|
+
| `path` | `string` | URL 路徑名(`/users`) |
|
|
648
|
+
| `searchParams` | `Record<string, string>` | Query 參數 |
|
|
649
|
+
| `headers` | `Record<string, string>` | 請求 headers |
|
|
650
|
+
| `body` | `string \| null` | 請求 body |
|
|
651
|
+
| `protocol` | `string` | URL 協定(`https:`) |
|
|
652
|
+
| `host` | `string` | URL 主機 |
|
|
653
|
+
| `port` | `string` | URL 連接埠 |
|
|
654
|
+
| `hash` | `string` | URL hash |
|
|
655
|
+
|
|
656
|
+
### `log.json()`
|
|
657
|
+
|
|
658
|
+
將請求 body 解析為 JSON。若 body 為 `null` 則回傳 `null`。
|
|
659
|
+
|
|
660
|
+
```typescript
|
|
661
|
+
const call = fetchMock.calls.lastCall()!;
|
|
662
|
+
const data = call.json() as { name: string };
|
|
663
|
+
expect(data.name).toBe('Alice');
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### `log.toMap()`
|
|
667
|
+
|
|
668
|
+
回傳包含所有 log 屬性的 `Map`。
|
|
669
|
+
|
|
670
|
+
```typescript
|
|
671
|
+
const map = call.toMap();
|
|
672
|
+
expect(map.get('method')).toBe('POST');
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
### `log.toString()`
|
|
676
|
+
|
|
677
|
+
回傳以管線符號分隔的字串表示,用於除錯和 RegExp 匹配。
|
|
678
|
+
|
|
679
|
+
```typescript
|
|
680
|
+
call.toString();
|
|
681
|
+
// "method->POST|protocol->https:|host->api.example.com|port->|origin->https://api.example.com|path->/users|hash->|fullUrl->https://api.example.com/users"
|
|
682
|
+
```
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Cloudflare Workers 遷移指南
|
|
2
|
+
|
|
3
|
+
如果你正在將測試從 Cloudflare Workers 的 `cloudflare:test` 遷移到標準的 Node.js 測試環境,`msw-fetch-mock` 提供了你已經熟悉的相同 `fetchMock` API。
|
|
4
|
+
|
|
5
|
+
## API 對照
|
|
6
|
+
|
|
7
|
+
| cloudflare:test | msw-fetch-mock |
|
|
8
|
+
| -------------------------------------------------- | ----------------------------------------------------------- |
|
|
9
|
+
| `import { fetchMock } from 'cloudflare:test'` | `import { fetchMock } from 'msw-fetch-mock'` |
|
|
10
|
+
| `fetchMock.activate()` | `await fetchMock.activate()` |
|
|
11
|
+
| `fetchMock.disableNetConnect()` | `fetchMock.disableNetConnect()` |
|
|
12
|
+
| `fetchMock.enableNetConnect(matcher?)` | `fetchMock.enableNetConnect(matcher?)` |
|
|
13
|
+
| `fetchMock.deactivate()` | `fetchMock.deactivate()` |
|
|
14
|
+
| `fetchMock.get(origin).intercept(opts).reply(...)` | `fetchMock.get(origin).intercept(opts).reply(...)` |
|
|
15
|
+
| `.replyWithError(error)` | `.replyWithError(error)` |
|
|
16
|
+
| `.reply(...).delay(ms)` | `.reply(...).delay(ms)` |
|
|
17
|
+
| `fetchMock.getCallHistory()` | `fetchMock.getCallHistory()` 或 `fetchMock.calls` |
|
|
18
|
+
| `fetchMock.clearCallHistory()` | `fetchMock.clearCallHistory()` 或 `fetchMock.calls.clear()` |
|
|
19
|
+
| `fetchMock.assertNoPendingInterceptors()` | `fetchMock.assertNoPendingInterceptors()` |
|
|
20
|
+
|
|
21
|
+
## 遷移前(cloudflare:test)
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { fetchMock } from 'cloudflare:test';
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
fetchMock.activate();
|
|
28
|
+
fetchMock.disableNetConnect();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => fetchMock.assertNoPendingInterceptors());
|
|
32
|
+
|
|
33
|
+
it('呼叫 API', async () => {
|
|
34
|
+
fetchMock
|
|
35
|
+
.get('https://api.example.com')
|
|
36
|
+
.intercept({ path: '/data', method: 'GET' })
|
|
37
|
+
.reply(200, { result: 'ok' });
|
|
38
|
+
|
|
39
|
+
const res = await fetch('https://api.example.com/data');
|
|
40
|
+
expect(await res.json()).toEqual({ result: 'ok' });
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 遷移後(msw-fetch-mock)
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { fetchMock } from 'msw-fetch-mock';
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
await fetchMock.activate();
|
|
51
|
+
});
|
|
52
|
+
afterAll(() => fetchMock.deactivate());
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
fetchMock.assertNoPendingInterceptors();
|
|
55
|
+
fetchMock.reset();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('呼叫 API', async () => {
|
|
59
|
+
fetchMock
|
|
60
|
+
.get('https://api.example.com')
|
|
61
|
+
.intercept({ path: '/data', method: 'GET' })
|
|
62
|
+
.reply(200, { result: 'ok' });
|
|
63
|
+
|
|
64
|
+
const res = await fetch('https://api.example.com/data');
|
|
65
|
+
expect(await res.json()).toEqual({ result: 'ok' });
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 主要差異
|
|
70
|
+
|
|
71
|
+
| 面向 | cloudflare:test | msw-fetch-mock |
|
|
72
|
+
| --------------- | --------------------------------------------- | ------------------------------------------------- |
|
|
73
|
+
| 匯入 | `import { fetchMock } from 'cloudflare:test'` | `import { fetchMock } from 'msw-fetch-mock'` |
|
|
74
|
+
| Server 生命週期 | 隱式(由測試框架管理) | 顯式(`activate()` / `deactivate()`) |
|
|
75
|
+
| 呼叫歷史存取 | `fetchMock.getCallHistory()` | `fetchMock.getCallHistory()` 或 `fetchMock.calls` |
|
|
76
|
+
| 呼叫歷史清理 | 每次測試自動清理 | 在 `afterEach` 中呼叫 `reset()` |
|
|
77
|
+
| 未處理的請求 | 必須呼叫 `disableNetConnect()` | 預設 `onUnhandledRequest: 'error'`(直接拒絕) |
|
|
78
|
+
| 執行環境 | Cloudflare Workers (workerd) | Node.js |
|
|
79
|
+
|
|
80
|
+
> **注意:** `getCallHistory()` 和 `clearCallHistory()` 是為了 Cloudflare 相容性而提供的別名。你可以使用 Cloudflare 風格的方法或 `fetchMock.calls` getter — 兩者是等價的。
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# MSW v1 Legacy 指南
|
|
2
|
+
|
|
3
|
+
如果你正在使用 MSW v1(`msw@^1.x`),請使用 `msw-fetch-mock/legacy` 子路徑匯出。API 與 v2 版本相同 — 只有設定方式不同。
|
|
4
|
+
|
|
5
|
+
## 安裝
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -D msw-fetch-mock msw@^1
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 設定
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { rest } from 'msw';
|
|
15
|
+
import { setupServer } from 'msw/node';
|
|
16
|
+
import { createFetchMock } from 'msw-fetch-mock/legacy';
|
|
17
|
+
|
|
18
|
+
const server = setupServer();
|
|
19
|
+
const fetchMock = createFetchMock(rest, server);
|
|
20
|
+
|
|
21
|
+
beforeAll(() => fetchMock.activate());
|
|
22
|
+
afterAll(() => fetchMock.deactivate());
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
fetchMock.assertNoPendingInterceptors();
|
|
25
|
+
fetchMock.reset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('模擬 GET 請求', async () => {
|
|
29
|
+
fetchMock
|
|
30
|
+
.get('https://api.example.com')
|
|
31
|
+
.intercept({ path: '/users', method: 'GET' })
|
|
32
|
+
.reply(200, { users: [{ id: '1', name: 'Alice' }] });
|
|
33
|
+
|
|
34
|
+
const res = await fetch('https://api.example.com/users');
|
|
35
|
+
const data = await res.json();
|
|
36
|
+
|
|
37
|
+
expect(data.users).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API 對照
|
|
42
|
+
|
|
43
|
+
| MSW v2(`msw-fetch-mock`) | MSW v1(`msw-fetch-mock/legacy`) |
|
|
44
|
+
| -------------------------------------------- | ---------------------------------------------- |
|
|
45
|
+
| `import { fetchMock } from 'msw-fetch-mock'` | 無單例 — 必須使用 `createFetchMock` |
|
|
46
|
+
| `import { createFetchMock } from '.../node'` | `import { createFetchMock } from '.../legacy'` |
|
|
47
|
+
| `createFetchMock(server?)` | `createFetchMock(rest, server?)` |
|
|
48
|
+
| `import { http } from 'msw'` | `import { rest } from 'msw'` |
|
|
49
|
+
|
|
50
|
+
## 主要差異
|
|
51
|
+
|
|
52
|
+
| 面向 | MSW v2 | MSW v1(Legacy) |
|
|
53
|
+
| ---------------- | ----------------------------------------- | --------------------------------- |
|
|
54
|
+
| 匯入 | `msw-fetch-mock` 或 `msw-fetch-mock/node` | `msw-fetch-mock/legacy` |
|
|
55
|
+
| 工廠函式 | `createFetchMock(server?)` | `createFetchMock(rest, server?)` |
|
|
56
|
+
| 單例 | `fetchMock` 預建實例 | 不提供 — 使用 `createFetchMock` |
|
|
57
|
+
| Handler 內部實作 | 使用 `http.*` + `HttpResponse` | 使用 `rest.*` + `(req, res, ctx)` |
|
|
58
|
+
| MSW 版本 | `msw@^2.12.7` | `msw@^1.0.0` |
|
|
59
|
+
|
|
60
|
+
## 匯出項目
|
|
61
|
+
|
|
62
|
+
`msw-fetch-mock/legacy` 子路徑匯出:
|
|
63
|
+
|
|
64
|
+
| 匯出 | 類型 | 說明 |
|
|
65
|
+
| -------------------------------- | ---- | ----------------------------------- |
|
|
66
|
+
| `createFetchMock(rest, server?)` | 函式 | 建立適用於 MSW v1 的 FetchMock 實例 |
|
|
67
|
+
| `FetchMock` | 類別 | 核心模擬類別(與 v2 相同) |
|
|
68
|
+
| `createLegacyHandlerFactory` | 函式 | 低階:建立 v1 相容的 handler 工廠 |
|
|
69
|
+
| `LegacyRestApi` | 型別 | MSW v1 的 `rest` 物件型別 |
|
|
70
|
+
|
|
71
|
+
## 從 MSW v1 遷移到 v2
|
|
72
|
+
|
|
73
|
+
當你將 MSW 從 v1 升級到 v2 時,更新你的匯入:
|
|
74
|
+
|
|
75
|
+
```diff
|
|
76
|
+
- import { rest } from 'msw';
|
|
77
|
+
- import { createFetchMock } from 'msw-fetch-mock/legacy';
|
|
78
|
+
- const fetchMock = createFetchMock(rest, server);
|
|
79
|
+
+ import { createFetchMock } from 'msw-fetch-mock/node';
|
|
80
|
+
+ const fetchMock = createFetchMock(server);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
或使用單例進行獨立模式:
|
|
84
|
+
|
|
85
|
+
```diff
|
|
86
|
+
- import { rest } from 'msw';
|
|
87
|
+
- import { setupServer } from 'msw/node';
|
|
88
|
+
- import { createFetchMock } from 'msw-fetch-mock/legacy';
|
|
89
|
+
- const server = setupServer();
|
|
90
|
+
- const fetchMock = createFetchMock(rest, server);
|
|
91
|
+
+ import { fetchMock } from 'msw-fetch-mock';
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
其餘 API(`get()`、`intercept()`、`reply()`、`calls` 等)維持不變。
|