umbraco-msw-testing
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
- Check
mockServiceWorker.jsexists in project root - Verify MSW script is loaded in test HTML:
<script src="/node_modules/msw/lib/iife/index.js"></script> - 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() |