Applied Module 12 · AI-Accelerated Government Development

Test End-to-End with Playwright

What you'll learn

~35 min
  • Configure Playwright with test fixtures for authenticated sessions against MSAL
  • Write E2E tests that verify RBAC enforcement across the 5-role matrix
  • Test DensityGate view state switching between executive, operational, and technical tiers
  • Verify mutation feedback UX: snackbar appears on success and error

Why E2E on this platform

Vitest proved that your route handlers return the right status codes. Playwright proves that a real browser, hitting a real server, with a real MSAL session, behaves the way your users expect. That means:

  • A viewer logs in and sees the facilities list but cannot see the “Delete” button.
  • An operator clicks “Create Facility,” fills out the form, submits, and sees a success snackbar.
  • Switching from executive to technical DensityGate view actually reveals the SQL query panel.
  • A token refresh mid-session does not dump the user to the login page.

These are the scenarios your users file tickets about. They are also the scenarios state auditors ask you to demonstrate. Automated E2E tests let you demonstrate them on demand.

Playwright vs Cypress

Both work. Playwright ships with multi-browser support, automatic waiting, and built-in trace recording. For Azure Gov environments where you need to test in specific Chromium versions deployed to locked-down machines, Playwright’s browser download management is more predictable. If your team already uses Cypress, the test structure in this lesson translates directly.


Set up Playwright with auth fixtures

The hardest part of E2E testing on a government platform is authentication. You cannot hard-code credentials. You cannot bypass MSAL in production. What you can do is store an authenticated session state and reuse it across tests.

Here is the prompt:

Set up Playwright for our Next.js 15.5 project with MSAL authentication.
Requirements:
1. playwright.config.ts:
- baseURL from PLAYWRIGHT_BASE_URL env var (default: http://localhost:3000)
- Projects: chromium only (our users are on Edge/Chrome, no Firefox/Safari needed)
- Global setup file that authenticates once and saves storage state
- Retry: 1 on CI, 0 locally
- Reporter: html locally, junit on CI for Azure DevOps integration
2. Global setup (e2e/global-setup.ts):
- Launch browser, navigate to /login
- Fill in test credentials from PLAYWRIGHT_TEST_USER and PLAYWRIGHT_TEST_PASS env vars
- Complete MSAL login flow (handle the Microsoft redirect)
- Wait for redirect back to our app (URL contains /dashboard)
- Save storage state to e2e/.auth/user.json
- Create a second auth state for admin: PLAYWRIGHT_ADMIN_USER/PLAYWRIGHT_ADMIN_PASS
saved to e2e/.auth/admin.json
3. Test fixtures (e2e/fixtures.ts):
- Export a 'test' that extends base test with:
- viewerPage: uses e2e/.auth/viewer.json storage state
- operatorPage: uses e2e/.auth/operator.json storage state
- adminPage: uses e2e/.auth/admin.json storage state
- Each fixture creates a new context with the stored auth state
4. .env.example showing all required PLAYWRIGHT_* variables
Our app uses next-auth v5 with JWT strategy. The session cookie is
'next-auth.session-token'. After MSAL redirect, next-auth sets this cookie
and the user lands on /dashboard.
Claude Code — Playwright Setup
/home/dev/ds-platform $ claude 'Set up Playwright for our Next.js 15.5 MSAL app with role-based auth fixtures'
I'll set up Playwright with authentication fixtures for each role. Created: playwright.config.ts (38 lines) Created: e2e/global-setup.ts (67 lines) Created: e2e/fixtures.ts (45 lines) Created: e2e/.env.example (8 lines) Created: e2e/.auth/.gitkeep Updated: .gitignore — added e2e/.auth/*.json The setup: - Global setup authenticates 3 roles and saves storage state - Fixtures provide viewerPage, operatorPage, adminPage - PLAYWRIGHT_BASE_URL defaults to localhost:3000 - Auth state files are gitignored (contain session tokens)
/home/dev/ds-platform $ npx playwright install chromium
Downloading Chromium 131.0.6778.33 (playwright build v1148) 100% [========================================] Browsers installed.
/home/dev/ds-platform $
Never commit auth state files

e2e/.auth/*.json contains session tokens. Add e2e/.auth/ to your .gitignore. If you are running Playwright in Azure Pipelines, the global setup runs fresh on every pipeline execution — no stored tokens persist between runs.


Test RBAC enforcement

The 5-role matrix (viewer, operator, manager, admin, superadmin) is the most audit-sensitive piece of the platform. One missing guard and a viewer can delete records. E2E tests prove the guards work in a real browser.

Write Playwright E2E tests for RBAC enforcement on the /facilities page.
Test cases:
1. Viewer can see the facilities list table
2. Viewer does NOT see the "Create Facility" button
3. Viewer navigating directly to /facilities/new gets redirected to /facilities
4. Operator sees and can click "Create Facility"
5. Operator does NOT see the "Delete" button on facility rows
6. Admin sees the "Delete" button and can open the confirmation dialog
7. Unauthenticated user navigating to /facilities gets redirected to /login
Use the role-based fixtures from e2e/fixtures.ts (viewerPage, operatorPage,
adminPage). For the unauthenticated test, use a fresh browser context with
no storage state.
Our MUI DataGrid renders facility rows with data-testid="facility-row-{id}".
The Create button has data-testid="create-facility-btn".
The Delete button has data-testid="delete-facility-{id}".

The AI will generate tests following this pattern:

import { test, expect } from './fixtures';
test.describe('RBAC — Facilities', () => {
test('viewer cannot see Create button', async ({ viewerPage }) => {
await viewerPage.goto('/facilities');
await expect(viewerPage.getByTestId('create-facility-btn')).not.toBeVisible();
});
test('viewer direct navigation to /new redirects', async ({ viewerPage }) => {
await viewerPage.goto('/facilities/new');
await expect(viewerPage).toHaveURL(/\/facilities$/);
});
test('operator can access Create', async ({ operatorPage }) => {
await operatorPage.goto('/facilities');
await expect(operatorPage.getByTestId('create-facility-btn')).toBeVisible();
});
test('operator cannot see Delete', async ({ operatorPage }) => {
await operatorPage.goto('/facilities');
await expect(operatorPage.getByTestId(/^delete-facility-/)).not.toBeVisible();
});
test('admin can see Delete', async ({ adminPage }) => {
await adminPage.goto('/facilities');
await expect(adminPage.getByTestId(/^delete-facility-/).first()).toBeVisible();
});
});
💡data-testid is the contract

MUI components accept data-testid props. Use them consistently. Selectors like button:has-text("Delete") break when someone changes the button label. data-testid="delete-facility-42" survives label changes, icon swaps, and i18n. Make data-testid a standard in your team’s PR review checklist.


Test DensityGate view states

DensityGate is the DS platform’s progressive disclosure component. Each page has three tiers: executive (high-level KPIs), operational (actionable tables), and technical (SQL queries, raw data, debug info). Switching tiers changes what is visible. E2E tests verify each tier renders its content and hides the others.

Write Playwright tests for DensityGate on /facilities:
1. Default view is 'executive' — shows the summary KPI cards
2. Clicking the 'Operational' tab shows the DataGrid table
3. Clicking the 'Technical' tab shows the SQL query panel
4. Switching from Technical back to Executive hides the SQL panel
5. The selected tier persists after page reload (stored in localStorage)
The DensityGate tabs have data-testid="density-tab-executive",
"density-tab-operational", "density-tab-technical".
The KPI panel: data-testid="kpi-panel"
The DataGrid: data-testid="facilities-grid"
The SQL panel: data-testid="sql-panel"
test('technical view reveals SQL panel', async ({ operatorPage }) => {
await operatorPage.goto('/facilities');
await operatorPage.getByTestId('density-tab-technical').click();
await expect(operatorPage.getByTestId('sql-panel')).toBeVisible();
await expect(operatorPage.getByTestId('kpi-panel')).not.toBeVisible();
});
test('tier persists after reload', async ({ operatorPage }) => {
await operatorPage.goto('/facilities');
await operatorPage.getByTestId('density-tab-operational').click();
await operatorPage.reload();
await expect(operatorPage.getByTestId('density-tab-operational'))
.toHaveAttribute('aria-selected', 'true');
await expect(operatorPage.getByTestId('facilities-grid')).toBeVisible();
});

Test mutation feedback

Every mutation in the DS platform should show a snackbar: green on success, red on error. If a create operation succeeds and the user sees nothing, they will click the button again. If it fails silently, they will file a ticket two weeks later when the data is missing.

Write Playwright tests for mutation feedback on /facilities:
1. Create facility: fill form, submit, verify success snackbar appears
with text "Facility created"
2. Create with duplicate code: submit, verify error snackbar with
"A facility with this code already exists"
3. Snackbar auto-dismisses after 6 seconds
4. After successful create, the new facility appears in the DataGrid
without manual refresh (SWR revalidation)
MUI Snackbar renders with role="alert". Success snackbar has
data-testid="snackbar-success", error has data-testid="snackbar-error".
test('successful create shows snackbar and updates grid', async ({ operatorPage }) => {
await operatorPage.goto('/facilities/new');
await operatorPage.getByLabel('Facility Name').fill('Test Lab');
await operatorPage.getByLabel('Facility Code').fill('TLAB');
await operatorPage.getByTestId('submit-facility').click();
// Verify snackbar
const snackbar = operatorPage.getByTestId('snackbar-success');
await expect(snackbar).toBeVisible();
await expect(snackbar).toContainText('Facility created');
// Verify SWR revalidation — grid updates without refresh
await expect(operatorPage).toHaveURL(/\/facilities$/);
await expect(operatorPage.getByText('Test Lab')).toBeVisible();
});
Environment switching with PLAYWRIGHT_BASE_URL

Your CI pipeline runs tests against a staging deployment on Azure Gov. Locally, you run against localhost:3000. The PLAYWRIGHT_BASE_URL env var handles this. In Azure Pipelines, set it as a pipeline variable pointing to your staging App Service URL. Locally, leave it unset and the default kicks in.


Run E2E tests

Claude Code — Playwright E2E Run
/home/dev/ds-platform $ PLAYWRIGHT_BASE_URL=http://localhost:3000 npx playwright test
Running 12 tests using 1 worker ✓ e2e/rbac.spec.ts:8 — viewer can see facilities list (1.2s) ✓ e2e/rbac.spec.ts:14 — viewer cannot see Create button (0.9s) ✓ e2e/rbac.spec.ts:19 — viewer redirect from /new (1.1s) ✓ e2e/rbac.spec.ts:25 — operator can access Create (1.0s) ✓ e2e/rbac.spec.ts:30 — operator cannot see Delete (0.8s) ✓ e2e/rbac.spec.ts:35 — admin can see Delete (0.9s) ✓ e2e/rbac.spec.ts:40 — unauth redirects to login (0.7s) ✓ e2e/density.spec.ts:8 — default view is executive (1.3s) ✓ e2e/density.spec.ts:14 — technical view reveals SQL panel (1.4s) ✓ e2e/density.spec.ts:22 — tier persists after reload (1.8s) ✓ e2e/mutations.spec.ts:8 — create shows snackbar and updates grid (2.1s) ✓ e2e/mutations.spec.ts:20 — duplicate code shows error snackbar (1.6s) 12 passed (14.8s)
/home/dev/ds-platform $ npx playwright show-report
Serving HTML report at http://localhost:9323 Opening in browser...
/home/dev/ds-platform $
💡Trace on failure

Add trace: 'on-first-retry' to your Playwright config. When a test fails and retries, Playwright records a full trace — DOM snapshots, network requests, console logs. You can replay the entire session in the Trace Viewer. This is invaluable when a test passes locally but fails in Azure Pipelines.


The test pyramid for a DS platform module

Here is how Vitest (Lesson 12) and Playwright fit together:

LayerToolWhat it coversRun time
UnitVitest (Node)Response helpers, pagination, Zod schemas, safe errors< 1 second
IntegrationVitest (Node)Route handlers with mocked DB and real withPermission1-2 seconds
ComponentVitest (jsdom)React components with mocked data2-3 seconds
E2EPlaywrightAuth flows, RBAC in browser, mutations, DensityGate15-30 seconds

Run unit and integration tests on every save. Run E2E tests before every push and in the Azure Pipeline. The pyramid is narrow at the top for a reason — E2E tests are slow but irreplaceable for the scenarios that matter most.


KNOWLEDGE CHECK

Your Playwright E2E test needs to verify that a 'viewer' user cannot delete a facility. Which approach is correct?


Key takeaways

  • Storage state reuse is the key to fast E2E tests with MSAL. Authenticate once in global setup, reuse the session across all tests. No login flow repeated per test.
  • RBAC E2E tests prove what unit tests cannot — that the button is hidden, the route redirects, the entire auth chain works from browser to server.
  • DensityGate view state tests catch regressions in progressive disclosure, which is the primary UX pattern your stakeholders interact with.
  • Mutation feedback tests verify that success and error states are visible to the user. Silent mutations are the #1 source of “the data is missing” tickets.
  • PLAYWRIGHT_BASE_URL lets the same test suite run locally and in staging without changing test code.

What’s next

Your tests verify that the application works. In the next lesson, you will verify that it is accessible — WCAG 2.1 AA compliance, MUI 7 keyboard navigation, color contrast in dark mode, and automated axe-core audits integrated with Playwright.