mcpField
U
umbraco-msw-testing

umbraco-msw-testing

github.com/umbraco测试与质量27371 次安装6 周前更新
代码仓库

关于 umbraco-msw-testing

面向 Umbraco 后台扩展开发者的测试技能,介绍使用 MSW 拦截 API 并返回模拟响应。可在不启动 Umbraco 实例的情况下测试错误、加载和边界状态。

主要功能

  1. 01拦截 Umbraco API 请求
  2. 02模拟错误与验证响应
  3. 03模拟延迟和加载状态
  4. 04提供 MSW v2 处理器模式
  5. 05集成 Web Test Runner 测试

适用场景

  1. 01验证 API 错误处理
  2. 02测试加载指示器和骨架屏
  3. 03检查网络重试行为
  4. 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.js
package-lock.json
package.json
README.md
tsconfig.json
web-test-runner.config.mjs
examples/api-mocking/src/
items-list.element.test.ts
items-list.element.ts
examples/api-mocking/src/mocks/
handlers.ts
items.db.ts
setup.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 未拦截请求
  1. 检查项目根目录中是否存在 mockServiceWorker.js
  2. 确认测试 HTML 中已加载 MSW 脚本:<script src="/node_modules/msw/lib/iife/index.js"></script>
  3. 确保在运行测试前启动 worker
“http is not defined”

使用全局访问方式:const { http, HttpResponse } = window.MockServiceWorker;

处理程序不匹配

检查路径是否完全匹配。对于 Umbraco API 路径,请使用 umbracoPath()。

请求仍然发送到真实服务端

确保已设置 onUnhandledRequest: 'warn',以便在控制台中查看未处理的请求。


从 MSW v1 迁移

如果从 MSW v1 升级,以下是主要变化:

MSW v1MSW 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()

评论

支持 Markdown