Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
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([]);
});
});