MSW 3でページナビゲーションとフォーム送信をモックする
MSW 3のhttpハンドラーでページ遷移やネイティブフォーム送信をモック。HTML応答、formDataの読み取り、アップグレード時の主な変更点を解説します。
Mock Service Worker(MSW)3.0.0が2026年9月28日にリリースされ、ページナビゲーションとフォーム送信のインターセプト(#2721)に対応しました。これにより、リンクのクリックや素のHTMLフォームのPOSTに対しても、普段書いているhttpハンドラーでモックレスポンスを返せるようになります。
このリリースまで、StorybookやPlaywright環境でのネイティブなフォーム送信はMSWを素通りしていました。そのフローのためだけに実際のバックエンドを起動するか、テスト自体を諦めるしかありませんでした。メンテナーは2023年のディスカッションで、実際のHTMLページの代わりにモックが誤って返されるのを防ぐため、MSWは意図的にナビゲーションリクエストをスキップしていると説明していました。
本記事では、この変更によってテスト可能になること、最初に直面するv3の破壊的変更、そしてページとフォームPOSTのためのハンドラーパターンを解説します。なお、2026年10月10日にはパッチリリース3.0.3が公開されています。MSWのインストールや最初のハンドラー作成については、Mock Service WorkerによるAPIサーバーのモックを参照してください。
要点
- MSW 3.0.0(2026年9月28日)でページナビゲーションとフォーム送信のインターセプトが追加されたが、専用APIはドキュメント化されていないため、標準の
http.getおよびhttp.postハンドラーを使う。 - 素のHTMLフォームはフィールドをJSONではなくURLエンコード形式で送信するため、ボディは
request.json()ではなくrequest.formData()で読み取る。 - MSW 3はESM専用となり、Node.js 22以降およびTypeScript 5.9以降が必要。
onUnhandledRequestはonUnhandledFrameに名称変更され、ハンドラー内のrequest.headers.get('cookie')はnullを返すようになった。
MSW 3のナビゲーション・フォームのモックで何が可能になるのか
MSW 3を使えば、サーバーサイドレンダリングされたフォームや別ページへのリンクを、バックエンドを動かさずにテストできます。ハンドラーが遷移先ページのHTMLドキュメントを返し、ブラウザはそれを実際のサーバーレスポンスと同じようにレンダリングします。
バージョン3より前のMSWが対象としていたのは、JavaScriptからfetchやXHRで発行されるリクエストのみでした。そのため、プログレッシブエンハンスメントを適用したサインアップフォーム、ページをリロードする検索フォーム、サーバーサイドレンダリングされたページへの「詳細を見る」リンクなどは対象外でした。これからは、それぞれをハンドラーとして記述し、ユーザーが最終的に目にする内容に対してアサーションを行えます。
最も恩恵を受けるのはエラー時の挙動です。フォームフローのセッションリプレイを見ると、送信ボタンを押した後に空白ページや生のエラーページにたどり着いてしまうユーザーがよく見られます。バリデーションエラーのHTMLや500を返すPOSTをモックすれば、リリース前にその画面をテストで再現できます。
MSW 3へのアップグレード:最初に直面する破壊的変更
ほとんどのテストスイートは、まずランタイムの最小要件と、名称変更された1つのオプションで壊れます。新機能を見る前に、v3.0.0リリースノートのうち以下の変更を確認しておきましょう。
- MSWはESM専用になりました(#2765)。
- サポートされるNode.jsの最小バージョンはv22です(#2729)。
- TypeScriptの最小バージョンは5.9になりました。
graphqlはmsw/graphqlとしてエクスポートされるようになり(#2691)、GraphQLは正式なオプショナルなpeer dependencyになりました。また、graphql.query()とgraphql.mutation()が名前空間から削除されたため、GraphQLハンドラーはgraphql.link()上で定義するようになりました。onUnhandledRequestオプションはonUnhandledFrameに名称変更されました。worker.stop()はPromiseを返すようになりました(#2724)。awaitが必要なのは、処理中のリクエストが残っているようなクリティカルなケースに限られます。msw/nativeは削除され、@msw/react-nativeに置き換えられました。ただしGitHubのissueでは、@msw/react-nativeがまだnpmで公開されておらず、ソースもMSW 2を対象としたままであると報告されています。そのため、React Nativeプロジェクトにはこれが公開されるまで有効なアップグレード手段がありません。
その他の変更については、リリースノートと2.x → 3.x移行ガイドにまとめられています。本記事のコードスニペットでは新しいmsw/httpエントリポイントを使用しています。MSW 3.0の告知によれば、ルートレベルのmswからのエクスポートは後方互換性のために残されていますが、専用のエントリポイントからインポートできるものは次のメジャーリリースで削除される予定です。
http.getでページナビゲーションをモックするには
ページナビゲーションをモックするには、ページのパスに対するhttp.getハンドラーを書き、HTMLボディとtext/htmlのContent-Typeを持つResponseを返します。v3のリリースノートではナビゲーションとフォームのインターセプトが告知されていますが、リリースノートにも移行ガイドにも専用のAPIやセットアップ手順は記載されていません。そのため、以下の例では標準のハンドラーのみを使用しています。
MSWの各リクエストハンドラーは、受信リクエストを絞り込む述語(predicate)と、条件に合致したリクエストを処理するレスポンスリゾルバーの組み合わせで構成されます。リクエストインターセプトのドキュメントにあるとおり、/signupのような相対パスの述語はlocation.hrefを基準に解決されます。またリクエスト処理のドキュメントで説明されているように、リゾルバーがResponseインスタンスを返すと、それがそのままモックレスポンスになります。HTML専用のヘルパーを使わずに素のnew Responseで動作するのはこのためです。
import { http } from 'msw/http'
export const html = (body: string, status = 200) =>
new Response(`<!doctype html><html><body>${body}</body></html>`, {
status,
headers: { 'Content-Type': 'text/html' },
})
export const pageHandlers = [
http.get('/signup', () =>
html(`<form method="post" action="/signup">
<input name="email" type="email">
<button>Sign up</button>
</form>`),
),
]
method="get"を使う検索フォームは挙動が異なります。method="get"で送信されたフォームは、フィールドをクエリ文字列に含めます。MSWの述語にはクエリパラメーターを含めてはいけないため、パスのみでマッチさせ、フィールドはnew URL(request.url).searchParamsから読み取ります。
http.get('/search', ({ request }) => {
const q = new URL(request.url).searchParams.get('q') ?? ''
const safe = q.replace(/&/g, '&').replace(/</g, '<')
return html(`<h1>Results for ${safe}</h1>`)
})
http.postでフォームのPOSTをモックするには
ネイティブなフォームのPOSTは、フォームのaction URLに対するhttp.postハンドラーで処理します。リゾルバーは標準のFetch APIのRequestであるrequestを、paramsおよびcookiesとともに受け取ります。そして、返したResponseがブラウザの次に表示するページになります。
すでに馴染みのあるMSW 2のパターンとの違いは、ボディの形式だけです。自前のJavaScriptでフォームを送信する場合、通常はJSONを送ります。
import { http, HttpResponse } from 'msw/http'
http.post<never, { email: string }>('/api/signup', async ({ request }) => {
const body = await request.clone().json()
return HttpResponse.json({ ok: true, email: body.email }, { status: 201 })
})
一方、素のHTMLフォームはフィールドをJSONではなくURLエンコードされたフォームデータとして送信します。これはapplication/x-www-form-urlencodedがデフォルトのフォームエンコーディングだからです。このボディに対して.json()を呼び出すと失敗するため、代わりにRequest.formData()で読み取ります。MSWのファイルアップロードガイドでも、インターセプトしたリクエストに対してformData()を使う例が示されています。
import { http } from 'msw/http'
import { html } from './pages'
export const formHandlers = [
http.post('/signup', async ({ request }) => {
const form = await request.clone().formData()
const email = form.get('email')
if (!email) {
return html('<p role="alert">Email is required.</p>', 422)
}
return html('<h1>Check your inbox</h1>')
}),
]
テストしたいサーバー側の結果ごとに、成功、バリデーション失敗、500ページといった個別の分岐を用意しましょう。そうすれば、アサーションでユーザーが実際に目にするドキュメントを検証できます。
フォームフローはセッションCookieに依存することがよくあります。v3では、ハンドラー内のrequest.headers.get('cookie')はモックされたCookie文字列を返さなくなりました。どの環境でもnullを返すため、Cookieに基づくロジックにはリゾルバー引数のcookiesを使用してください。
MSW 3のナビゲーション・フォームインターセプトはどこで動作するのか
v3のリリースノートには、ナビゲーションとフォームのインターセプトがどの環境でサポートされるかが明記されていません。そのため、利用する前にPR #2721とドキュメントを自身の環境と照らし合わせて確認してください。ページナビゲーションはブラウザが実行するものなので、これらのハンドラーを試すのに自然な場所はブラウザベースのテストランナーです。
その理由はMSWの経緯からもわかります。メンテナーがDiscussion #1578で説明しているとおり、バージョン3より前のワーカースクリプトは、modeがnavigateのリクエストをスキップしていました。Vitestを使ったVueテストでのAPI呼び出しのモックのように、VitestやJestのテストスイートでコンポーネントテストをNode上で実行している場合、fetchのモックはこれまでどおり動作します。ただし、そこでネイティブなフォーム送信が同じように動作するとは考えないでください。また、このリリースではViteプラグインmsw/vite(#2781)も追加されました。これがワーカースクリプトを配信してくれるため、mockServiceWorker.jsをpublicディレクトリにコピーする必要はなくなります。
まとめ
MSW 3でページとフォームをモックするにあたって、新しい概念を覚える必要はありません。ページにはhttp.get、送信にはhttp.post、ボディにはformData()、そして結果にはtext/htmlのResponseを使うだけです。アップグレードは次の順序で進めましょう。まずNode.js 22、TypeScript 5.9、ESMへ移行し、onUnhandledRequestをonUnhandledFrameに名称変更し、インポートをmsw/httpに切り替えます。その後、最も重要なフォームの各結果に対してハンドラーを追加していきます。まずはエラーページから始めるのがよいでしょう。
よくある質問
request.formData()はenctypeがmultipart/form-dataのフォームでも動作しますか?
はい。Request.formData()はapplication/x-www-form-urlencodedとmultipart/form-dataのどちらのボディもFormDataオブジェクトにパースするため、フォームにファイル入力が含まれているかどうかにかかわらず、同じハンドラーコードが動作します。テキストフィールドはform.get()から文字列として返され、ファイル入力はFileオブジェクトとして返されるので、name、size、typeを検査できます。
フォームハンドラーでチェックボックスのグループや複数選択フィールドを読み取るには?
form.get('name')の代わりにform.getAll('name')を使います。チェックボックスのグループやmultiple属性付きのselectは、選択された値ごとに同じフィールド名で1エントリずつ送信しますが、get()は最初の値しか返しません。getAll()はすべての値を配列で返し、何も選択されていない場合は空配列を返します。ブラウザはチェックされていないチェックボックスをまったく送信しないためです。
MSWのリゾルバーでpassthrough()を返すことと、何も返さないことの違いは?
passthrough()(MSW 3ではmsw/utils/passthroughからインポート)を返すと、リクエストは変更されずに実際のネットワークへ送られ、処理済みとしてマークされるため、以降のハンドラーが影響を与えることはありません。何も返さない場合はリクエストに手を加えず、MSWはマッチする次のハンドラーを試します。特定のページやフォーム送信を意図的に実際のサーバーへ到達させたい場合はpassthrough()を使ってください。
MSW 3へのアップグレード後、mockServiceWorker.jsを再生成する必要はありますか?
はい。ただし、ワーカースクリプトを配信してくれる新しいmsw/viteプラグインを使う場合は不要です。それ以外の場合は、MSW 3をインストールした後にnpx msw initに続けてpublicディレクトリを指定して実行し、新しいファイルをコミットしてください。これにより、ワーカースクリプトが古い2.x版ではなく3.xパッケージと一致するようになります。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k