高度なテスト機能

Playwright では、機能テスト以外にも、ビジュアル回帰テスト、ネットワーク要求のモック作成、アクセシビリティ監査の組み込みサポートが提供されています。 この記事では、Power Platform アプリ テストにそれぞれを適用する方法について説明します。

ビジュアル比較テスト

Playwright の toHaveScreenshot() アサーションは、最初の実行時のベースライン スクリーンショットをキャプチャし、後続の実行と比較します。 ピクセル レベルの違いはテストに失敗します。

キャンバス アプリのベースラインをキャプチャする

次の例では、キャンバス アプリを起動し、ギャラリー コントロールのスクリーンショットをキャプチャして、将来の比較のための視覚的なベースラインを確立します。

import { test, expect } from '@playwright/test';
import { AppProvider, AppType, AppLaunchMode, buildCanvasAppUrlFromEnv } from 'power-platform-playwright-toolkit';

test('gallery matches visual baseline', async ({ page, context }) => {
  const app = new AppProvider(page, context);
  await app.launch({
    app: 'Orders App',
    type: AppType.Canvas,
    mode: AppLaunchMode.Play,
    skipMakerPortal: true,
    directUrl: buildCanvasAppUrlFromEnv(),
  });

  const canvasFrame = page.frameLocator('iframe[name="fullscreen-app-host"]');
  await canvasFrame
    .locator('[data-control-part="gallery-item"]')
    .first()
    .waitFor({ state: 'visible', timeout: 60000 });

  // Capture the canvas frame only (not the model-driven app shell chrome)
  const galleryLocator = canvasFrame.locator('[data-control-name="Gallery1"]');
  await expect(galleryLocator).toHaveScreenshot('orders-gallery.png');
});

Note

最初の実行時に、Playwright はベースラインのスクリーンショットを tests/__screenshots__/に書き込みます。 これらのファイルをソース管理にコミットします。 その後それらに対して diff を実行します。

ベースラインの更新

UI が意図的に変更されたら、ベースラインを更新します。

npx playwright test --update-snapshots

スクリーンショットのしきい値を構成する

環境間でのフォントレンダリングに対応するために、小さなピクセル差を許可します。

// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.01,  // allow 1% pixel difference
      threshold: 0.2,           // per-pixel color difference threshold
    },
  },
});

モデル駆動型アプリ ビューを比較する

モデル駆動型アプリの場合は、動的なタイムスタンプをキャプチャしないようにスクリーンショットのスコープを設定します。

test('order grid matches visual baseline', async ({ page, context }) => {
  const app = new AppProvider(page, context);
  await app.launch({ ... });
  const mda = app.getModelDrivenAppPage();

  await mda.navigateToGridView('nwind_orders');
  await mda.grid.waitForGridLoad();

  // Capture only the grid container, not the full page
  const grid = page.locator('[ref="eBodyContainer"]');
  await expect(grid).toHaveScreenshot('orders-grid.png', {
    mask: [page.locator('[col-id="modifiedon"]')],  // mask dynamic columns
  });
});

ネットワーク要求のモックテスト

Playwright の page.route() は HTTP 要求をインターセプトします。 これを使用して、Dataverse API の応答をモック化したり、エラー状態をシミュレートしたり、ライブ データを必要としないテストを高速化したりできます。

Dataverse WebApi 応答をモックする

次の例では、Dataverse WebAPI 呼び出しをインターセプトし、モックされた JSON 応答を返します。そのため、ライブ データに依存せずにアプリの動作をテストできます。

test('gallery shows mocked orders', async ({ page, context }) => {
  // Intercept Dataverse API calls before launching the app
  await page.route('**/api/data/v9.2/nwind_orders*', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        value: [
          { nwind_ordernumber: 'ORD-MOCK-001', nwind_name: 'Mocked Order 1' },
          { nwind_ordernumber: 'ORD-MOCK-002', nwind_name: 'Mocked Order 2' },
        ],
      }),
    });
  });

  const app = new AppProvider(page, context);
  await app.launch({ ... });

  const canvasFrame = page.frameLocator('iframe[name="fullscreen-app-host"]');
  await expect(
    canvasFrame.locator('[data-control-part="gallery-item"]').first()
  ).toBeVisible({ timeout: 30000 });

  // Verify mocked data appears
  await expect(
    canvasFrame.locator('[data-control-name="Title1"]').getByText('Mocked Order 1')
  ).toBeVisible();
});

API エラーをシミュレートする

次の例では、500 状態コードを返すことでサーバー エラーをシミュレートするため、アプリに適切なエラー状態が表示されることを確認できます。

test('shows error state when API fails', async ({ page, context }) => {
  await page.route('**/api/data/v9.2/nwind_orders*', (route) => {
    route.fulfill({ status: 500, body: 'Internal Server Error' });
  });

  const app = new AppProvider(page, context);
  await app.launch({ ... });

  const canvasFrame = page.frameLocator('iframe[name="fullscreen-app-host"]');

  // Verify the app shows an error or empty state
  await expect(canvasFrame.locator('[data-control-name="ErrorLabel1"]')).toBeVisible();
});

要求を傍受して監視する (モックテストを使用せずに)

次の例では、Dataverse API への送信 POST 要求を変更せずにリッスンするため、ユーザーアクションが発生したときにアプリが予期される要求を送信することを確認できます。

test('save triggers a POST to Dataverse', async ({ page, context }) => {
  const apiRequests: string[] = [];

  page.on('request', (req) => {
    if (req.url().includes('/api/data/v9.2/') && req.method() === 'POST') {
      apiRequests.push(req.url());
    }
  });

  // ... perform save action ...

  expect(apiRequests.some((url) => url.includes('nwind_orders'))).toBe(true);
});

Tip

要求の監視とモック フルフィルメントを組み合わせて、正しい OData クエリが Dataverse に送信されることを検証します。 この方法は、フィルターと展開が正しく構築されていることを確認するのに役立ちます。

アクセシビリティ テスト

Playwright は、 パッケージを通じて @axe-core/playwright と統合して、Web コンテンツ アクセシビリティ ガイドライン (WCAG) コンプライアンスのページを監査します。

Playwright 用の axe-core をインストールする

次のコマンドを実行して、axe-core Playwright パッケージを開発依存関係としてテスト プロジェクトに追加します。

cd packages/e2e-tests
npm install --save-dev @axe-core/playwright

アクセシビリティ違反のキャンバス アプリを監査する

次の例では、キャンバス アプリを起動し、WCAG 2.0 レベル A および AA ルールをスコープとする axe-core 監査を実行します。 違反が見つかると、テストは失敗します。

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { AppProvider, AppType, AppLaunchMode, buildCanvasAppUrlFromEnv } from 'power-platform-playwright-toolkit';

test('canvas app has no critical accessibility violations', async ({ page, context }) => {
  const app = new AppProvider(page, context);
  await app.launch({
    app: 'Orders App',
    type: AppType.Canvas,
    mode: AppLaunchMode.Play,
    skipMakerPortal: true,
    directUrl: buildCanvasAppUrlFromEnv(),
  });

  const canvasFrame = page.frameLocator('iframe[name="fullscreen-app-host"]');
  await canvasFrame
    .locator('[data-control-part="gallery-item"]')
    .first()
    .waitFor({ state: 'visible', timeout: 60000 });

  // Audit the canvas iframe content
  const frame = page.frame({ name: 'fullscreen-app-host' });
  if (!frame) throw new Error('Canvas frame not found');

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .include('iframe[name="fullscreen-app-host"]')
    .analyze();

  expect(results.violations).toEqual([]);
});

モデル駆動型アプリ フォームを監査する

次の例では、モデル駆動型アプリでレコードを開き、アクセシビリティ監査を実行します。 重要かつ重大な違反のみをフィルターします。

test('order form has no accessibility violations', async ({ page, context }) => {
  const app = new AppProvider(page, context);
  await app.launch({ ... });
  const mda = app.getModelDrivenAppPage();

  await mda.navigateToGridView('nwind_orders');
  await mda.grid.waitForGridLoad();
  await mda.grid.openRecord({ rowNumber: 0 });

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .exclude('.ms-Spinner')  // exclude loading spinners
    .analyze();

  // Filter to critical and serious violations only
  const critical = results.violations.filter(
    (v) => v.impact === 'critical' || v.impact === 'serious'
  );

  expect(critical).toEqual([]);
});

アクセシビリティ違反を報告する

違反が見つかったときに読み取り可能な出力を取得するには、テスト出力でそれらを書式設定します。

if (results.violations.length > 0) {
  const summary = results.violations
    .map((v) => `[${v.impact}] ${v.id}: ${v.description}`)
    .join('\n');

  throw new Error(`Accessibility violations found:\n${summary}`);
}

既知の違反を除外する

アプリに、個別に受け入れるか追跡する既知の違反がある場合:

const results = await new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa'])
  .disableRules(['color-contrast'])  // Known issue tracked in #123
  .analyze();

Important

ルールの無効化は一時的なものでなければなりません。 無効になっている各ルールを作業項目参照で追跡して、修正されるようにします。

機能を組み合わせる

ビジュアル テスト、ネットワーク テスト、アクセシビリティ テストを同じテスト ファイルに組み合わせることができます。 一般的なパターンは、3 つすべてを実行する スモーク テスト スイート です。

test.describe('Canvas app smoke tests', () => {
  test('loads successfully (visual)', async ({ page, context }) => {
    // ... launch app ...
    await expect(canvasFrame.locator('[data-control-name="Gallery1"]'))
      .toHaveScreenshot('gallery-baseline.png');
  });

  test('Dataverse API is called on load (network)', async ({ page, context }) => {
    const calls: string[] = [];
    page.on('request', (req) => {
      if (req.url().includes('/api/data/')) calls.push(req.url());
    });
    // ... launch app ...
    expect(calls.length).toBeGreaterThan(0);
  });

  test('has no accessibility violations (a11y)', async ({ page, context }) => {
    // ... launch app ...
    const results = await new AxeBuilder({ page }).withTags(['wcag2aa']).analyze();
    expect(results.violations.filter((v) => v.impact === 'critical')).toEqual([]);
  });
});

次のステップ

こちらも参照ください