umbraco-msw-testing

SKILL.md

Umbraco MSW Testing

What is it?

MSW (Mock Service Worker) enables testing Umbraco backoffice extensions by intercepting API calls and returning mock responses. This is ideal for testing error states, loading states, and edge cases without a running Umbraco instance.

When to Use

  • Testing API error handling (404, 500, validation errors)
  • Testing loading spinners and skeleton states
  • Testing network retry behavior
  • Testing edge cases without backend setup
  • Adding API mocking to unit tests

Related Skills

  • umbraco-testing - Master skill for testing overview
  • umbraco-unit-testing - Unit testing patterns (combine with MSW)

Documentation

  • MSW Docs: https://mswjs.io/docs/
  • Reference handlers: Umbraco-CMS/src/Umbraco.Web.UI.Client/src/mocks/handlers/

Setup

Dependencies

Add to 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"
  }
}

Then run:

npm install
npx playwright install chromium

The postinstall script copies mockServiceWorker.js to your project root. Without this file, MSW will fail silently.

Configuration

Create 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" />
        <!-- Load MSW v2 as IIFE to get window.MockServiceWorker -->
        <script src="/node_modules/msw/lib/iife/index.js"></script>
      </head>
      <body>
        <script type="module" src="${testFramework}"></script>
      </body>
    </html>`,
};

Directory Structure

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

Patterns

MSW v2 Syntax

Umbraco uses MSW v2. Key API patterns:

Concept MSW v2 Syntax
HTTP methods http.get(), http.post(), http.put(), http.delete()
JSON response HttpResponse.json(data)
Status codes HttpResponse.json(data, { status: 201 })
Empty response new HttpResponse(null, { status: 204 })
Request params ({ params }) => { ... }
Request body ({ request }) => { const body = await request.json(); }
Delay await delay(2000)

Global MSW Access

const { http, HttpResponse, delay } = window.MockServiceWorker;

umbracoPath Helper

import { umbracoPath } from '@umbraco-cms/backoffice/utils';

// Creates: /umbraco/management/api/v1/document/:id
umbracoPath('/document/:id')

Basic Handlers

GET Handler:

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 Handler:

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 Handler:

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 Handler:

http.delete(umbracoPath('/document/:id'), ({ params }) => {
  const id = params.id as string;
  mockDb.delete(id);
  return new HttpResponse(null, { status: 200 });
}),

Simulating States

Error Responses:

// 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 }
  );
}),

Validation Errors:

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 });
}),

Delayed Responses (Loading States):

http.get(umbracoPath('/slow-endpoint'), async () => {
  await delay(2000);
  return HttpResponse.json({ data: 'loaded' });
}),

Mock Database Pattern

// 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 Setup

// 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,
  });

Integration with Tests

In test file:

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');
  });
});

Examples

Complete Handler File

// 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 });
  }),
];

Handler Organization

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,
];

Running Tests

# 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

Troubleshooting

MSW not intercepting requests

  1. Check mockServiceWorker.js exists in project root
  2. Verify MSW script is loaded in test HTML: <script src="/node_modules/msw/lib/iife/index.js"></script>
  3. Ensure worker is started before tests run

"http is not defined"

Use global access: const { http, HttpResponse } = window.MockServiceWorker;

Handler not matching

Check path matches exactly. Use umbracoPath() for Umbraco API paths.

Requests still hitting real server

Ensure onUnhandledRequest: 'warn' is set to see unhandled requests in console.


Migration from MSW v1

If upgrading from MSW v1, here are the key changes:

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()
Weekly Installs
43
GitHub Stars
12
First Seen
Feb 4, 2026
Installed on
github-copilot33
opencode17
codex17
claude-code16
gemini-cli15
amp15