umbraco-msw-testing について
Umbraco のバックオフィス拡張を開発する人向けのテストスキルです。MSW で API 呼び出しを捕捉してモック応答を返し、Umbraco を起動せずにエラーや読み込み、境界状態をテストできます。
主な機能
- 01Umbraco API リクエストの捕捉
- 02エラーと検証応答のモック
- 03遅延と読み込み状態の再現
- 04MSW v2 ハンドラーパターン
- 05Web Test Runner との連携
活用シーン
- 01API エラー処理の検証
- 02スピナーやスケルトンのテスト
- 03ネットワークリトライ動作の確認
- 04バックエンドなしで境界条件をテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ umbraco/ umbraco-cms-backoffice-skills --skill umbraco-msw-testingインストール後、Claude Code で/umbraco-msw-testingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/api-mocking/mockServiceWorker.jspackage-lock.jsonpackage.jsonREADME.mdtsconfig.jsonweb-test-runner.config.mjsexamples/api-mocking/src/items-list.element.test.tsitems-list.element.tsexamples/api-mocking/src/mocks/handlers.tsitems.db.tssetup.ts詳細
Umbraco MSW テスト
これは何ですか?
MSW (Mock Service Worker) は、API 呼び出しをインターセプトしてモックレスポンスを返すことで、Umbraco バックオフィス拡張機能のテストを可能にします。これは、実行中の Umbraco インスタンスがなくても、エラー状態、ローディング状態、エッジケースをテストするのに理想的です。
いつ使用するか
- API エラー処理 (404、500、検証エラー) のテスト
- ローディングスピナーとスケルトン状態のテスト
- ネットワークリトライ動作のテスト
- バックエンドのセットアップなしでエッジケースをテスト
- 単体テストへの API モックの追加
関連スキル
- umbraco-testing - テスト概要のマスタースキル
- umbraco-unit-testing - 単体テストのパターン (MSW と組み合わせて使用)
ドキュメント
- MSW ドキュメント: https://mswjs.io/docs/
- 参考ハンドラー:
Umbraco-CMS/src/Umbraco.Web.UI.Client/src/mocks/handlers/
セットアップ
依存関係
package.json に追加します:
{
"devDependencies": {
"@open-wc/testing": "^4.0.0",
"@web/dev-server-esbuild": "^1.0.0",
"@web/dev-server-import-maps": "^0.2.0",
"@web/test-runner": "^0.18.0",
"@web/test-runner-playwright": "^0.11.0",
"msw": "^2.7.0"
},
"scripts": {
"postinstall": "npx msw init . --save",
"test": "web-test-runner",
"test:watch": "web-test-runner --watch"
}
}
次に実行します:
npm install
npx playwright install chromium
postinstall スクリプトは mockServiceWorker.js をプロジェクトルートにコピーします。このファイルがないと、MSW はサイレントに失敗します。
設定
web-test-runner.config.mjs を作成します:
import { esbuildPlugin } from '@web/dev-server-esbuild';
import { playwrightLauncher } from '@web/test-runner-playwright';
import { importMapsPlugin } from '@web/dev-server-import-maps';
export default {
rootDir: '.',
files: ['./src/**/*.test.ts', '!**/node_modules/**'],
nodeResolve: {
exportConditions: ['development'],
preferBuiltins: false,
browser: false,
},
browsers: [playwrightLauncher({ product: 'chromium' })],
plugins: [
importMapsPlugin({
inject: {
importMap: {
imports: {
'@umbraco-cms/backoffice/external/lit': '/node_modules/lit/index.js',
'@umbraco-cms/backoffice/lit-element':
'/node_modules/@umbraco-cms/backoffice/dist-cms/packages/core/lit-element/index.js',
'@umbraco-cms/backoffice/element-api':
'/node_modules/@umbraco-cms/backoffice/dist-cms/libs/element-api/index.js',
'@umbraco-cms/backoffice/observable-api':
'/node_modules/@umbraco-cms/backoffice/dist-cms/libs/observable-api/index.js',
'@umbraco-cms/backoffice/context-api':
'/node_modules/@umbraco-cms/backoffice/dist-cms/libs/context-api/index.js',
'@umbraco-cms/backoffice/controller-api':
'/node_modules/@umbraco-cms/backoffice/dist-cms/libs/controller-api/index.js',
'@umbraco-cms/backoffice/class-api':
'/node_modules/@umbraco-cms/backoffice/dist-cms/packages/core/class-api/index.js',
},
},
},
}),
esbuildPlugin({
ts: true,
tsconfig: './tsconfig.json',
target: 'auto',
json: true,
}),
],
testRunnerHtml: (testFramework) =>
`<html lang="en-us">
<head>
<meta charset="UTF-8" />
<script src="/node_modules/msw/lib/iife/index.js"></script>
</head>
<body>
<script type="module" src="${testFramework}"></script>
</body>
</html>`,
};
ディレクトリ構造
my-extension/
├── src/
│ ├── my-element.ts
│ ├── my-element.test.ts
│ └── mocks/
│ ├── handlers.ts # MSW handlers
│ ├── setup.ts # Worker setup
│ └── data/
│ └── items.db.ts # Mock database
├── mockServiceWorker.js # Generated by postinstall
├── web-test-runner.config.mjs
├── package.json
└── tsconfig.json
パターン
MSW v2 の構文
Umbraco は MSW v2 を使用しています。主な API パターン:
| 概念 | MSW v2 の構文 |
|---|---|
| HTTP メソッド | http.get()、http.post()、http.put()、http.delete() |
| JSON レスポンス | HttpResponse.json(data) |
| ステータスコード | HttpResponse.json(data, { status: 201 }) |
| 空のレスポンス | new HttpResponse(null, { status: 204 }) |
| リクエストパラメーター | ({ params }) => { ... } |
| リクエストボディ | ({ request }) => { const body = await request.json(); } |
| 遅延 | await delay(2000) |
グローバルな MSW アクセス
const { http, HttpResponse, delay } = window.MockServiceWorker;
umbracoPath ヘルパー
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
// Creates: /umbraco/management/api/v1/document/:id
umbracoPath('/document/:id')
基本的なハンドラー
GET ハンドラー:
const { http, HttpResponse } = window.MockServiceWorker;
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
http.get(umbracoPath('/document/:id'), ({ params }) => {
const id = params.id as string;
return HttpResponse.json({
id,
name: 'Test Document',
documentType: { alias: 'testType' },
});
}),
];
POST ハンドラー:
http.post(umbracoPath('/document'), async ({ request }) => {
const body = await request.json();
if (!body.name) {
return HttpResponse.json(
{
type: 'validation',
status: 400,
errors: { name: ['Name is required'] },
},
{ status: 400 }
);
}
const newId = crypto.randomUUID();
return HttpResponse.json(
{ id: newId },
{
status: 201,
headers: { 'Umb-Generated-Resource': newId },
}
);
}),
PUT ハンドラー:
http.put(umbracoPath('/document/:id'), async ({ params, request }) => {
const id = params.id as string;
const body = await request.json();
mockDb.update(id, body);
return new HttpResponse(null, { status: 200 });
}),
DELETE ハンドラー:
http.delete(umbracoPath('/document/:id'), ({ params }) => {
const id = params.id as string;
mockDb.delete(id);
return new HttpResponse(null, { status: 200 });
}),
状態のシミュレーション
エラーレスポンス:
// 404 Not Found
http.get(umbracoPath('/document/:id'), ({ params }) => {
const doc = mockDb.read(params.id as string);
if (!doc) return new HttpResponse(null, { status: 404 });
return HttpResponse.json(doc);
}),
// 500 Server Error
http.get(umbracoPath('/document/:id'), () => {
return HttpResponse.json(
{ type: 'error', detail: 'Internal server error' },
{ status: 500 }
);
}),
検証エラー:
http.post(umbracoPath('/document'), async ({ request }) => {
const body = await request.json();
if (!body.name) {
return HttpResponse.json(
{
type: 'validation',
errors: {
name: ['Name is required'],
title: ['Title must be at least 3 characters'],
},
},
{ status: 400 }
);
}
return new HttpResponse(null, { status: 201 });
}),
遅延レスポンス (ローディング状態):
http.get(umbracoPath('/slow-endpoint'), async () => {
await delay(2000);
return HttpResponse.json({ data: 'loaded' });
}),
モックデータベースパターン
// src/mocks/data/items.db.ts
interface Item {
id: string;
name: string;
value: number;
}
class ItemsMockDb {
private data: Item[] = [
{ id: '1', name: 'Item 1', value: 100 },
{ id: '2', name: 'Item 2', value: 200 },
];
read(id: string) {
return this.data.find((item) => item.id === id);
}
readAll() {
return [...this.data];
}
create(item: Omit<Item, 'id'>) {
const newItem = { ...item, id: crypto.randomUUID() };
this.data.push(newItem);
return newItem.id;
}
update(id: string, updates: Partial<Item>) {
const index = this.data.findIndex((i) => i.id === id);
if (index !== -1) {
this.data[index] = { ...this.data[index], ...updates };
}
}
delete(id: string) {
this.data = this.data.filter((i) => i.id !== id);
}
}
export const itemsDb = new ItemsMockDb();
Worker のセットアップ
// src/mocks/setup.ts
const { setupWorker } = window.MockServiceWorker;
import { handlers } from './handlers.js';
const worker = setupWorker(...handlers);
export const startMockServiceWorker = () =>
worker.start({
onUnhandledRequest: 'warn',
quiet: true,
});
テストとの統合
テストファイル内:
import { expect, fixture } from '@open-wc/testing';
import { startMockServiceWorker } from './mocks/setup.js';
import './my-element.js';
// Start MSW before tests
before(async () => {
await startMockServiceWorker();
});
describe('MyElement with API', () => {
it('displays data from API', async () => {
const element = await fixture(html`<my-element></my-element>`);
await element.updateComplete;
// Element should show mocked data
expect(element.shadowRoot?.textContent).to.include('Item 1');
});
});
例
完全なハンドラーファイル
// src/mocks/handlers.ts
const { http, HttpResponse } = window.MockServiceWorker;
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
import { itemsDb } from './data/items.db.js';
export const handlers = [
// List items
http.get(umbracoPath('/my-extension/items'), () => {
const items = itemsDb.readAll();
return HttpResponse.json({ total: items.length, items });
}),
// Get single item
http.get(umbracoPath('/my-extension/items/:id'), ({ params }) => {
const item = itemsDb.read(params.id as string);
if (!item) return new HttpResponse(null, { status: 404 });
return HttpResponse.json(item);
}),
// Create item
http.post(umbracoPath('/my-extension/items'), async ({ request }) => {
const body = await request.json();
if (!body.name) {
return HttpResponse.json(
{ type: 'validation', errors: { name: ['Required'] } },
{ status: 400 }
);
}
const id = itemsDb.create(body);
return HttpResponse.json(
{ id },
{
status: 201,
headers: { 'Umb-Generated-Resource': id },
}
);
}),
// Update item
http.put(umbracoPath('/my-extension/items/:id'), async ({ params, request }) => {
const id = params.id as string;
if (!itemsDb.read(id)) return new HttpResponse(null, { status: 404 });
itemsDb.update(id, await request.json());
return new HttpResponse(null, { status: 200 });
}),
// Delete item
http.delete(umbracoPath('/my-extension/items/:id'), ({ params }) => {
const id = params.id as string;
if (!itemsDb.read(id)) return new HttpResponse(null, { status: 404 });
itemsDb.delete(id);
return new HttpResponse(null, { status: 200 });
}),
];
ハンドラーの整理
src/mocks/
├── handlers.ts # Aggregates all handlers
├── setup.ts # Worker setup
├── handlers/
│ ├── document.handlers.ts
│ ├── media.handlers.ts
│ └── my-extension.handlers.ts
└── data/
├── document.db.ts
└── items.db.ts
// handlers.ts
import { documentHandlers } from './handlers/document.handlers.js';
import { mediaHandlers } from './handlers/media.handlers.js';
import { myExtensionHandlers } from './handlers/my-extension.handlers.js';
export const handlers = [
...documentHandlers,
...mediaHandlers,
...myExtensionHandlers,
];
テストの実行
# Run all tests
npm test
# Run in watch mode
npm run test:watch
# Run specific file
npx web-test-runner src/my-element.test.ts
トラブルシューティング
MSW がリクエストをインターセプトしない
mockServiceWorker.jsがプロジェクトルートに存在することを確認する- テスト HTML で MSW スクリプトが読み込まれていることを確認する:
<script src="/node_modules/msw/lib/iife/index.js"></script> - テストが実行される前に worker が起動されていることを確認する
「http is not defined」
グローバルアクセスを使用します: const { http, HttpResponse } = window.MockServiceWorker;
ハンドラーが一致しない
パスが完全に一致していることを確認してください。Umbraco API パスには umbracoPath() を使用します。
リクエストが依然として実際のサーバーに到達する
コンソールで未処理のリクエストを確認するには、onUnhandledRequest: 'warn' が設定されていることを確認してください。
MSW v1 からの移行
MSW v1 からアップグレードする場合、主な変更点は次のとおりです:
| MSW v1 | MSW v2 |
|---|---|
rest.get() | http.get() |
rest.post() | http.post() |
(req, res, ctx) => res(ctx.json(data)) | () => HttpResponse.json(data) |
res(ctx.status(404)) | new HttpResponse(null, { status: 404 }) |
res(ctx.delay(2000), ctx.json(data)) | await delay(2000); return HttpResponse.json(data) |
req.params.id | ({ params }) => params.id |
await req.json() | ({ request }) => await request.json() |