
Umbraco Msw Testing
- 277 installs
- 26 repo stars
- Updated August 1, 2026
- umbraco/umbraco-cms-backoffice-skills
Helps with testing & qa tasks.
About
umbraco-msw-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted coding.
- umbraco-msw-testing
- Testing & QA
- AI-coding skill
Umbraco Msw Testing by the numbers
- 277 all-time installs (skills.sh)
- +12 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #737 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/umbraco/umbraco-cms-backoffice-skills --skill umbraco-msw-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 277 |
|---|---|
| repo stars | ★ 26 |
| Last updated | August 1, 2026 |
| Repository | umbraco/umbraco-cms-backoffice-skills ↗ |
What it does
Helps with testing & qa tasks.
Files
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 chromiumThe 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() |
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.12.7'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
{
"name": "@example/msw-api-mocking",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Example demonstrating MSW API mocking for Umbraco backoffice testing",
"scripts": {
"postinstall": "npx msw init . --save",
"test": "web-test-runner",
"test:watch": "web-test-runner --watch"
},
"devDependencies": {
"@open-wc/testing": "^4.0.0",
"@web/dev-server-esbuild": "^1.0.2",
"@web/dev-server-import-maps": "^0.2.1",
"@web/test-runner": "^0.18.3",
"@web/test-runner-playwright": "^0.11.0",
"lit": "^3.2.0",
"msw": "^2.7.0",
"typescript": "^5.7.2"
},
"keywords": [
"umbraco",
"msw",
"testing",
"mock-service-worker"
],
"msw": {
"workerDirectory": [
""
]
}
}API Mocking Example - MSW Testing
This example demonstrates how to use MSW (Mock Service Worker) to mock Umbraco APIs for testing backoffice extensions without a running Umbraco instance.
What This Example Shows
This example demonstrates:
- MSW Handler Setup - Creating handlers for Umbraco API endpoints
- Mock Database - Stateful mock data management
- Error Simulation - Testing error states and edge cases
- Handler Override - Per-test handler customization
Files Included
| File | Description |
|---|---|
mocks/handlers.ts | API handlers using MSW v2 syntax |
mocks/items.db.ts | Mock database for items |
mocks/setup.ts | MSW worker setup |
items-list.element.ts | Element that fetches from API |
items-list.element.test.ts | Tests with mocked API |
Project Structure
api-mocking/
├── src/
│ ├── mocks/
│ │ ├── handlers.ts # MSW handlers
│ │ ├── items.db.ts # Mock database
│ │ └── setup.ts # Worker setup
│ ├── items-list.element.ts # Component using API
│ └── items-list.element.test.ts # Tests
├── web-test-runner.config.mjs
├── package.json
└── README.mdKey Patterns
1. MSW Handler (v2 Syntax)
const { http, HttpResponse } = window.MockServiceWorker;
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
http.get(umbracoPath('/items'), () => {
return HttpResponse.json({
total: items.length,
items: items,
});
}),
];2. Mock Database
class ItemsMockDb {
private items: Item[] = [...initialItems];
getAll() { return [...this.items]; }
getById(id: string) { return this.items.find(i => i.id === id); }
create(item: Item) { this.items.push(item); }
delete(id: string) { this.items = this.items.filter(i => i.id !== id); }
}3. Error Simulation
http.get(umbracoPath('/items/:id'), ({ params }) => {
const id = params.id as string;
// Simulate forbidden access
if (id === 'forbidden') {
return new HttpResponse(null, { status: 403 });
}
// Simulate not found
const item = db.getById(id);
if (!item) {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json(item);
}),4. Delay Simulation (Loading States)
http.get(umbracoPath('/slow-endpoint'), async () => {
await delay(2000); // 2 second delay
return HttpResponse.json({ data: 'loaded' });
}),Running the Tests
npm install # Automatically runs 'msw init' to create mockServiceWorker.js
npm testNote: The postinstall script runs npx msw init . --save to generate the mockServiceWorker.js file required for browser-based MSW testing.
Skills Referenced
| Skill | What It Covers |
|---|---|
umbraco-msw-testing | MSW handler patterns |
umbraco-unit-testing | @open-wc/testing integration |
/**
* Items List Element Tests with MSW
*
* Demonstrates:
* - Testing with mocked API responses
* - Loading state verification
* - Error handling tests
* - Runtime handler overrides
*/
import { expect, fixture, waitUntil } from '@open-wc/testing';
import { html } from 'lit';
// MSW v2 is loaded as IIFE and exposed globally
const { http, HttpResponse, delay } = window.MockServiceWorker;
import { worker, resetHandlers } from './mocks/setup.js';
import { itemsDb } from './mocks/items.db.js';
import './items-list.element.js';
import type { ItemsListElement } from './items-list.element.js';
const apiPath = (path: string) => `/umbraco/management/api/v1${path}`;
describe('ItemsListElement', () => {
// Start MSW before all tests
before(async () => {
await worker.start({ onUnhandledRequest: 'bypass', quiet: true });
});
// Stop MSW after all tests
after(() => {
worker.stop();
});
// Reset handlers and database between tests
beforeEach(() => {
resetHandlers();
itemsDb.reset();
});
describe('loading state', () => {
it('shows loading indicator initially', async () => {
const element = await fixture<ItemsListElement>(html`<items-list></items-list>`);
// Check for loading state (may be brief)
const loading = element.shadowRoot?.querySelector('.loading');
// Loading may or may not be visible depending on timing
expect(element).to.exist;
});
});
describe('success states', () => {
it('displays items from API', async () => {
const element = await fixture<ItemsListElement>(html`<items-list></items-list>`);
// Wait for loading to complete
await waitUntil(() => !element.shadowRoot?.querySelector('.loading'), 'Loading did not complete', {
timeout: 2000,
});
const items = element.shadowRoot?.querySelectorAll('.item');
expect(items?.length).to.equal(3); // Initial mock data has 3 items
const firstItem = element.shadowRoot?.querySelector('.item-name');
expect(firstItem?.textContent).to.equal('First Item');
});
it('shows empty state when no items', async () => {
// Override handler to return empty list
worker.use(
http.get(apiPath('/items'), () => {
return HttpResponse.json({
total: 0,
items: [],
});
})
);
const element = await fixture<ItemsListElement>(html`<items-list></items-list>`);
await waitUntil(() => !element.shadowRoot?.querySelector('.loading'), 'Loading did not complete', {
timeout: 2000,
});
const empty = element.shadowRoot?.querySelector('.empty');
expect(empty?.textContent).to.include('No items found');
});
});
describe('error states', () => {
it('shows error message on API failure', async () => {
// Override handler to return error
worker.use(
http.get(apiPath('/items'), () => {
return HttpResponse.json(
{
type: 'error',
status: 500,
detail: 'Database connection failed',
},
{ status: 500 }
);
})
);
const element = await fixture<ItemsListElement>(html`<items-list></items-list>`);
await waitUntil(() => element.shadowRoot?.querySelector('.error'), 'Error did not appear', { timeout: 2000 });
const error = element.shadowRoot?.querySelector('.error');
expect(error?.textContent).to.include('Database connection failed');
});
it('handles network errors gracefully', async () => {
// Override handler to simulate network error
worker.use(
http.get(apiPath('/items'), () => {
return HttpResponse.error();
})
);
const element = await fixture<ItemsListElement>(html`<items-list></items-list>`);
await waitUntil(() => element.shadowRoot?.querySelector('.error'), 'Error did not appear', { timeout: 2000 });
const error = element.shadowRoot?.querySelector('.error');
expect(error).to.exist;
});
});
describe('delete functionality', () => {
it('removes item from list after delete', async () => {
const element = await fixture<ItemsListElement>(html`<items-list></items-list>`);
await waitUntil(() => !element.shadowRoot?.querySelector('.loading'), 'Loading did not complete', {
timeout: 2000,
});
// Should have 3 items initially
let items = element.shadowRoot?.querySelectorAll('.item');
expect(items?.length).to.equal(3);
// Delete first item
await element.deleteItem('item-1');
// Wait for reload
await waitUntil(
() => {
const currentItems = element.shadowRoot?.querySelectorAll('.item');
return currentItems?.length === 2;
},
'Item was not deleted',
{ timeout: 2000 }
);
items = element.shadowRoot?.querySelectorAll('.item');
expect(items?.length).to.equal(2);
});
});
describe('slow responses', () => {
it('maintains loading state during slow response', async () => {
// Override with slow response
worker.use(
http.get(apiPath('/items'), async () => {
await delay(500);
return HttpResponse.json({
total: 1,
items: [{ id: 'slow-1', name: 'Slow Item', description: '', createdAt: new Date().toISOString() }],
});
})
);
const element = await fixture<ItemsListElement>(html`<items-list></items-list>`);
// Should show loading initially
const loading = element.shadowRoot?.querySelector('.loading');
expect(loading).to.exist;
// Wait for data to load
await waitUntil(() => element.shadowRoot?.querySelector('.item'), 'Item did not appear', { timeout: 2000 });
const item = element.shadowRoot?.querySelector('.item-name');
expect(item?.textContent).to.equal('Slow Item');
});
});
});
// Type declaration for global MSW (v2)
declare global {
interface Window {
MockServiceWorker: {
setupWorker: typeof import('msw/browser').setupWorker;
http: typeof import('msw').http;
HttpResponse: typeof import('msw').HttpResponse;
delay: typeof import('msw').delay;
};
}
}
/**
* Items List Element
*
* Demonstrates:
* - Fetching data from API
* - Loading states
* - Error handling
* - Lit element patterns
*/
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import type { Item } from './mocks/items.db.js';
interface ItemsResponse {
total: number;
items: Item[];
}
interface ApiError {
type: string;
status: number;
detail: string;
}
@customElement('items-list')
export class ItemsListElement extends LitElement {
static styles = css`
:host {
display: block;
padding: 16px;
}
.loading {
color: #666;
font-style: italic;
}
.error {
color: #d32f2f;
padding: 8px;
background: #ffebee;
border-radius: 4px;
}
.items {
list-style: none;
padding: 0;
margin: 0;
}
.item {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
}
.item:last-child {
border-bottom: none;
}
.item-name {
font-weight: bold;
}
.item-description {
color: #666;
font-size: 0.9em;
}
.empty {
color: #999;
text-align: center;
padding: 24px;
}
`;
@state()
private _items: Item[] = [];
@state()
private _loading = false;
@state()
private _error: string | null = null;
private _apiBase = '/umbraco/management/api/v1';
connectedCallback(): void {
super.connectedCallback();
this._loadItems();
}
async _loadItems(): Promise<void> {
this._loading = true;
this._error = null;
try {
const response = await fetch(`${this._apiBase}/items`);
if (!response.ok) {
const errorData = (await response.json()) as ApiError;
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data = (await response.json()) as ItemsResponse;
this._items = data.items;
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to load items';
} finally {
this._loading = false;
}
}
async deleteItem(id: string): Promise<void> {
try {
const response = await fetch(`${this._apiBase}/items/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to delete: HTTP ${response.status}`);
}
// Reload items after deletion
await this._loadItems();
} catch (err) {
this._error = err instanceof Error ? err.message : 'Failed to delete item';
}
}
render() {
if (this._loading) {
return html`<div class="loading">Loading items...</div>`;
}
if (this._error) {
return html`<div class="error">${this._error}</div>`;
}
if (this._items.length === 0) {
return html`<div class="empty">No items found</div>`;
}
return html`
<ul class="items">
${this._items.map(
(item) => html`
<li class="item">
<div class="item-name">${item.name}</div>
${item.description ? html`<div class="item-description">${item.description}</div>` : nothing}
<button @click=${() => this.deleteItem(item.id)}>Delete</button>
</li>
`
)}
</ul>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'items-list': ItemsListElement;
}
}
/**
* MSW Handlers for Items API
*
* Demonstrates:
* - MSW v2 syntax with http API
* - CRUD handlers
* - Error simulation
* - Delay simulation
*/
// MSW v2 is loaded as IIFE and exposed globally
const { http, HttpResponse, delay } = window.MockServiceWorker;
import { itemsDb, type Item } from './items.db.js';
// Helper to create API paths (simplified version of umbracoPath)
const apiPath = (path: string) => `/umbraco/management/api/v1${path}`;
export const itemsHandlers = [
// GET all items
http.get(apiPath('/items'), () => {
const items = itemsDb.getAll();
return HttpResponse.json({
total: items.length,
items: items,
});
}),
// GET single item by ID
http.get(apiPath('/items/:id'), ({ params }) => {
const id = params.id as string;
// Simulate forbidden access for testing
if (id === 'forbidden') {
return new HttpResponse(null, { status: 403 });
}
// Simulate server error for testing
if (id === 'error') {
return HttpResponse.json(
{
type: 'error',
status: 500,
detail: 'Internal server error',
},
{ status: 500 }
);
}
const item = itemsDb.getById(id);
if (!item) {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json(item);
}),
// POST create new item
http.post(apiPath('/items'), async ({ request }) => {
const body = (await request.json()) as Partial<Item>;
// Validate required fields
if (!body.name) {
return HttpResponse.json(
{
type: 'validation',
status: 400,
detail: 'Validation failed',
errors: {
name: ['Name is required'],
},
},
{ status: 400 }
);
}
// Check for duplicate name
if (itemsDb.getByName(body.name)) {
return HttpResponse.json(
{
type: 'validation',
status: 400,
detail: 'Item already exists',
errors: {
name: ['An item with this name already exists'],
},
},
{ status: 400 }
);
}
const newItem = itemsDb.create({
name: body.name,
description: body.description || '',
});
return HttpResponse.json(newItem, {
status: 201,
headers: {
Location: `${apiPath('/items')}/${newItem.id}`,
'Umb-Generated-Resource': newItem.id,
},
});
}),
// PUT update item
http.put(apiPath('/items/:id'), async ({ params, request }) => {
const id = params.id as string;
const body = (await request.json()) as Partial<Item>;
if (!itemsDb.exists(id)) {
return new HttpResponse(null, { status: 404 });
}
const updated = itemsDb.update(id, body);
return HttpResponse.json(updated);
}),
// DELETE item
http.delete(apiPath('/items/:id'), ({ params }) => {
const id = params.id as string;
if (!itemsDb.exists(id)) {
return new HttpResponse(null, { status: 404 });
}
itemsDb.delete(id);
return new HttpResponse(null, { status: 200 });
}),
];
// Slow endpoint for testing loading states
export const slowHandlers = [
http.get(apiPath('/slow'), async () => {
await delay(2000);
return HttpResponse.json({ message: 'Finally loaded!' });
}),
];
// Export all handlers
export const handlers = [...itemsHandlers, ...slowHandlers];
/**
* Mock Database for Items
*
* Demonstrates:
* - Stateful mock data
* - CRUD operations
* - Data isolation between tests
*/
export interface Item {
id: string;
name: string;
description: string;
createdAt: string;
}
// Initial mock data
const initialItems: Item[] = [
{
id: 'item-1',
name: 'First Item',
description: 'This is the first item',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'item-2',
name: 'Second Item',
description: 'This is the second item',
createdAt: '2024-01-02T00:00:00Z',
},
{
id: 'item-3',
name: 'Third Item',
description: 'This is the third item',
createdAt: '2024-01-03T00:00:00Z',
},
];
/**
* Items Mock Database
*
* Provides CRUD operations for mock items.
* State persists during test session but can be reset.
*/
class ItemsMockDb {
private items: Item[] = [...initialItems];
/** Get all items */
getAll(): Item[] {
return [...this.items];
}
/** Get item by ID */
getById(id: string): Item | undefined {
return this.items.find((item) => item.id === id);
}
/** Get item by name */
getByName(name: string): Item | undefined {
return this.items.find((item) => item.name === name);
}
/** Create a new item */
create(item: Omit<Item, 'id' | 'createdAt'>): Item {
const newItem: Item = {
...item,
id: `item-${Date.now()}`,
createdAt: new Date().toISOString(),
};
this.items.push(newItem);
return newItem;
}
/** Update an existing item */
update(id: string, updates: Partial<Omit<Item, 'id' | 'createdAt'>>): Item | undefined {
const index = this.items.findIndex((item) => item.id === id);
if (index === -1) return undefined;
this.items[index] = { ...this.items[index], ...updates };
return this.items[index];
}
/** Delete an item */
delete(id: string): boolean {
const initialLength = this.items.length;
this.items = this.items.filter((item) => item.id !== id);
return this.items.length < initialLength;
}
/** Check if item exists */
exists(id: string): boolean {
return this.items.some((item) => item.id === id);
}
/** Reset to initial state (useful between tests) */
reset(): void {
this.items = [...initialItems];
}
/** Get count */
get count(): number {
return this.items.length;
}
}
// Export singleton instance
export const itemsDb = new ItemsMockDb();
/**
* MSW Worker Setup
*
* Demonstrates:
* - MSW v2 setup pattern
* - Browser worker initialization
* - Handler registration
*/
// MSW v2 is loaded as IIFE and exposed globally
const { setupWorker } = window.MockServiceWorker;
import { handlers } from './handlers.js';
// Create and export the worker
export const worker = setupWorker(...handlers);
// Start options for tests
export const startOptions = {
onUnhandledRequest: 'bypass' as const,
quiet: true,
};
/**
* Start the MSW worker
* Call this before running tests
*/
export async function startMocking(): Promise<void> {
await worker.start(startOptions);
}
/**
* Stop the MSW worker
* Call this after tests complete
*/
export function stopMocking(): void {
worker.stop();
}
/**
* Reset handlers to defaults
* Call this between tests to ensure clean state
*/
export function resetHandlers(): void {
worker.resetHandlers();
}
// Type declaration for global MSW (v2)
declare global {
interface Window {
MockServiceWorker: {
setupWorker: typeof import('msw/browser').setupWorker;
http: typeof import('msw').http;
HttpResponse: typeof import('msw').HttpResponse;
delay: typeof import('msw').delay;
};
}
}
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"noEmit": true
},
"include": ["src/**/*.ts"]
}
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'],
nodeResolve: {
exportConditions: ['development'],
preferBuiltins: false,
browser: true,
},
browsers: [playwrightLauncher({ product: 'chromium' })],
plugins: [
importMapsPlugin({
inject: {
importMap: {
imports: {
// Lit imports
lit: '/node_modules/lit/index.js',
'lit/': '/node_modules/lit/',
'lit/decorators.js': '/node_modules/lit/decorators.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 (global) - exposes http, HttpResponse, delay, setupWorker -->
<script src="/node_modules/msw/lib/iife/index.js"></script>
</head>
<body>
<script type="module" src="${testFramework}"></script>
</body>
</html>`,
};