mcpField
U
umbraco-unit-testing

umbraco-unit-testing

github.com/umbracoテスト・品質27366 回インストール6 週間前に更新
リポジトリ

umbraco-unit-testing について

umbraco-unit-testing は、@open-wc/testing を使って Umbraco のバックオフィス拡張をテストするスキルです。Lit 要素や Web Components の開発者向けに、Umbraco の実インポートとモックの両方で、コンテキスト、状態、Shadow DOM を検証する方法を紹介します。

主な機能

  1. 01@open-wc/testing でテストを作成
  2. 02Lit 要素と Shadow DOM をテスト
  3. 03コンテキストと状態変化を確認
  4. 04必要に応じて Umbraco のインポートをモック
  5. 05Web Test Runner を設定

活用シーン

  1. 01バックオフィス拡張のコンポーネント描画をテスト
  2. 02コンテキストの受け渡しと利用を検証
  3. 03型、定数、純粋関数のテストを分離
  4. 04Observable の状態変化を確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/umbraco/umbraco-cms-backoffice-skills --skill umbraco-unit-testing

インストール後、Claude Code で/umbraco-unit-testingを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

examples/counter-dashboard/
package-lock.json
package.json
README.md
tsconfig.json
web-test-runner.config.mjs
examples/counter-dashboard/src/
counter-context.test.ts
counter-context.ts
counter-dashboard.element.test.ts
counter-dashboard.element.ts

詳細

Umbraco 単体テスト

これは何ですか?

@open-wc/testing を使用した Umbraco バックオフィス拡張機能の単体テスト - Web Components と Lit 要素向けに設計されたテストフレームワークです。これは最も高速で最も分離されたテストアプローチです。

使用する場面

  • コンテキストロジックと状態管理のテスト
  • Lit 要素のレンダリングとシャドウ DOM のテスト
  • オブザーバブル購読と状態変更のテスト
  • コントローラーとユーティリティ関数のテスト
  • 開発中の迅速なフィードバック

関連スキル

  • umbraco-testing - テスト概要に関するマスタースキル
  • umbraco-msw-testing - 単体テストに API モックを追加する

ドキュメント


セットアップ

依存関係

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"
  }
}

次に実行します:

npm install
npx playwright install chromium
設定

プロジェクトルートに 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',
            // 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>`,
};
インポートパスの参照
種類場所例
Libs(低レベル API)dist-cms/libs/element-api, observable-api
Packages(機能)dist-cms/packages/core/lit-element, core/class-api

よくある間違い:dist-cms の代わりに dist/packages を使用すると 404 エラーが発生します。


代替案:モックベースのアプローチ(よりシンプル)

完全な Umbraco コンテキストシステムを必要としない、よりシンプルな単体テストでは、Umbraco のインポートを完全にモック化します。このアプローチは:

  • 複雑なインポートマップの設定を回避します
  • より高速に実行されます(Umbraco パッケージを読み込みません)
  • 真の分離状態でロジックをテストします
  • 型、定数、オブザーバブルパターンのテストに適しています
簡略化された設定
// 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
          },
        },
      },
    }),
  ],
};
モックファイル

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 = []; }
}

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) => {};
モックを使用したテスト
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');
  });
});
各アプローチを使い分ける場面
シナリオアプローチ
型、定数、純粋関数のテストモックベース(よりシンプル)
オブザーバブルな状態パターンのテストモックベース(よりシンプル)
シャドウ DOM を使用する Lit 要素のテスト完全な Umbraco インポート
要素間のコンテキスト消費のテスト完全な Umbraco インポート
UUI コンポーネントを使用したテスト完全な Umbraco インポート
動作する例

umbraco-backoffice/examples/tree-example/Client/ の tree-example を参照してください:

  • web-test-runner.config.mjs - モックベースの設定
  • src/__mocks__/ - モック実装
  • src/**/*.test.ts - モックを使用した単体テスト
ディレクトリ構造
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

パターン

基本的なテスト構造
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
  });
});
主要なユーティリティ

fixture() - 要素を作成して待機します:

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() - 一意のタグでカスタム要素を定義します:

import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';

class TestHostElement extends UmbLitElement {}
const testHostTag = defineCE(TestHostElement);

const host = await fixture(`<${testHostTag}></${testHostTag}>`);

expect() - Chai アサーション:

expect(value).to.equal(5);
expect(value).to.be.true;
expect(array).to.have.length(3);
expect(element.shadowRoot?.textContent).to.include('Hello');
コンテキストのテスト
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();
      }
    });
  });
});
Lit 要素のテスト
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');
  });
});
コンテキストを持つ要素のテスト
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');
  });
});
UI インタラクションのテスト

UUI コンポーネントはシャドウ DOM を使用するため、イベントには 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');
});
オブザーバブルな状態の動作

重要:状態オブジェクトは値が変更されたときにのみ発行されます:

// 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);

ノーオペレーションのテスト:

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);
    }
  });
});

例

完全なコンテキストテスト
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');
  });
});

テストの実行

# 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

トラブルシューティング

インポートの 404 エラー

インポートマップのパスを確認してください。API には dist-cms/libs/ を、機能には dist-cms/packages/ を使用します。

要素が定義されていない

テストで使用する前に、要素ファイルをインポートしていることを確認してください:

import './my-element.js'; // Side effect import registers element
コンテキストが利用できない

要素はコンテキストを持つホストの子である必要があります:

element = await fixture(html`<my-element></my-element>`, {
  parentNode: hostElement, // Host must have context
});
オブザーバブルのテストがハングする

非同期購読には done() コールバックを使用します:

it('test', (done) => {
  observable.subscribe((value) => {
    expect(value).to.equal(expected);
    done(); // Signal completion
  });
});
updateComplete が待機しない

必ず await してください:

element.value = 'new';
await element.updateComplete; // Must await
expect(element.shadowRoot?.textContent).to.include('new');

コメント