umbraco-unit-testing

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.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "umbraco-unit-testing" with this command: npx skills add umbraco/umbraco-cms-backoffice-skills/umbraco-umbraco-cms-backoffice-skills-umbraco-unit-testing

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

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 chromium

Configuration

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 configuration

  • src/mocks/

  • Mock implementations

  • src/**/*.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 element

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

umbraco-backoffice

No summary provided by upstream source.

Repository SourceNeeds Review
General

umbraco-dashboard

No summary provided by upstream source.

Repository SourceNeeds Review
General

umbraco-quickstart

No summary provided by upstream source.

Repository SourceNeeds Review
General

umbraco-extension-template

No summary provided by upstream source.

Repository SourceNeeds Review