- 技術 💻
MSWの使い方|v3対応のハンドラ一覧とVitest・ブラウザ設定
MSW(Mock Service Worker)は、アプリが送る HTTP リクエストを途中で受け取り、決めたレスポンスを返すライブラリです。 ブラウザでは Service Worker(ページとネットワークの間に入るスクリプト)で、Node(テスト)ではリクエストの横取りで動きます。アプリのコードを書き換えずに、同じハンドラをテストと開発中の画面の両方で使えます。
2026年9月28日に MSW 3.0.0 が出ました。 v2 の書き方の多くはそのまま使えますが、GraphQL と一部のオプション名が変わっています。
| 使う場所 | 読み込むもの | 起動 |
|---|---|---|
| ハンドラ(REST) | import { http, HttpResponse } from 'msw/http' |
― |
| ハンドラ(GraphQL) | import { graphql } from 'msw/graphql' |
graphql.link(URL) から作る |
| Node・Vitest | import { setupServer } from 'msw/node' |
server.listen() |
| ブラウザ | import { setupWorker } from 'msw/browser' |
await worker.start() |
| ブラウザ(Vite) | import { msw } from 'msw/vite' |
network.enable() |
import { http, HttpResponse } from 'msw/http'
export const handlers = [
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({ id: Number(params.id), name: 'Taro' })
}),
]
確認環境:msw 3.0.3・Vitest 5.0.4・Vite 8.3.4・graphql 17.0.2・Node.js 22.22.2(Linux)。Node の setupServer と Vitest のテスト、ブラウザの setupWorker と Vite プラグインは、Vite の開発サーバーを Chromium(playwright-core 1.64.0)で開いて動かしました。v2 からの変更点は公式の移行ガイドで確かめた内容です(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「MSWの使い方|v3対応のハンドラ一覧とVitest・ブラウザ設定」
インストールと版の条件
npm install msw --save-dev
npm の msw 3.0.3 の package.json に書かれた条件です。
| 項目 | 3.0.3 の条件 |
|---|---|
| Node.js | >=22.12.0(v2 の移行ガイドでは 18.0.0 以上だった) |
| TypeScript(使う場合) | >=5.9.x |
| graphql(GraphQL を使う場合) | >=16 |
| Vite(プラグインを使う場合) | >=6 |
Node.js 20 以前では入りません。 v2 の版を続けて使うなら npm install msw@2 で、v2 の最終は 2.15.0(2026年7月8日公開)でした。
ハンドラの書き方:http の一覧
http.メソッド(URL, リゾルバー, オプション) で1つのハンドラを作ります。 リゾルバーは「リクエストを受けてレスポンスを返す関数」です。
| メソッド | 対象 |
|---|---|
http.get() |
GET |
http.post() |
POST |
http.put() |
PUT |
http.patch() |
PATCH |
http.delete() |
DELETE |
http.head() |
HEAD |
http.options() |
OPTIONS |
http.all() |
メソッドを問わない |
3.0.3 の http のキーを出力すると、all, head, get, post, put, delete, patch, options の8つでした。
リゾルバーが受け取るもの
| 引数 | 中身 | 例 |
|---|---|---|
request |
Fetch API の Request |
await request.json()、request.headers.get('Authorization') |
params |
パスの :id などの値 |
params.id(文字列) |
cookies |
リクエストの Cookie | cookies.session |
requestId |
リクエストごとの ID | ログに使う |
クエリ文字列は params ではなく、new URL(request.url).searchParams から読みます。
レスポンスの作り方:HttpResponse の一覧
| 書き方 | 返るもの |
|---|---|
HttpResponse.json(値, { status }) |
JSON(Content-Type: application/json) |
HttpResponse.text('...') |
テキスト(text/plain) |
HttpResponse.html('...') |
HTML(text/html) |
HttpResponse.xml('...') |
XML(application/xml) |
HttpResponse.formData(fd) |
multipart/form-data |
HttpResponse.arrayBuffer(buf) |
バイナリ(Content-Length だけ付く) |
new HttpResponse(null, { status: 500 }) |
本文なし・状態コードだけ |
HttpResponse.error() |
ネットワークエラー(接続の失敗) |
動かしたハンドラ一式(コピペ用)
// src/mocks/handlers.js
import { http, HttpResponse } from 'msw/http'
import { graphql } from 'msw/graphql'
import { delay } from 'msw/utils/delay'
const users = [
{ id: 1, name: 'Taro' },
{ id: 2, name: 'Hanako' },
]
const api = graphql.link('https://api.example.com/graphql')
export const handlers = [
// 一覧(クエリ文字列は url から読む)
http.get('https://api.example.com/users', ({ request }) => {
const url = new URL(request.url)
const q = url.searchParams.get('q') ?? ''
return HttpResponse.json(users.filter((u) => u.name.includes(q)))
}),
// パスパラメータ
http.get('https://api.example.com/users/:id', ({ params }) => {
const user = users.find((u) => u.id === Number(params.id))
if (!user) {
return HttpResponse.json({ message: 'Not Found' }, { status: 404 })
}
return HttpResponse.json(user)
}),
// 本文を読む
http.post('https://api.example.com/users', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({ id: 3, ...body }, { status: 201 })
}),
// 遅延
http.get('https://api.example.com/slow', async () => {
await delay(500)
return HttpResponse.text('late')
}),
// ネットワークエラー
http.get('https://api.example.com/down', () => HttpResponse.error()),
// GraphQL
api.query('GetUser', ({ variables }) => {
return HttpResponse.json({
data: { user: { id: variables.id, name: 'Taro' } },
})
}),
]
// src/mocks/node.js
import { setupServer } from 'msw/node'
import { handlers } from './handlers.js'
export const server = setupServer(...handlers)
server.listen() のあとに Node の fetch で各 URL を呼んだ結果です。
| 呼び出し | 結果 |
|---|---|
GET /users?q=Ta |
200 [{ id: 1, name: 'Taro' }] |
GET /users/2 |
200 { id: 2, name: 'Hanako' } |
GET /users/9 |
404 { message: 'Not Found' } |
POST /users 本文 {"name":"Jiro"} |
201 { id: 3, name: 'Jiro' } |
GET /slow |
200 late、547 ミリ秒 |
GET /down |
TypeError: fetch failed(例外) |
GraphQL GetUser(id: "1") |
200 {"data":{"user":{"id":"1","name":"Taro"}}} |
GraphQL は graphql.link から作る(v3 の変更)
v3 では graphql.query() が無くなり、エンドポイントごとに graphql.link() を作ってから query・mutation を定義します。
import { graphql } from 'msw/graphql'
import { HttpResponse } from 'msw/http'
const api = graphql.link('https://api.example.com/graphql')
export const handlers = [
api.query('GetUser', ({ variables }) =>
HttpResponse.json({ data: { user: { id: variables.id, name: 'Taro' } } }),
),
api.mutation('CreateUser', ({ variables }) =>
HttpResponse.json({ data: { createUser: { id: '3', ...variables.input } } }),
),
]
3.0.3 で graphql のキーは link だけで、v2 の書き方 graphql.query(...) を呼ぶと graphql.query is not a function でした。link() の戻り値には operation・query・mutation・subscription がありました。
Vitest で使う:setupServer の設定
beforeAll で起動、afterEach でテスト中に足したハンドラを戻し、afterAll で止めます。
// vitest.setup.js
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './src/mocks/node.js'
beforeAll(() => server.listen({ onUnhandledFrame: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// vitest.config.js
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.js'],
},
})
テストするコード(API を呼ぶ関数)です。
// src/userApi.js
const BASE = 'https://api.example.com'
export async function fetchUser(id) {
const res = await fetch(`${BASE}/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function createUser(name) {
const res = await fetch(`${BASE}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
return res.json()
}
テストの中だけ応答を変えるときは server.use() を使います。 afterEach の resetHandlers() で元に戻ります。
// src/userApi.test.js
import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw/http'
import { server } from './mocks/node.js'
import { fetchUser, createUser } from './userApi.js'
describe('userApi', () => {
it('ユーザーを取得できる', async () => {
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Taro' })
})
it('404 のときは例外', async () => {
await expect(fetchUser(9)).rejects.toThrow('HTTP 404')
})
it('このテストだけ 500 を返す(server.use)', async () => {
server.use(
http.get('https://api.example.com/users/:id', () =>
new HttpResponse(null, { status: 500 }),
),
)
await expect(fetchUser(1)).rejects.toThrow('HTTP 500')
})
it('resetHandlers で元に戻っている', async () => {
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Taro' })
})
it('送った本文を確かめる', async () => {
let sent
server.use(
http.post('https://api.example.com/users', async ({ request }) => {
sent = await request.clone().json()
return HttpResponse.json({ id: 10, ...sent }, { status: 201 })
}),
)
await createUser('Jiro')
expect(sent).toEqual({ name: 'Jiro' })
})
it('once: 1回目だけ 503', async () => {
server.use(
http.get('https://api.example.com/users/:id', () =>
new HttpResponse(null, { status: 503 }), { once: true }),
)
await expect(fetchUser(1)).rejects.toThrow('HTTP 503')
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Taro' })
})
it('ネットワークエラー', async () => {
server.use(http.get('https://api.example.com/users/:id', () => HttpResponse.error()))
await expect(fetchUser(1)).rejects.toThrow('fetch failed')
})
})
$ npx vitest run --reporter=verbose
✓ src/userApi.test.js > userApi > ユーザーを取得できる 51ms
✓ src/userApi.test.js > userApi > 404 のときは例外 11ms
✓ src/userApi.test.js > userApi > このテストだけ 500 を返す(server.use) 14ms
✓ src/userApi.test.js > userApi > resetHandlers で元に戻っている 14ms
✓ src/userApi.test.js > userApi > 送った本文を確かめる 23ms
✓ src/userApi.test.js > userApi > once: 1回目だけ 503 14ms
✓ src/userApi.test.js > userApi > ネットワークエラー 7ms
{ once: true } は、1回使ったらそのハンドラを使い終わりにするオプションです。リトライ処理のテストに使えます。vi.fn() で関数を置き換える方法との使い分けは、関連記事「Vitestモックの使い方|vi.mock・vi.fn・vi.spyOnの使い分け表」で扱っています。
server の主なメソッド
3.0.3 の setupServer() の戻り値にあったものです。
| メソッド | 役割 |
|---|---|
listen(options) |
横取りを始める |
use(...handlers) |
ハンドラを先頭に足す(テスト中の上書き) |
resetHandlers() |
use で足した分を消し、最初のハンドラに戻す |
restoreHandlers() |
once で使い終わったハンドラを使える状態に戻す |
listHandlers() |
今のハンドラの一覧(v1 の printHandlers の代わり) |
boundary(fn) |
use の効く範囲を関数の中に閉じる(並列テスト向け) |
close() |
横取りを止めて片付ける |
events |
request:start などのイベントを受け取る |
ハンドラの無いリクエスト:onUnhandledFrame
v3 では onUnhandledRequest が onUnhandledFrame に名前が変わりました。 WebSocket なども含めた「対応するハンドラが無かった通信」の扱いを決めます。
| 値 | 動き |
|---|---|
| 指定なし(既定) | 警告を出し、本物のネットワークへそのまま送る |
'error' |
エラーを出し、そのリクエストを失敗させる |
'bypass' |
何も出さずに本物へ送る |
関数 ({ frame, defaults }) => {} |
自分で決める。defaults.warn() などで既定の動きを呼べる |
既定(警告)のときの出力です。
[MSW] Warning: intercepted a request without a matching request handler:
• GET https://api.example.com/b
If you still wish to intercept this unhandled request, please create a request handler for it.
Read more: https://mswjs.io/docs/http/intercepting-requests
'error' にした Vitest のテストでは、[MSW] Error: intercepted a request without a matching request handler のあとに、テストが次の内容で失敗しました。
TypeError: fetch failed
Caused by: InternalError: [MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledFrame" option.
テストでは 'error' にしておくと、ハンドラの書き忘れに気づけます。
v2 の onUnhandledRequest: 'error' を 3.0.3 に渡すと、エラーにならず、既定と同じ「警告して本物へ送る」動きでした。 名前を変え忘れても例外は出ないため、移行のときに見落としやすい点です。
一部だけ本物に送る:passthrough と bypass
| 関数 | 使い方 |
|---|---|
passthrough() |
そのリクエストを本物のサーバーにそのまま送る |
bypass(request) |
ハンドラの中から本物を呼ぶ(MSW に二重に横取りさせない) |
import { http, HttpResponse } from 'msw/http'
import { passthrough } from 'msw/utils/passthrough'
import { bypass } from 'msw/utils/bypass'
export const handlers = [
http.get('http://localhost:18168/health', () => passthrough()),
// 本物の応答の一部だけを書き換える
http.get('http://localhost:18168/users/1', async ({ request }) => {
const original = await fetch(bypass(request)).then((r) => r.json())
return HttpResponse.json({ ...original, name: 'Taro(mock)' })
}),
]
手元に立てた本物のサーバー(パスを返すだけ)に対して動かした結果です。
{ from: 'real', path: '/health' }
{ from: 'real', path: '/users/1', name: 'Taro(mock)' }
ブラウザで使う①:msw init と setupWorker
ブラウザでは、Service Worker のスクリプト mockServiceWorker.js を、サイトの公開フォルダに置きます。
npx msw init public --save
--save を付けると、package.json に次の設定が入りました。msw を更新したときに、このフォルダのスクリプトも更新されます。
"msw": {
"workerDirectory": ["public"]
}
置かれた mockServiceWorker.js は 12,754 バイトで、先頭に const PACKAGE_VERSION = '3.0.3' がありました。--save は、そのフォルダに package.json があるプロジェクトの直下で実行します(package.json が無いと ENOENT で止まりました)。
// src/mocks/browser.js
import { setupWorker } from 'msw/browser'
import { handlers } from './browser-handlers.js'
export const worker = setupWorker(...handlers)
// src/mocks/browser-handlers.js(ブラウザでは相対パスで書ける)
import { http, HttpResponse } from 'msw/http'
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: Number(params.id), name: 'Taro' })
}),
]
worker.start() の完了を待ってから、アプリの通信を始めます。 待たないと、最初のリクエストが MSW を通らずに本物へ出ることがあります。
// src/main.js
async function enableMocking() {
if (!import.meta.env.DEV) return
const { worker } = await import('./mocks/browser.js')
await worker.start({ onUnhandledFrame: 'bypass' })
}
enableMocking().then(async () => {
const res = await fetch('/api/users/1')
document.querySelector('#app').textContent = `${res.status} ${await res.text()}`
})
Vite の開発サーバーを Chromium で開くと、画面に 200 {"id":1,"name":"Taro"} が出て、コンソールに次のログが出ました(抜粋)。
[MSW] Mocking enabled.
Worker script URL: http://localhost:18167/mockServiceWorker.js
Worker scope: http://localhost:18167/
[MSW] 15:55:41 GET /api/users/1 (200 OK)
import.meta.env.DEV で囲むと、本番ビルドには MSW が入りません。
ブラウザで使う②:Vite プラグイン(v3 の新機能)
v3 の msw/vite プラグインを使うと、mockServiceWorker.js を public に置かなくても、開発サーバーがスクリプトを配ります。
// vite.config.js
import { defineConfig } from 'vite'
import { msw } from 'msw/vite'
export default defineConfig({
plugins: [msw()],
})
// src/main.js
async function enableMocking() {
if (!import.meta.env.DEV) return
const { network } = await import('virtual:msw')
const { handlers } = await import('./mocks/handlers.js')
network.configure({ handlers })
await network.enable()
}
enableMocking().then(async () => {
const res = await fetch('/api/users/7')
document.querySelector('#app').textContent = `${res.status} ${await res.text()}`
})
public フォルダの無いプロジェクトで開発サーバーを起動すると、/mockServiceWorker.js が 200・application/javascript で返り、画面に 200 {"id":7,"name":"Taro"} が出ました。
v2・v1 からの書き換え早見表
| v1 | v2 | v3 |
|---|---|---|
rest.get(url, (req, res, ctx) => …) |
http.get(url, ({ request, params }) => …) |
v2 と同じ(msw/http から読み込み) |
res(ctx.json(data)) |
HttpResponse.json(data) |
v2 と同じ |
req.body |
await request.json() |
v2 と同じ |
ctx.delay(500) |
await delay(500) |
msw/utils/delay から読み込み |
res.networkError('msg') |
HttpResponse.error() |
v2 と同じ |
res.once(...) |
第3引数 { once: true } |
v2 と同じ |
graphql.query('Op', …) |
graphql.query('Op', …) |
graphql.link(url).query('Op', …) |
onUnhandledRequest |
onUnhandledRequest |
onUnhandledFrame |
worker.stop() |
worker.stop() |
await worker.stop()(Promise を返す) |
printHandlers() |
listHandlers() |
listHandlers() |
3.0.3 では、import { http, HttpResponse, delay } from 'msw' のようにルートから読み込む書き方も動きました。移行ガイドではサブパス(msw/http など)が案内されています。graphql だけはルートから読み込めませんでした。
よくあるエラーと原因
| エラー | 原因 | 直し方 |
|---|---|---|
The requested module 'msw' does not provide an export named 'rest' |
v1 の書き方 | http(msw/http)に書き換える |
... does not provide an export named 'graphql' |
v3 でルートから消えた | import { graphql } from 'msw/graphql' |
... does not provide an export named 'setupWorker' |
ルートには無い | msw/browser から読み込む |
graphql.query is not a function |
v3 の GraphQL | graphql.link(url).query(...) |
ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './browser' is not defined by "exports" |
Node で msw/browser を読んだ |
Node・Vitest は msw/node |
[MSW] Failed to register the Service Worker: ... unsupported MIME type ('text/html') |
mockServiceWorker.js が無く、404 の HTML が返った |
npx msw init public --save、または Vite プラグイン |
TypeError: Failed to parse URL from /users |
Node で相対パスの fetch |
Node では https://... の絶対 URL で書く |
[MSW] Warning: intercepted a request without a matching request handler |
ハンドラの URL・メソッドが合わない | URL の綴り、http.get と http.post の違いを確かめる |
テストで onUnhandledRequest: 'error' が効かない |
v3 で名前が変わった | onUnhandledFrame: 'error' |
最後の Service Worker の登録エラーは、mockServiceWorker.js を消して開いたときに出たメッセージです。Vite の開発サーバーは、無いファイルにも index.html を返すため、MIME type のエラーになりました。
よくある質問
Q. Jest でも使えますか。
A. 使えます。v2 の移行ガイドには、jest-environment-jsdom で Request・Response・TextEncoder が「is not defined」になる問題と、jest-fixed-jsdom を使う解決策が書かれています。
Q. 本番のビルドに入りませんか。
A. import.meta.env.DEV の条件の中で動的に import() すると、本番では読み込まれません。public/mockServiceWorker.js はファイルとして残るので、気になる場合は本番の配信から外します。
Q. Angular の HttpClient のテストでも使えますか。
A. MSW は fetch や XMLHttpRequest の層で横取りします。Angular の標準のやり方は関連記事「Angular HTTPテスト|HttpTestingControllerの使い方」で扱っています。
Q. E2E テストと組み合わせられますか。
A. ブラウザで worker.start() した画面を Playwright で開けば、MSW の応答で E2E テストを流せます。この記事のブラウザの確認も、その形で行いました。Playwright は関連記事「Playwright入門|E2Eテストの書き方とCI実行まで実行結果つきで解説」で扱っています。
まとめ
- MSW は HTTP リクエストを横取りして決めた応答を返す。ハンドラはテストとブラウザで共通
- ハンドラは
http.get(url, ({ request, params }) => HttpResponse.json(...)) - 3.0.0(2026年9月28日)で Node.js 22.12 以上。GraphQL は
graphql.link()、オプションはonUnhandledFrame - Vitest は
listen・resetHandlers・closeの3行。テスト中の上書きはserver.use() - ブラウザは
npx msw init public --saveか Vite プラグインmsw/vite
参考・出典
確認日はいずれも 2026年10月13日です。
- MSW ドキュメント「Quick start」:https://mswjs.io/docs/quick-start
- MSW ドキュメント「http」:https://mswjs.io/docs/api/http
- MSW ドキュメント「HttpResponse」:https://mswjs.io/docs/api/http-response
- MSW ドキュメント「setupServer」:https://mswjs.io/docs/api/setup-server
- MSW ドキュメント「Migrating from 2.x to 3.x」:https://mswjs.io/docs/migrations/2.x-to-3.x
- MSW ドキュメント「Migrating from 1.x to 2.x」:https://mswjs.io/docs/migrations/1.x-to-2.x
- npm「msw」:https://www.npmjs.com/package/msw
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「MSWの使い方|v3対応のハンドラ一覧とVitest・ブラウザ設定」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。