
Umbraco Unit Testing
- 278 installs
- 26 repo stars
- Updated August 1, 2026
- umbraco/umbraco-cms-backoffice-skills
Helps with testing & qa tasks.
About
umbraco-unit-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted coding.
- umbraco-unit-testing
- Testing & QA
- AI-coding skill
Umbraco Unit Testing by the numbers
- 278 all-time installs (skills.sh)
- +11 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #735 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-unit-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 278 |
|---|---|
| repo stars | ★ 26 |
| Last updated | August 1, 2026 |
| Repository | umbraco/umbraco-cms-backoffice-skills ↗ |
What it does
Helps with testing & qa tasks.
Files
Umbraco Unit Testing
What is it?
Unit testing for Umbraco backoffice extensions using @open-wc/testing - a testing framework designed for Web Components and Lit elements. This is the fastest and most isolated testing approach.
When to Use
- Testing context logic and state management
- Testing Lit element rendering and shadow DOM
- Testing observable subscriptions and state changes
- Testing controllers and utility functions
- Fast feedback during development
Related Skills
- umbraco-testing - Master skill for testing overview
- umbraco-msw-testing - Add API mocking to unit tests
Documentation
- @open-wc/testing: https://open-wc.org/docs/testing/testing-package/
- Web Test Runner: https://modern-web.dev/docs/test-runner/overview/
---
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"
},
"scripts": {
"test": "web-test-runner",
"test:watch": "web-test-runner --watch"
}
}Then run:
npm install
npx playwright install chromiumConfiguration
Create web-test-runner.config.mjs in the project root:
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',
// CRITICAL: Use dist-cms, NOT dist/packages
'@umbraco-cms/backoffice/lit-element':
'/node_modules/@umbraco-cms/backoffice/dist-cms/packages/core/lit-element/index.js',
// CRITICAL: libs are at dist-cms/libs/, NOT dist-cms/packages/
'@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',
// Add other imports as needed
},
},
},
}),
esbuildPlugin({
ts: true,
tsconfig: './tsconfig.json',
target: 'auto',
json: true,
}),
],
testRunnerHtml: (testFramework) =>
`<html lang="en-us">
<head>
<meta charset="UTF-8" />
</head>
<body>
<script type="module" src="${testFramework}"></script>
</body>
</html>`,
};Import Path Reference
| Type | Location | Example |
|---|---|---|
| Libs (low-level APIs) | dist-cms/libs/ | element-api, observable-api |
| Packages (features) | dist-cms/packages/ | core/lit-element, core/class-api |
Common mistake: Using dist/packages instead of dist-cms causes 404 errors.
---
Alternative: Mock-Based Approach (Simpler)
For simpler unit tests that don't need the full Umbraco context system, mock the Umbraco imports entirely. This approach:
- Avoids complex import map configuration
- Runs faster (no loading Umbraco packages)
- Tests logic in true isolation
- Works well for testing types, constants, and observable patterns
Simplified Configuration
// web-test-runner.config.mjs
import { esbuildPlugin } from '@web/dev-server-esbuild';
import { importMapsPlugin } from '@web/dev-server-import-maps';
import { playwrightLauncher } from '@web/test-runner-playwright';
export default {
files: 'src/**/*.test.ts',
nodeResolve: true,
browsers: [playwrightLauncher({ product: 'chromium' })],
plugins: [
esbuildPlugin({ ts: true }),
importMapsPlugin({
inject: {
importMap: {
imports: {
// Map Umbraco imports to local mocks
'@umbraco-cms/backoffice/external/lit': '/src/__mocks__/lit.js',
'@umbraco-cms/backoffice/observable-api': '/src/__mocks__/observable-api.js',
'@umbraco-cms/backoffice/class-api': '/src/__mocks__/class-api.js',
// Add others as needed
},
},
},
}),
],
};Mock Files
Create src/__mocks__/observable-api.js:
export class UmbStringState {
#value;
#subscribers = [];
constructor(initialValue) {
this.#value = initialValue;
}
getValue() { return this.#value; }
setValue(value) {
this.#value = value;
this.#subscribers.forEach(cb => cb(value));
}
asObservable() {
return {
subscribe: (callback) => {
this.#subscribers.push(callback);
callback(this.#value);
return { unsubscribe: () => {
const idx = this.#subscribers.indexOf(callback);
if (idx > -1) this.#subscribers.splice(idx, 1);
}};
}
};
}
destroy() { this.#subscribers = []; }
}Create src/__mocks__/lit.js:
export const html = (strings, ...values) => ({ strings, values });
export const css = (strings, ...values) => ({ strings, values });
export const nothing = Symbol('nothing');
export const customElement = (name) => (target) => target;
export const state = () => (target, propertyKey) => {};Testing with Mocks
import { expect } from '@open-wc/testing';
import { OUR_ENTITY_TYPE } from './types.js';
describe('Entity Types', () => {
it('should define entity type', () => {
expect(OUR_ENTITY_TYPE).to.equal('our-entity');
});
});When to Use Each Approach
| Scenario | Approach |
|---|---|
| Testing types, constants, pure functions | Mock-based (simpler) |
| Testing observable state patterns | Mock-based (simpler) |
| Testing Lit elements with shadow DOM | Full Umbraco imports |
| Testing context consumption between elements | Full Umbraco imports |
| Testing with UUI components | Full Umbraco imports |
Working Example
See tree-example in umbraco-backoffice/examples/tree-example/Client/:
web-test-runner.config.mjs- Mock-based configurationsrc/__mocks__/- Mock implementationssrc/**/*.test.ts- Unit tests using mocks
Directory Structure
my-extension/
├── src/
│ ├── my-context.ts
│ ├── my-context.test.ts # Test alongside source
│ ├── my-element.ts
│ └── my-element.test.ts
├── web-test-runner.config.mjs
├── package.json
└── tsconfig.json---
Patterns
Basic Test Structure
import { expect, fixture, defineCE } from '@open-wc/testing';
import { html } from 'lit';
describe('MyFeature', () => {
beforeEach(async () => {
// Setup for each test
});
afterEach(() => {
// Cleanup after each test
});
it('should do something', async () => {
// Arrange, Act, Assert
});
});Key Utilities
`fixture()` - Create and wait for element:
const element = await fixture(html`<my-element></my-element>`);
// With parent node (for context consumption)
const element = await fixture(html`<my-element></my-element>`, {
parentNode: hostElement,
});`defineCE()` - Define custom element with unique tag:
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
class TestHostElement extends UmbLitElement {}
const testHostTag = defineCE(TestHostElement);
const host = await fixture(`<${testHostTag}></${testHostTag}>`);`expect()` - Chai assertions:
expect(value).to.equal(5);
expect(value).to.be.true;
expect(array).to.have.length(3);
expect(element.shadowRoot?.textContent).to.include('Hello');Testing Contexts
import { expect, fixture, defineCE } from '@open-wc/testing';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { MyContext } from './my-context.js';
class TestHostElement extends UmbLitElement {}
const testHostTag = defineCE(TestHostElement);
describe('MyContext', () => {
let hostElement: UmbLitElement;
let context: MyContext;
beforeEach(async () => {
hostElement = await fixture(`<${testHostTag}></${testHostTag}>`);
context = new MyContext(hostElement);
});
it('initializes with default value', (done) => {
context.value.subscribe((value) => {
expect(value).to.equal(0);
done();
});
});
it('increments value', (done) => {
let callCount = 0;
context.value.subscribe((value) => {
callCount++;
if (callCount === 1) {
expect(value).to.equal(0);
context.increment();
} else if (callCount === 2) {
expect(value).to.equal(1);
done();
}
});
});
});Testing Lit Elements
import { expect, fixture } from '@open-wc/testing';
import { html } from 'lit';
import './my-element.js';
import type { MyElement } from './my-element.js';
describe('MyElement', () => {
let element: MyElement;
beforeEach(async () => {
element = await fixture(html`<my-element></my-element>`);
});
it('renders with default content', async () => {
expect(element.shadowRoot?.textContent).to.include('Default Value');
});
it('updates display when property changes', async () => {
element.value = 'New Value';
await element.updateComplete;
expect(element.shadowRoot?.textContent).to.include('New Value');
});
});Testing Elements with Context
import { expect, fixture, defineCE } from '@open-wc/testing';
import { html } from 'lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { MyContext } from './my-context.js';
import './my-view.js';
class TestHostElement extends UmbLitElement {}
const testHostTag = defineCE(TestHostElement);
describe('MyView', () => {
let element: MyViewElement;
let context: MyContext;
let hostElement: UmbLitElement;
beforeEach(async () => {
// 1. Create host element
hostElement = await fixture(`<${testHostTag}></${testHostTag}>`);
// 2. Create context on host
context = new MyContext(hostElement);
// 3. Create element as child of host
element = await fixture(html`<my-view></my-view>`, {
parentNode: hostElement,
});
await element.updateComplete;
});
it('displays value from context', async () => {
expect(element.shadowRoot?.textContent).to.include('Value: 0');
});
it('updates when context changes', async () => {
context.increment();
await element.updateComplete;
expect(element.shadowRoot?.textContent).to.include('Value: 1');
});
});Testing UI Interactions
UUI components use shadow DOM, so events need composed: true:
// Clicking buttons
it('button click triggers action', async () => {
const button = element.shadowRoot?.querySelector('uui-button') as HTMLElement;
button.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await element.updateComplete;
expect(element.shadowRoot?.textContent).to.include('clicked');
});
// Toggling uui-toggle
it('toggle changes state', async () => {
const toggle = element.shadowRoot?.querySelector('uui-toggle') as HTMLElement;
toggle.dispatchEvent(new Event('change', { bubbles: true }));
await element.updateComplete;
expect(element.shadowRoot?.textContent).to.include('toggled');
});Observable State Behavior
Important: State objects only emit when values change:
// This WILL emit twice (values different)
state.setValue(0);
state.setValue(1);
// This emits ONCE (same value - no second emission)
state.setValue(0);
state.setValue(0);Testing no-op operations:
it('does not go below 0', (done) => {
let callCount = 0;
context.count.subscribe((value) => {
callCount++;
if (callCount === 1) {
expect(value).to.equal(0);
context.decrement(); // Try to go below 0
setTimeout(() => {
expect(callCount).to.equal(1); // No second emission
done();
}, 50);
}
});
});---
Examples
Complete Context Test
import { expect, fixture, defineCE } from '@open-wc/testing';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { html } from '@umbraco-cms/backoffice/external/lit';
import { CounterContext } from './counter-context.js';
import './counter-view.js';
class TestHostElement extends UmbLitElement {}
const testHostTag = defineCE(TestHostElement);
describe('CounterContext', () => {
let element: UmbLitElement;
let context: CounterContext;
beforeEach(async () => {
element = await fixture(`<${testHostTag}></${testHostTag}>`);
context = new CounterContext(element);
});
it('initializes with 0', (done) => {
context.counter.subscribe((value) => {
expect(value).to.equal(0);
done();
});
});
it('increments', (done) => {
let callCount = 0;
context.counter.subscribe((value) => {
callCount++;
if (callCount === 1) {
context.increment();
} else if (callCount === 2) {
expect(value).to.equal(1);
done();
}
});
});
it('resets to 0', (done) => {
let callCount = 0;
context.counter.subscribe((value) => {
callCount++;
if (callCount === 1) {
context.increment();
context.increment();
} else if (callCount === 3) {
context.reset();
} else if (callCount === 4) {
expect(value).to.equal(0);
done();
}
});
});
});
describe('CounterView', () => {
let element: CounterViewElement;
let context: CounterContext;
let hostElement: UmbLitElement;
beforeEach(async () => {
hostElement = await fixture(`<${testHostTag}></${testHostTag}>`);
context = new CounterContext(hostElement);
element = await fixture(html`<counter-view></counter-view>`, {
parentNode: hostElement,
});
await element.updateComplete;
});
it('shows initial value', async () => {
expect(element.shadowRoot?.textContent).to.include('Count: 0');
});
it('reflects changes', async () => {
context.increment();
await element.updateComplete;
expect(element.shadowRoot?.textContent).to.include('Count: 1');
});
});---
Running Tests
# Run all unit tests
npm test
# Run in watch mode
npm run test:watch
# Run specific file
npx web-test-runner src/my-element.test.ts
# Run with coverage
npx web-test-runner --coverage---
Troubleshooting
404 errors for imports
Check import map paths. Use dist-cms/libs/ for APIs and dist-cms/packages/ for features.
Element not defined
Ensure you import the element file before using it in tests:
import './my-element.js'; // Side effect import registers elementContext not available
Element must be child of host with context:
element = await fixture(html`<my-element></my-element>`, {
parentNode: hostElement, // Host must have context
});Observable tests hang
Use done() callback for async subscriptions:
it('test', (done) => {
observable.subscribe((value) => {
expect(value).to.equal(expected);
done(); // Signal completion
});
});updateComplete not waiting
Ensure you await it:
element.value = 'new';
await element.updateComplete; // Must await
expect(element.shadowRoot?.textContent).to.include('new');{
"name": "counter-dashboard-example",
"version": "1.0.0",
"description": "Unit testing example for Umbraco backoffice extensions",
"type": "module",
"scripts": {
"test": "web-test-runner",
"test:watch": "web-test-runner --watch"
},
"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",
"@umbraco-cms/backoffice": "^14.0.0",
"lit": "^3.0.0",
"typescript": "^5.3.0"
}
}
Counter Dashboard - Unit Testing Example
A complete working example demonstrating how to write unit tests for Umbraco backoffice extensions using @open-wc/testing.
What This Example Shows
This example demonstrates:
- Testing Contexts - Testing state management with observables
- Testing Lit Elements - Testing component rendering and interactions
- Testing with Context - Setting up host elements for context consumption
- Async Testing - Using
done()callback for observable subscriptions
Extension Types Included
| Type | File | Description |
|---|---|---|
| Context | counter-context.ts | Manages counter state with increment/decrement/reset |
| Dashboard Element | counter-dashboard.element.ts | Displays counter and control buttons |
| Unit Tests | *.test.ts | Comprehensive tests for both |
Project Structure
counter-dashboard/
├── src/
│ ├── counter-context.ts # Context with counter state
│ ├── counter-context.test.ts # Context unit tests
│ ├── counter-dashboard.element.ts # Dashboard Lit element
│ └── counter-dashboard.element.test.ts # Element unit tests
├── web-test-runner.config.mjs # Test runner config
├── package.json
├── tsconfig.json
└── README.mdRunning the Tests
# Install dependencies
npm install
# Run tests
npm test
# Run tests in watch mode
npm run test:watchKey Testing Patterns
1. Testing a Context
import { expect, fixture, defineCE } from '@open-wc/testing';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
class TestHostElement extends UmbLitElement {}
const testHostTag = defineCE(TestHostElement);
describe('CounterContext', () => {
let context: CounterContext;
let hostElement: UmbLitElement;
beforeEach(async () => {
hostElement = await fixture(`<${testHostTag}></${testHostTag}>`);
context = new CounterContext(hostElement);
});
it('initializes with 0', (done) => {
context.count.subscribe((value) => {
expect(value).to.equal(0);
done();
});
});
it('increments correctly', (done) => {
let callCount = 0;
context.count.subscribe((value) => {
callCount++;
if (callCount === 1) {
expect(value).to.equal(0);
context.increment();
} else if (callCount === 2) {
expect(value).to.equal(1);
done();
}
});
});
});2. Testing a Lit Element
import { expect, fixture } from '@open-wc/testing';
import { html } from 'lit';
describe('CounterDashboard', () => {
let element: CounterDashboardElement;
beforeEach(async () => {
element = await fixture(html`<counter-dashboard></counter-dashboard>`);
});
it('renders the count display', () => {
const display = element.shadowRoot?.querySelector('.count-display');
expect(display).to.exist;
});
it('updates on button click', async () => {
const button = element.shadowRoot?.querySelector('.increment-btn');
button?.click();
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent).to.include('1');
});
});3. Testing Element with Context
describe('CounterDashboard with Context', () => {
let element: CounterDashboardElement;
let context: CounterContext;
let hostElement: UmbLitElement;
beforeEach(async () => {
// 1. Create host for context
hostElement = await fixture(`<${testHostTag}></${testHostTag}>`);
// 2. Create context on host
context = new CounterContext(hostElement);
// 3. Create element as child (so it can consume context)
element = await fixture(html`<counter-dashboard></counter-dashboard>`, {
parentNode: hostElement,
});
await element.updateComplete;
});
it('displays value from context', async () => {
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent).to.include('0');
});
it('updates when context changes', async () => {
context.increment();
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent).to.include('1');
});
});Skills Referenced
| Skill | What It Covers |
|---|---|
umbraco-unit-testing | @open-wc/testing patterns |
umbraco-context-api | Context creation and consumption |
umbraco-dashboard | Dashboard element patterns |
import { expect, fixture, defineCE } from '@open-wc/testing';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { CounterContext, COUNTER_CONTEXT } from './counter-context.js';
// Create a test host element for the context
class TestHostElement extends UmbLitElement {}
const testHostTag = defineCE(TestHostElement);
describe('CounterContext', () => {
let context: CounterContext;
let hostElement: UmbLitElement;
beforeEach(async () => {
hostElement = await fixture(`<${testHostTag}></${testHostTag}>`);
context = new CounterContext(hostElement);
});
describe('initialization', () => {
it('initializes with count of 0', (done) => {
context.count.subscribe((value) => {
expect(value).to.equal(0);
done();
});
});
it('provides context token', () => {
expect(COUNTER_CONTEXT).to.not.be.undefined;
});
});
describe('increment', () => {
it('increments count by 1', (done) => {
let callCount = 0;
context.count.subscribe((value) => {
callCount++;
if (callCount === 1) {
expect(value).to.equal(0);
context.increment();
} else if (callCount === 2) {
expect(value).to.equal(1);
done();
}
});
});
it('increments multiple times correctly', (done) => {
let callCount = 0;
context.count.subscribe((value) => {
callCount++;
if (callCount === 1) {
expect(value).to.equal(0);
context.increment();
context.increment();
context.increment();
} else if (callCount === 4) {
expect(value).to.equal(3);
done();
}
});
});
});
describe('decrement', () => {
it('decrements count by 1', (done) => {
let callCount = 0;
context.count.subscribe((value) => {
callCount++;
if (callCount === 1) {
// Start at 0, increment first
context.increment();
context.increment();
} else if (callCount === 3) {
expect(value).to.equal(2);
context.decrement();
} else if (callCount === 4) {
expect(value).to.equal(1);
done();
}
});
});
it('does not go below 0', (done) => {
let callCount = 0;
context.count.subscribe((value) => {
callCount++;
if (callCount === 1) {
expect(value).to.equal(0);
context.decrement(); // Should stay at 0
// UmbNumberState may not emit when value doesn't change
// So verify immediately and complete
setTimeout(() => {
expect(value).to.equal(0);
done();
}, 50);
}
});
});
});
describe('reset', () => {
it('resets count to 0', (done) => {
let callCount = 0;
context.count.subscribe((value) => {
callCount++;
if (callCount === 1) {
context.increment();
context.increment();
context.increment();
} else if (callCount === 4) {
expect(value).to.equal(3);
context.reset();
} else if (callCount === 5) {
expect(value).to.equal(0);
done();
}
});
});
it('works when already at 0', (done) => {
context.count.subscribe((value) => {
expect(value).to.equal(0);
context.reset();
expect(value).to.equal(0);
done();
});
});
});
describe('combined operations', () => {
it('handles increment, decrement, and reset in sequence', (done) => {
let callCount = 0;
context.count.subscribe((value) => {
callCount++;
if (callCount === 1) {
expect(value).to.equal(0);
context.increment();
} else if (callCount === 2) {
expect(value).to.equal(1);
context.increment();
} else if (callCount === 3) {
expect(value).to.equal(2);
context.decrement();
} else if (callCount === 4) {
expect(value).to.equal(1);
context.reset();
} else if (callCount === 5) {
expect(value).to.equal(0);
done();
}
});
});
});
});
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbNumberState } from '@umbraco-cms/backoffice/observable-api';
/**
* Counter Context - Manages counter state
*
* Demonstrates:
* - Private state with public observable
* - Methods to update state
* - Context token for dependency injection
*/
export class CounterContext extends UmbContextBase {
// Private state - only this class can modify
#count = new UmbNumberState(0);
// Public observable - consumers subscribe to changes
readonly count = this.#count.asObservable();
constructor(host: UmbControllerHost) {
super(host, COUNTER_CONTEXT);
}
/** Increment the counter by 1 */
increment(): void {
this.#count.setValue(this.#count.getValue() + 1);
}
/** Decrement the counter by 1 (minimum 0) */
decrement(): void {
const newValue = Math.max(0, this.#count.getValue() - 1);
this.#count.setValue(newValue);
}
/** Reset the counter to 0 */
reset(): void {
this.#count.setValue(0);
}
}
// Context Token for dependency injection
export const COUNTER_CONTEXT = new UmbContextToken<CounterContext>(
'CounterContext',
'example.counter.context',
);
import { expect, fixture, defineCE } from '@open-wc/testing';
import { html } from 'lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { CounterContext } from './counter-context.js';
import './counter-dashboard.element.js';
import type { CounterDashboardElement } from './counter-dashboard.element.js';
// Create a test host element for the context
class TestHostElement extends UmbLitElement {}
const testHostTag = defineCE(TestHostElement);
describe('CounterDashboardElement', () => {
describe('basic rendering', () => {
let element: CounterDashboardElement;
beforeEach(async () => {
element = await fixture(html`<counter-dashboard></counter-dashboard>`);
});
it('renders the dashboard container', () => {
const dashboard = element.shadowRoot?.querySelector('.dashboard');
expect(dashboard).to.exist;
});
it('renders the title', () => {
const title = element.shadowRoot?.querySelector('h1');
expect(title?.textContent).to.include('Counter Dashboard');
});
it('renders the count display', () => {
const display = element.shadowRoot?.querySelector('.count-display');
expect(display).to.exist;
});
it('renders control buttons', () => {
const incrementBtn = element.shadowRoot?.querySelector('.increment-btn');
const decrementBtn = element.shadowRoot?.querySelector('.decrement-btn');
const resetBtn = element.shadowRoot?.querySelector('.reset-btn');
expect(incrementBtn).to.exist;
expect(decrementBtn).to.exist;
expect(resetBtn).to.exist;
});
});
describe('with context', () => {
let element: CounterDashboardElement;
let context: CounterContext;
let hostElement: UmbLitElement;
beforeEach(async () => {
// 1. Create host for context
hostElement = await fixture(`<${testHostTag}></${testHostTag}>`);
// 2. Create context on host
context = new CounterContext(hostElement);
// 3. Create element as child (so it can consume context)
element = await fixture(html`<counter-dashboard></counter-dashboard>`, {
parentNode: hostElement,
});
await element.updateComplete;
});
it('displays initial count of 0', async () => {
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent?.trim()).to.equal('0');
});
it('updates display when context increments', async () => {
context.increment();
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent?.trim()).to.equal('1');
});
it('updates display when context decrements', async () => {
context.increment();
context.increment();
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent?.trim()).to.equal('2');
context.decrement();
await element.updateComplete;
expect(display?.textContent?.trim()).to.equal('1');
});
it('updates display when context resets', async () => {
context.increment();
context.increment();
context.increment();
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent?.trim()).to.equal('3');
context.reset();
await element.updateComplete;
expect(display?.textContent?.trim()).to.equal('0');
});
});
describe('button interactions with context', () => {
let element: CounterDashboardElement;
let context: CounterContext;
let hostElement: UmbLitElement;
beforeEach(async () => {
hostElement = await fixture(`<${testHostTag}></${testHostTag}>`);
context = new CounterContext(hostElement);
element = await fixture(html`<counter-dashboard></counter-dashboard>`, {
parentNode: hostElement,
});
await element.updateComplete;
});
it('increments when increment button is clicked', async () => {
const button = element.shadowRoot?.querySelector('.increment-btn') as HTMLButtonElement;
button?.click();
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent?.trim()).to.equal('1');
});
it('decrements when decrement button is clicked', async () => {
// First increment a few times
context.increment();
context.increment();
await element.updateComplete;
const button = element.shadowRoot?.querySelector('.decrement-btn') as HTMLButtonElement;
button?.click();
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent?.trim()).to.equal('1');
});
it('resets when reset button is clicked', async () => {
// First increment
context.increment();
context.increment();
context.increment();
await element.updateComplete;
const button = element.shadowRoot?.querySelector('.reset-btn') as HTMLButtonElement;
button?.click();
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent?.trim()).to.equal('0');
});
it('handles multiple rapid clicks', async () => {
const button = element.shadowRoot?.querySelector('.increment-btn') as HTMLButtonElement;
// Click rapidly
button?.click();
button?.click();
button?.click();
button?.click();
button?.click();
await element.updateComplete;
const display = element.shadowRoot?.querySelector('.count-display');
expect(display?.textContent?.trim()).to.equal('5');
});
});
});
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import { CounterContext, COUNTER_CONTEXT } from './counter-context.js';
/**
* Counter Dashboard Element
*
* Demonstrates:
* - Lit element with shadow DOM
* - Context consumption
* - Reactive state updates
* - User interactions
*/
@customElement('counter-dashboard')
export class CounterDashboardElement extends UmbElementMixin(LitElement) {
@state()
private _count = 0;
#counterContext?: CounterContext;
constructor() {
super();
// Consume the counter context
this.consumeContext(COUNTER_CONTEXT, (context) => {
this.#counterContext = context;
this.#observeCount();
});
}
#observeCount() {
if (!this.#counterContext) return;
this.observe(this.#counterContext.count, (count) => {
this._count = count;
});
}
#handleIncrement() {
this.#counterContext?.increment();
}
#handleDecrement() {
this.#counterContext?.decrement();
}
#handleReset() {
this.#counterContext?.reset();
}
static styles = css`
:host {
display: block;
padding: 20px;
font-family: var(--uui-font-family, sans-serif);
}
.dashboard {
background: var(--uui-color-surface, #fff);
border: 1px solid var(--uui-color-border, #e9e9e9);
border-radius: var(--uui-border-radius, 3px);
padding: 24px;
text-align: center;
}
h1 {
margin: 0 0 16px 0;
font-size: 1.5rem;
color: var(--uui-color-text, #1b264f);
}
.count-display {
font-size: 4rem;
font-weight: bold;
color: var(--uui-color-interactive, #1b264f);
margin: 24px 0;
}
.controls {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 16px;
}
button {
padding: 8px 16px;
font-size: 1rem;
border: 1px solid var(--uui-color-border, #e9e9e9);
border-radius: var(--uui-border-radius, 3px);
cursor: pointer;
background: var(--uui-color-surface, #fff);
}
button:hover {
background: var(--uui-color-surface-emphasis, #f5f5f5);
}
.increment-btn {
background: var(--uui-color-positive, #4caf50);
color: white;
border-color: var(--uui-color-positive, #4caf50);
}
.decrement-btn {
background: var(--uui-color-warning, #ff9800);
color: white;
border-color: var(--uui-color-warning, #ff9800);
}
.reset-btn {
background: var(--uui-color-danger, #f44336);
color: white;
border-color: var(--uui-color-danger, #f44336);
}
`;
render() {
return html`
<div class="dashboard">
<h1>Counter Dashboard</h1>
<div class="count-display">${this._count}</div>
<div class="controls">
<button class="decrement-btn" @click=${this.#handleDecrement}>
- Decrement
</button>
<button class="reset-btn" @click=${this.#handleReset}>
Reset
</button>
<button class="increment-btn" @click=${this.#handleIncrement}>
+ Increment
</button>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'counter-dashboard': CounterDashboardElement;
}
}
{
"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: {
// Map Umbraco backoffice imports (dist-cms structure)
'@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/context-api':
'/node_modules/@umbraco-cms/backoffice/dist-cms/libs/context-api/index.js',
'@umbraco-cms/backoffice/class-api':
'/node_modules/@umbraco-cms/backoffice/dist-cms/libs/class-api/index.js',
'@umbraco-cms/backoffice/controller-api':
'/node_modules/@umbraco-cms/backoffice/dist-cms/libs/controller-api/index.js',
'@umbraco-cms/backoffice/observable-api':
'/node_modules/@umbraco-cms/backoffice/dist-cms/libs/observable-api/index.js',
// 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" />
</head>
<body>
<script type="module" src="${testFramework}"></script>
</body>
</html>`,
};