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.
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 cookieand the user lands on /dashboard.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 table2. Viewer does NOT see the "Create Facility" button3. Viewer navigating directly to /facilities/new gets redirected to /facilities4. Operator sees and can click "Create Facility"5. Operator does NOT see the "Delete" button on facility rows6. Admin sees the "Delete" button and can open the confirmation dialog7. 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 withno 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(); });});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 cards2. Clicking the 'Operational' tab shows the DataGrid table3. Clicking the 'Technical' tab shows the SQL query panel4. Switching from Technical back to Executive hides the SQL panel5. 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 seconds4. After successful create, the new facility appears in the DataGrid without manual refresh (SWR revalidation)
MUI Snackbar renders with role="alert". Success snackbar hasdata-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();});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
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:
| Layer | Tool | What it covers | Run time |
|---|---|---|---|
| Unit | Vitest (Node) | Response helpers, pagination, Zod schemas, safe errors | < 1 second |
| Integration | Vitest (Node) | Route handlers with mocked DB and real withPermission | 1-2 seconds |
| Component | Vitest (jsdom) | React components with mocked data | 2-3 seconds |
| E2E | Playwright | Auth flows, RBAC in browser, mutations, DensityGate | 15-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.
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_URLlets 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.