Claude
Skills
Sign in
Back

umbraco-unit-testing

Included with Lifetime
$97 forever

Unit and component testing for Umbraco backoffice extensions using @open-wc/testing

Design

What this skill does

# 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`:

```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:
```bash
npm install
npx playwright install chromium
```

### Configuration

Create `web-test-runner.config.mjs` in the project root:

```javascript
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

```javascript
// 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`:

```javascript
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`:

```javascript
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

```typescript
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

```typescript
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:
```typescript
const element = await fixt

Related in Design