Edit

Advanced testing capabilities

Beyond functional tests, Playwright provides built-in support for visual regression testing, network request mocking, and accessibility auditing. This article shows how to apply each to Power Platform app tests.

Visual comparison testing

Playwright's toHaveScreenshot() assertion captures a baseline screenshot on the first run and compares subsequent runs against it. Pixel-level differences fail the test.

Capture a canvas app baseline

The following example launches a canvas app and captures a screenshot of the gallery control to establish a visual baseline for future comparison.

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

On the first run, Playwright writes the baseline screenshot to tests/__screenshots__/. Commit these files to source control. Subsequent runs diff against them.

Update baselines

When the UI intentionally changes, update the baselines:

npx playwright test --update-snapshots

Configure screenshot thresholds

Allow a small pixel difference to accommodate font rendering across environments:

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

Compare model-driven app views

For model-driven apps, scope the screenshot to avoid capturing dynamic timestamps:

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

Network request mocking

Playwright's page.route() intercepts HTTP requests. Use it to mock Dataverse API responses, simulate error conditions, or speed up tests that don't need live data.

Mock a Dataverse WebApi response

The following example intercepts a Dataverse WebAPI call and returns a mocked JSON response, so you can test app behavior without relying on live data.

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

Simulate an API error

The following example simulates a server error by returning a 500 status code, so you can verify that the app displays the appropriate error state.

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

Intercept and observe requests (without mocking)

The following example listens for outgoing POST requests to the Dataverse API without altering them, so you can verify that the app sends the expected requests when a user action occurs.

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

Combine request observation with mock fulfillment to validate that the correct OData query is sent to Dataverse. This approach is useful for verifying that filters and expands are constructed correctly.

Accessibility testing

Playwright integrates with axe-core through the @axe-core/playwright package to audit pages for Web Content Accessibility Guidelines (WCAG) compliance.

Install axe-core for Playwright

Run the following command to add the axe-core Playwright package as a dev dependency in your test project.

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

Audit a canvas app for accessibility violations

The following example launches a canvas app and runs an axe-core audit scoped to WCAG 2.0 Level A and AA rules. The test fails if it finds any violations.

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

Audit a model-driven app form

The following example opens a record in a model-driven app and runs an accessibility audit. It filters the results to only critical and serious violations.

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

Report accessibility violations

To get readable output when violations are found, format them in the test output.

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

Exclude known violations

If your app has known violations that you accept or track separately:

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

Important

Disabling rules should be temporary. Track each disabled rule with a work item reference so it gets fixed.

Combine capabilities

You can combine visual, network, and accessibility tests in the same test file. A common pattern is a smoke test suite that runs all three.

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

Next steps

See also