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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?